├── .cargo └── config.toml ├── .github ├── release-drafter.yml └── workflows │ ├── build.yml │ ├── release-drafter.yml │ └── update_python_test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── SECURITY.md ├── build.rs ├── ci ├── Vagrantfile ├── publish_freebsd.sh ├── test_freebsd.sh ├── testdata │ ├── cython_test.c │ └── cython_test.pyx └── update_python_test_versions.py ├── examples └── dump_traces.rs ├── generate_bindings.py ├── images ├── console_viewer.gif ├── dump.png └── flamegraph.svg ├── pyproject.toml ├── setup.cfg ├── src ├── binary_parser.rs ├── chrometrace.rs ├── config.rs ├── console_viewer.rs ├── coredump.rs ├── cython.rs ├── dump.rs ├── flamegraph.rs ├── lib.rs ├── main.rs ├── native_stack_trace.rs ├── python_bindings │ ├── mod.rs │ ├── v2_7_15.rs │ ├── v3_10_0.rs │ ├── v3_11_0.rs │ ├── v3_12_0.rs │ ├── v3_13_0.rs │ ├── v3_3_7.rs │ ├── v3_4_8.rs │ ├── v3_5_5.rs │ ├── v3_6_6.rs │ ├── v3_7_0.rs │ ├── v3_8_0.rs │ └── v3_9_5.rs ├── python_data_access.rs ├── python_interpreters.rs ├── python_process_info.rs ├── python_spy.rs ├── python_threading.rs ├── sampler.rs ├── speedscope.rs ├── stack_trace.rs ├── timer.rs ├── utils.rs └── version.rs └── tests ├── integration_test.py ├── integration_test.rs └── scripts ├── busyloop.py ├── cyrillic.py ├── delayed_launch.sh ├── local_vars.py ├── longsleep.py ├── negative_linenumber_offsets.py ├── recursive.py ├── subprocesses.py ├── subprocesses_zombie_child.py ├── thread_names.py ├── thread_reuse.py └── unicode💩.py /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.armv7-unknown-linux-gnueabihf] 2 | linker = "arm-linux-gnueabihf-gcc" 3 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | categories: 2 | - title: "⚠ Breaking Changes" 3 | labels: 4 | - "breaking" 5 | - title: "🚀 Features" 6 | labels: 7 | - "feature" 8 | - "enhancement" 9 | - title: "🐛 Bug Fixes" 10 | labels: 11 | - "fix" 12 | - "bugfix" 13 | - "bug" 14 | - title: "📄 Documentation" 15 | labels: 16 | - "documentation" 17 | - title: "🧰 Maintenance" 18 | label: 19 | - "chore" 20 | - "ci" 21 | - "dependencies" 22 | 23 | exclude-labels: 24 | - "skip-changelog" 25 | 26 | change-template: "- $TITLE @$AUTHOR (#$NUMBER)" 27 | change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. 28 | version-resolver: 29 | major: 30 | labels: 31 | - "major" 32 | minor: 33 | labels: 34 | - "minor" 35 | patch: 36 | labels: 37 | - "patch" 38 | default: patch 39 | template: | 40 | ## Changes 41 | 42 | $CHANGES 43 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [master] 7 | tags: 8 | - v* 9 | pull_request: 10 | branches: [master] 11 | 12 | env: 13 | CARGO_TERM_COLOR: always 14 | 15 | jobs: 16 | lint: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v3 20 | - uses: actions/setup-python@v4 21 | with: 22 | python-version: 3.11 23 | - uses: pre-commit/action@v3.0.0 24 | 25 | build: 26 | runs-on: ${{ matrix.os }} 27 | needs: [lint] 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | os: [ubuntu-latest, windows-latest, macos-latest] 32 | steps: 33 | - uses: actions/checkout@v3 34 | - uses: Swatinem/rust-cache@v2 35 | - name: Install Dependencies 36 | run: sudo apt install libunwind-dev 37 | if: runner.os == 'Linux' 38 | - uses: actions/setup-python@v4 39 | with: 40 | python-version: 3.9 41 | - name: Build 42 | run: cargo build --release --verbose --examples 43 | - uses: actions/setup-python@v4 44 | with: 45 | python-version: 3.9 46 | - name: Test 47 | id: test 48 | continue-on-error: true 49 | run: cargo test --release 50 | - name: Test (retry#1) 51 | id: test1 52 | run: cargo test --release 53 | if: steps.test.outcome=='failure' 54 | continue-on-error: true 55 | - name: Test (retry#2) 56 | run: cargo test --release 57 | if: steps.test1.outcome=='failure' 58 | - name: Build Wheel 59 | run: | 60 | pip install --upgrade maturin 61 | maturin build --release -o dist --all-features 62 | if: runner.os == 'Windows' 63 | - name: Build Wheel - universal2 64 | env: 65 | DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer 66 | SDKROOT: /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk 67 | MACOSX_DEPLOYMENT_TARGET: 10.9 68 | run: | 69 | rustup target add aarch64-apple-darwin 70 | rustup target add x86_64-apple-darwin 71 | pip install --upgrade maturin 72 | maturin build --release -o dist 73 | maturin build --release -o dist --target universal2-apple-darwin 74 | if: matrix.os == 'macos-latest' 75 | - name: Rename Wheels 76 | run: | 77 | python3 -c "import shutil; import glob; wheels = glob.glob('dist/*.whl'); [shutil.move(wheel, wheel.replace('py3', 'py2.py3')) for wheel in wheels if 'py2' not in wheel]" 78 | if: runner.os != 'Linux' 79 | - name: Upload wheels 80 | uses: actions/upload-artifact@v3 81 | with: 82 | name: wheels 83 | path: dist 84 | if: runner.os != 'Linux' 85 | 86 | build-linux-cross: 87 | runs-on: ubuntu-latest 88 | needs: [lint] 89 | strategy: 90 | fail-fast: false 91 | matrix: 92 | target: 93 | [ 94 | i686-unknown-linux-musl, 95 | armv7-unknown-linux-musleabihf, 96 | aarch64-unknown-linux-musl, 97 | x86_64-unknown-linux-musl, 98 | ] 99 | container: 100 | image: ghcr.io/benfred/rust-musl-cross:${{ matrix.target }} 101 | env: 102 | RUSTUP_HOME: /root/.rustup 103 | CARGO_HOME: /root/.cargo 104 | steps: 105 | - uses: actions/checkout@v3 106 | - uses: Swatinem/rust-cache@v2 107 | - name: Build 108 | run: | 109 | python3 -m pip install --upgrade maturin 110 | maturin build --release -o dist --target ${{ matrix.target }} --features unwind 111 | maturin sdist -o dist 112 | if: matrix.target == 'x86_64-unknown-linux-musl' 113 | - name: Build 114 | run: | 115 | python3 -m pip install --upgrade maturin 116 | maturin build --release -o dist --target ${{ matrix.target }} 117 | maturin sdist -o dist 118 | if: matrix.target != 'x86_64-unknown-linux-musl' 119 | - name: Rename Wheels 120 | run: | 121 | python3 -c "import shutil; import glob; wheels = glob.glob('dist/*.whl'); [shutil.move(wheel, wheel.replace('py3', 'py2.py3')) for wheel in wheels if 'py2' not in wheel]" 122 | - name: Upload wheels 123 | uses: actions/upload-artifact@v3 124 | with: 125 | name: wheels 126 | path: dist 127 | 128 | build-freebsd: 129 | runs-on: ubuntu-22.04 130 | needs: [lint] 131 | timeout-minutes: 30 132 | strategy: 133 | matrix: 134 | box: 135 | - freebsd-14 136 | steps: 137 | - uses: actions/checkout@v3 138 | - name: Cache Vagrant box 139 | uses: actions/cache@v3.0.4 140 | with: 141 | path: .vagrant.d 142 | key: ${{ matrix.box }}-vagrant-boxes-20231115-${{ hashFiles('ci/Vagrantfile') }} 143 | restore-keys: | 144 | ${{ matrix.box }}-vagrant-boxes-20231115- 145 | - name: Cache Cargo and build artifacts 146 | uses: actions/cache@v3.0.4 147 | with: 148 | path: build-artifacts.tar 149 | key: ${{ matrix.box }}-cargo-20231115-${{ hashFiles('**/Cargo.lock') }} 150 | restore-keys: | 151 | ${{ matrix.box }}-cargo-20231115- 152 | - name: Display CPU info 153 | run: lscpu 154 | - name: Install VM tools 155 | run: | 156 | sudo apt-get update -qq 157 | sudo apt-get install -qq -o=Dpkg::Use-Pty=0 moreutils 158 | sudo chronic apt-get install -qq -o=Dpkg::Use-Pty=0 vagrant virtualbox qemu libvirt-daemon-system 159 | - name: Set up VM 160 | shell: sudo bash {0} 161 | run: | 162 | vagrant plugin install vagrant-libvirt 163 | vagrant plugin install vagrant-scp 164 | ln -sf ci/Vagrantfile Vagrantfile 165 | vagrant status 166 | vagrant up --no-tty --provider libvirt ${{ matrix.box }} 167 | - name: Build and test 168 | shell: sudo bash {0} 169 | run: vagrant ssh ${{ matrix.box }} -- bash /vagrant/ci/test_freebsd.sh 170 | - name: Retrieve build artifacts for caching purposes 171 | shell: sudo bash {0} 172 | run: | 173 | vagrant scp ${{ matrix.box }}:/vagrant/build-artifacts.tar build-artifacts.tar 174 | ls -ahl build-artifacts.tar 175 | - name: Prepare binary for upload 176 | run: | 177 | tar xf build-artifacts.tar target/release/py-spy 178 | mv target/release/py-spy py-spy-x86_64-unknown-freebsd 179 | - name: Upload Binaries 180 | uses: actions/upload-artifact@v3 181 | with: 182 | name: py-spy-x86_64-unknown-freebsd 183 | path: py-spy-x86_64-unknown-freebsd 184 | 185 | test-wheels: 186 | name: Test Wheels 187 | needs: [build, build-linux-cross] 188 | runs-on: ${{ matrix.os }} 189 | strategy: 190 | fail-fast: false 191 | # automatically generated by ci/update_python_test_versions.py 192 | matrix: 193 | python-version: 194 | [ 195 | 3.6.7, 196 | 3.6.15, 197 | 3.7.1, 198 | 3.7.17, 199 | 3.8.0, 200 | 3.8.18, 201 | 3.9.0, 202 | 3.9.20, 203 | 3.10.0, 204 | 3.10.15, 205 | 3.11.0, 206 | 3.11.10, 207 | 3.12.0, 208 | 3.12.1, 209 | 3.12.2, 210 | 3.12.3, 211 | 3.12.4, 212 | 3.12.5, 213 | 3.12.6, 214 | 3.12.7, 215 | 3.13.0, 216 | ] 217 | # TODO: also test windows 218 | os: [ubuntu-20.04, macos-13, windows-latest] 219 | # some versions of python can't be tested on GHA with osx because of SIP: 220 | exclude: 221 | - os: windows-latest 222 | python-version: 3.6.15 223 | - os: windows-latest 224 | python-version: 3.7.17 225 | - os: windows-latest 226 | python-version: 3.8.18 227 | - os: windows-latest 228 | python-version: 3.9.20 229 | - os: windows-latest 230 | python-version: 3.10.15 231 | - os: macos-13 232 | python-version: 3.11.10 233 | - os: windows-latest 234 | python-version: 3.11.10 235 | - os: macos-13 236 | python-version: 3.12.0 237 | - os: windows-latest 238 | python-version: 3.12.0 239 | - os: macos-13 240 | python-version: 3.12.1 241 | - os: windows-latest 242 | python-version: 3.12.1 243 | - os: macos-13 244 | python-version: 3.12.2 245 | - os: windows-latest 246 | python-version: 3.12.2 247 | - os: macos-13 248 | python-version: 3.12.3 249 | - os: windows-latest 250 | python-version: 3.12.3 251 | - os: macos-13 252 | python-version: 3.12.4 253 | - os: windows-latest 254 | python-version: 3.12.4 255 | - os: macos-13 256 | python-version: 3.12.5 257 | - os: windows-latest 258 | python-version: 3.12.5 259 | - os: macos-13 260 | python-version: 3.12.6 261 | - os: windows-latest 262 | python-version: 3.12.6 263 | - os: macos-13 264 | python-version: 3.12.7 265 | - os: windows-latest 266 | python-version: 3.12.7 267 | 268 | steps: 269 | - uses: actions/checkout@v2 270 | - uses: actions/download-artifact@v3 271 | with: 272 | name: wheels 273 | - uses: actions/setup-python@v4 274 | with: 275 | python-version: ${{ matrix.python-version }} 276 | - name: Install wheel 277 | run: | 278 | pip install --force-reinstall --no-index --find-links . py-spy 279 | - name: Test Wheel 280 | id: test 281 | run: python tests/integration_test.py 282 | if: runner.os != 'macOS' 283 | continue-on-error: true 284 | - name: Test Wheel (Retry#1) 285 | id: test1 286 | run: python tests/integration_test.py 287 | if: steps.test.outcome=='failure' 288 | continue-on-error: true 289 | - name: Test Wheel (Retry#2) 290 | id: test2 291 | run: python tests/integration_test.py 292 | if: steps.test1.outcome=='failure' 293 | - name: Test macOS Wheel 294 | id: osx_test 295 | run: sudo "PATH=$PATH" python tests/integration_test.py 296 | if: runner.os == 'macOS' 297 | continue-on-error: true 298 | - name: Test macOS Wheel (Retry#1) 299 | id: osx_test1 300 | run: sudo "PATH=$PATH" python tests/integration_test.py 301 | if: steps.osx_test.outcome=='failure' 302 | continue-on-error: true 303 | - name: Test macOS Wheel (Retry#2) 304 | id: osx_test2 305 | run: sudo "PATH=$PATH" python tests/integration_test.py 306 | if: steps.osx_test1.outcome=='failure' 307 | 308 | release: 309 | name: Release 310 | runs-on: ubuntu-latest 311 | if: "startsWith(github.ref, 'refs/tags/')" 312 | needs: [test-wheels] 313 | steps: 314 | - uses: actions/download-artifact@v3 315 | with: 316 | name: wheels 317 | - name: Create GitHub Release 318 | uses: fnkr/github-action-ghr@v1.3 319 | env: 320 | GHR_PATH: . 321 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 322 | - name: Install Dependencies 323 | run: sudo apt install libunwind-dev 324 | if: runner.os == 'Linux' 325 | - uses: actions/setup-python@v4 326 | with: 327 | python-version: 3.9 328 | - name: Push to PyPi 329 | env: 330 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 331 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 332 | run: | 333 | pip install --upgrade wheel pip setuptools twine 334 | twine upload * 335 | rm * 336 | - uses: actions/checkout@v3 337 | - name: Push to crates.io 338 | env: 339 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 340 | run: cargo publish 341 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | # draft release notes with https://github.com/release-drafter/release-drafter 2 | name: Release Drafter 3 | 4 | on: 5 | push: 6 | branches: 7 | - master 8 | workflow_dispatch: 9 | 10 | jobs: 11 | update_release_draft: 12 | runs-on: ubuntu-latest 13 | steps: 14 | # Drafts your next Release notes as Pull Requests are merged into "master" 15 | - uses: release-drafter/release-drafter@v5 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/workflows/update_python_test.yml: -------------------------------------------------------------------------------- 1 | name: Update Python Test Versions 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: "0 1 * * *" 6 | jobs: 7 | update-dep: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | with: 12 | ssh-key: ${{ secrets.SSH_PRIVATE_KEY }} 13 | - uses: actions/setup-python@v4 14 | with: 15 | python-version: 3.9 16 | - name: Install 17 | run: pip install --upgrade requests pyyaml 18 | - name: Scan for new python versions 19 | run: python ci/update_python_test_versions.py 20 | - name: Format results 21 | run: npx prettier --write ".github/workflows/update_python_test.yml" 22 | - name: Create Pull Request 23 | uses: peter-evans/create-pull-request@v4 24 | with: 25 | commit-message: Update tested python versions 26 | title: Update tested python versions 27 | branch: update-python-versions 28 | labels: | 29 | skip-changelog 30 | dependencies 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | remoteprocess/target 3 | **/*.rs.bk 4 | 5 | # Python Distribution / packaging 6 | .Python 7 | env/ 8 | build/ 9 | develop-eggs/ 10 | dist/ 11 | downloads/ 12 | eggs/ 13 | .eggs/ 14 | lib/ 15 | lib64/ 16 | parts/ 17 | sdist/ 18 | var/ 19 | *.egg-info/ 20 | .installed.cfg 21 | *.egg 22 | 23 | .vscode/ 24 | **/.vagrant 25 | *.log 26 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/codespell-project/codespell 3 | rev: v2.2.4 4 | hooks: 5 | - id: codespell 6 | additional_dependencies: [tomli] 7 | args: ["--toml", "pyproject.toml"] 8 | exclude: (?x)^(ci/testdata.*|images.*)$ 9 | - repo: https://github.com/doublify/pre-commit-rust 10 | rev: v1.0 11 | hooks: 12 | - id: fmt 13 | - id: cargo-check 14 | - repo: local 15 | hooks: 16 | - id: cargo-clippy 17 | name: cargo clippy 18 | entry: cargo clippy -- -D warnings 19 | language: system 20 | files: \.rs$ 21 | pass_filenames: false 22 | - repo: https://github.com/rbubley/mirrors-prettier 23 | rev: v3.3.3 24 | hooks: 25 | - id: prettier 26 | types: [yaml] 27 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Release notes are now being hosted in Github Releases: https://github.com/benfred/py-spy/releases 2 | 3 | ## v0.3.11 4 | 5 | * Update dependencies [#463](https://github.com/benfred/py-spy/pull/463), [#457](https://github.com/benfred/py-spy/pull/463) 6 | * Warn about SYS_PTRACE when running in docker [#459](https://github.com/benfred/py-spy/pull/459) 7 | * Fix spelling mistakes [#453](https://github.com/benfred/py-spy/pull/453) 8 | 9 | ## v0.3.10 10 | * Add support for profiling Python v3.10 [#425](https://github.com/benfred/py-spy/pull/425) 11 | * Fix issue with native profiling on Linux with Anaconda [#447](https://github.com/benfred/py-spy/pull/447) 12 | 13 | ## v0.3.9 14 | * Add a subcommand to generate shell completions [#427](https://github.com/benfred/py-spy/issues/427) 15 | * Allow attaching co_firstlineno to frame name [#428](https://github.com/benfred/py-spy/issues/428) 16 | * Fix speedscope time interval [#434](https://github.com/benfred/py-spy/issues/434) 17 | * Fix profiling on FreeBSD [#431](https://github.com/benfred/py-spy/issues/431) 18 | * Use GitHub actions for FreeBSD CI [#433](https://github.com/benfred/py-spy/issues/433) 19 | 20 | ## v0.3.8 21 | * Add wheels for Apple Silicon [#419](https://github.com/benfred/py-spy/issues/419) 22 | * Add --gil and --idle options to top view [#406](https://github.com/benfred/py-spy/issues/406) 23 | * Fix errors parsing python binaries [#407](https://github.com/benfred/py-spy/issues/407) 24 | * Specify timeunit in speedscope profiles [#294](https://github.com/benfred/py-spy/issues/294) 25 | 26 | ## v0.3.7 27 | * Fix error that sometimes left the profiled program suspended [#390](https://github.com/benfred/py-spy/issues/390) 28 | * Documentation fixes for README [#391](https://github.com/benfred/py-spy/issues/391), [#393](https://github.com/benfred/py-spy/issues/393) 29 | 30 | ## v0.3.6 31 | * Fix profiling inside a venv on windows [#216](https://github.com/benfred/py-spy/issues/216) 32 | * Detect GIL on Python 3.9.3+, 3.8.9+ [#375](https://github.com/benfred/py-spy/issues/375) 33 | * Fix getting thread names on python 3.9 [#387](https://github.com/benfred/py-spy/issues/387) 34 | * Fix getting thread names on ARMv7 [#388](https://github.com/benfred/py-spy/issues/388) 35 | * Add python integration tests, and test wheels across a range of different python versions [#378](https://github.com/benfred/py-spy/pull/378) 36 | * Automatically add tests for new versions of python [#379](https://github.com/benfred/py-spy/pull/379) 37 | 38 | ## v0.3.5 39 | * Handle case where linux kernel is compiled without ```process_vm_readv``` support [#22](https://github.com/benfred/py-spy/issues/22) 40 | * Handle case where /proc/self/ns/mnt is missing [#326](https://github.com/benfred/py-spy/issues/326) 41 | * Allow attaching to processes where the python binary has been deleted [#109](https://github.com/benfred/py-spy/issues/109) 42 | * Make '--output' optional [#229](https://github.com/benfred/py-spy/issues/229) 43 | * Add --full-filenames to allow showing full Python filenames [#363](https://github.com/benfred/py-spy/issues/363) 44 | * Count "samples" as the number of recorded stacks (per thread) [#365](https://github.com/benfred/py-spy/issues/365) 45 | * Exit with an error if --gil but we failed to get necessary addrs/offsets [#361](https://github.com/benfred/py-spy/pull/361) 46 | * Include command/options used to run py-spy in flamegraph output [#293](https://github.com/benfred/py-spy/issues/293) 47 | * GIL Detection fixes for python 3.9.2/3.8.8 [#362](https://github.com/benfred/py-spy/pull/362) 48 | * Move to Github Actions for CI 49 | 50 | ## v0.3.4 51 | * Build armv7/aarch64 wheels [#328](https://github.com/benfred/py-spy/issues/328) 52 | * Detect GIL on Python 3.9 / 3.7.7+ / 3.8.2+ 53 | * Add option for more verbose local variables [#287](https://github.com/benfred/py-spy/issues/287) 54 | * Fix issues with profiling subprocesses [#265](https://github.com/benfred/py-spy/issues/265) 55 | * Include python thread names in record [#237](https://github.com/benfred/py-spy/issues/237) 56 | * Fix issue with threadids triggering differential flamegraphs [#234](https://github.com/benfred/py-spy/issues/234) 57 | 58 | ## v0.3.3 59 | 60 | * Change to display stdout/stderr from profiled child process [#217](https://github.com/benfred/py-spy/issues/217) 61 | * Fix memory leak on OSX [#227](https://github.com/benfred/py-spy/issues/227) 62 | * Fix panic on dump --locals [#224](https://github.com/benfred/py-spy/issues/224) 63 | * Fix cross container short filename generation [#220](https://github.com/benfred/py-spy/issues/220) 64 | 65 | ## v0.3.2 66 | 67 | * Fix line numbers on python 3.8+ [#190](https://github.com/benfred/py-spy/issues/190) 68 | * Fix profiling pyinstaller binaries on OSX [#207](https://github.com/benfred/py-spy/issues/207) 69 | * Support getting GIL from Python 3.8.1/3.7.6/3.7.5 [#211](https://github.com/benfred/py-spy/issues/211) 70 | 71 | ## v0.3.1 72 | 73 | * Fix ptrace errors on linux kernel older than v4.7 [#83](https://github.com/benfred/py-spy/issues/83) 74 | * Fix for profiling docker containers from host os [#199](https://github.com/benfred/py-spy/issues/199) 75 | * Fix for speedscope profiles aggregated by function name [#201](https://github.com/benfred/py-spy/issues/201) 76 | * Use symbols from dynsym table of ELF binaries [#191](https://github.com/benfred/py-spy/pull/191) 77 | 78 | ## v0.3.0 79 | 80 | * Add ability to profile subprocesses [#124](https://github.com/benfred/py-spy/issues/124) 81 | * Fix overflow issue with linux symbolication [#183](https://github.com/benfred/py-spy/issues/183) 82 | * Fixes for printing local variables [#180](https://github.com/benfred/py-spy/pull/180) 83 | 84 | ## v0.2.2 85 | 86 | * Add ability to show local variables when dumping out stack traces [#77](https://github.com/benfred/py-spy/issues/77) 87 | * Show python thread names in dump [#47](https://github.com/benfred/py-spy/issues/47) 88 | * Fix issues with profiling python hosted by .NET exe [#171](https://github.com/benfred/py-spy/issues/171) 89 | 90 | ## v0.2.1 91 | 92 | * Fix issue with profiling dockerized process from the host os [#168](https://github.com/benfred/py-spy/issues/168) 93 | 94 | ## v0.2.0 95 | 96 | * Add ability to profile native python extensions [#2](https://github.com/benfred/py-spy/issues/2) 97 | * Add FreeBSD support [#112](https://github.com/benfred/py-spy/issues/112) 98 | * Relicense to MIT [#163](https://github.com/benfred/py-spy/issues/163) 99 | * Add option to write out Speedscope files [#115](https://github.com/benfred/py-spy/issues/115) 100 | * Add option to output raw call stack data [#35](https://github.com/benfred/py-spy/issues/35) 101 | * Get thread idle status from OS [#92](https://github.com/benfred/py-spy/issues/92) 102 | * Add 'unlimited' default option for the duration [#93](https://github.com/benfred/py-spy/issues/93) 103 | * Allow use as a library by other rust programs [#110](https://github.com/benfred/py-spy/issues/110) 104 | * Show OS threadids in dump [#57](https://github.com/benfred/py-spy/issues/57) 105 | * Drop root permissions when starting new process [#116](https://github.com/benfred/py-spy/issues/116) 106 | * Support building for ARM processors [#89](https://github.com/benfred/py-spy/issues/89) 107 | * Python 3.8 compatibility 108 | * Fix issues profiling functions with more than 4000 lines [#164](https://github.com/benfred/py-spy/issues/164) 109 | 110 | ## v0.1.11 111 | 112 | * Fix to detect GIL status on Python 3.7+ [#104](https://github.com/benfred/py-spy/pull/104) 113 | * Generate flamegraphs without perl (using Inferno) [#38](https://github.com/benfred/py-spy/issues/38) 114 | * Use irregular sampling interval to avoid incorrect results [#94](https://github.com/benfred/py-spy/issues/94) 115 | * Detect python packages when generating short filenames [#75](https://github.com/benfred/py-spy/issues/75) 116 | * Fix issue with finding interpreter with Python 3.7 and 32bit Linux [#101](https://github.com/benfred/py-spy/issues/101) 117 | * Detect "v2.7.15+" as a valid version string [#81](https://github.com/benfred/py-spy/issues/81) 118 | * Fix to cleanup venv after failing to build with setup.py [#69](https://github.com/benfred/py-spy/issues/69) 119 | 120 | ## v0.1.10 121 | 122 | * Fix running py-spy inside a docker container [#68](https://github.com/benfred/py-spy/issues/68) 123 | 124 | ## v0.1.9 125 | 126 | * Fix partial stack traces from showing up, by pausing process while collecting samples [#56](https://github.com/benfred/py-spy/issues/56). Also add a ```--nonblocking``` option to use previous behaviour of not stopping process. 127 | * Allow sampling process running in a docker container from the host OS [#49](https://github.com/benfred/py-spy/issues/49) 128 | * Allow collecting data for flame graph until interrupted with Control-C [#21](https://github.com/benfred/py-spy/issues/21) 129 | * Support 'legacy' strings in python 3 [#64](https://github.com/benfred/py-spy/issues/64) 130 | 131 | ## v0.1.8 132 | 133 | * Support profiling pyinstaller binaries [#42](https://github.com/benfred/py-spy/issues/42) 134 | * Add fallback when failing to find exe in memory maps [#40](https://github.com/benfred/py-spy/issues/40) 135 | 136 | ## v0.1.7 137 | 138 | * Console viewer improvements for Windows 7 [#37](https://github.com/benfred/py-spy/issues/37) 139 | 140 | ## v0.1.6 141 | 142 | * Warn if we can't sample fast enough [#33](https://github.com/benfred/py-spy/issues/33) 143 | * Support embedded python interpreters like UWSGI [#25](https://github.com/benfred/py-spy/issues/25) 144 | * Better error message when failing with 32-bit python on windows 145 | 146 | ## v0.1.5 147 | 148 | * Use musl libc for linux wheels [#5](https://github.com/benfred/py-spy/issues/5) 149 | * Fix for OSX python built with '--enable-framework' [#15](https://github.com/benfred/py-spy/issues/15) 150 | * Fix for running on Centos7 151 | 152 | ## v0.1.4 153 | 154 | * Initial public release 155 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [features] 2 | unwind = [] 3 | 4 | [package] 5 | name = "py-spy" 6 | version = "0.4.0" 7 | authors = ["Ben Frederickson "] 8 | repository = "https://github.com/benfred/py-spy" 9 | homepage = "https://github.com/benfred/py-spy" 10 | description = "Sampling profiler for Python programs " 11 | readme = "README.md" 12 | exclude = ["images/*", "test_programs/*"] 13 | license = "MIT" 14 | build="build.rs" 15 | edition="2021" 16 | 17 | [dependencies] 18 | anyhow = "1" 19 | clap = {version="3.2", features=["wrap_help", "cargo", "derive"]} 20 | clap_complete="3.2" 21 | console = "0.15" 22 | ctrlc = "3" 23 | indicatif = "0.17" 24 | env_logger = "0.10" 25 | goblin = "0.9.2" 26 | inferno = "0.11.21" 27 | lazy_static = "1.4.0" 28 | libc = "0.2" 29 | log = "0.4" 30 | lru = "0.10" 31 | num-traits = "0.2" 32 | regex = ">=1.6.0" 33 | tempfile = "3.6.0" 34 | proc-maps = "0.4.0" 35 | memmap2 = "0.9.4" 36 | cpp_demangle = "0.4" 37 | serde = {version="1.0", features=["rc"]} 38 | serde_derive = "1.0" 39 | serde_json = "1.0" 40 | rand = "0.8" 41 | rand_distr = "0.4" 42 | remoteprocess = {version="0.5.0", features=["unwind"]} 43 | chrono = "0.4.26" 44 | 45 | [dev-dependencies] 46 | py-spy-testdata = "0.1.0" 47 | 48 | [target.'cfg(unix)'.dependencies] 49 | termios = "0.3.3" 50 | 51 | [target.'cfg(windows)'.dependencies] 52 | winapi = {version = "0.3", features = ["errhandlingapi", "winbase", "consoleapi", "wincon", "handleapi", "timeapi", "processenv" ]} 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018-2019 Ben Frederickson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | py-spy: Sampling profiler for Python programs 2 | ===== 3 | [![Build Status](https://github.com/benfred/py-spy/workflows/Build/badge.svg?branch=master)](https://github.com/benfred/py-spy/actions?query=branch%3Amaster) 4 | [![FreeBSD Build Status](https://api.cirrus-ci.com/github/benfred/py-spy.svg)](https://cirrus-ci.com/github/benfred/py-spy) 5 | 6 | py-spy is a sampling profiler for Python programs. It lets you visualize what your Python 7 | program is spending time on without restarting the program or modifying the code in any way. 8 | py-spy is extremely low overhead: it is written in Rust for speed and doesn't run 9 | in the same process as the profiled Python program. This means py-spy is safe to use against production Python code. 10 | 11 | py-spy works on Linux, OSX, Windows and FreeBSD, and supports profiling all recent versions of the CPython 12 | interpreter (versions 2.3-2.7 and 3.3-3.13). 13 | 14 | ## Installation 15 | 16 | Prebuilt binary wheels can be installed from PyPI with: 17 | 18 | ``` 19 | pip install py-spy 20 | ``` 21 | 22 | You can also download prebuilt binaries from the [GitHub Releases 23 | Page](https://github.com/benfred/py-spy/releases). 24 | 25 | If you're a Rust user, py-spy can also be installed with: ```cargo install py-spy```. Note this 26 | builds py-spy from source and requires `libunwind` on Linux and Window, e.g., 27 | `apt install libunwind-dev`. 28 | 29 | On macOS, [py-spy is in Homebrew](https://formulae.brew.sh/formula/py-spy#default) and 30 | can be installed with ```brew install py-spy```. 31 | 32 | On Arch Linux, [py-spy is in AUR](https://aur.archlinux.org/packages/py-spy/) and can be 33 | installed with ```yay -S py-spy```. 34 | 35 | On Alpine Linux, [py-spy is in testing repository](https://pkgs.alpinelinux.org/packages?name=py-spy&branch=edge&repo=testing) and 36 | can be installed with ```apk add py-spy --update-cache --repository http://dl-3.alpinelinux.org/alpine/edge/testing/ --allow-untrusted```. 37 | 38 | ## Usage 39 | 40 | py-spy works from the command line and takes either the PID of the program you want to sample from 41 | or the command line of the python program you want to run. py-spy has three subcommands 42 | ```record```, ```top``` and ```dump```: 43 | 44 | ### record 45 | 46 | py-spy supports recording profiles to a file using the ```record``` command. For example, you can 47 | generate a [flame graph](http://www.brendangregg.com/flamegraphs.html) of your python process by 48 | going: 49 | 50 | ``` bash 51 | py-spy record -o profile.svg --pid 12345 52 | # OR 53 | py-spy record -o profile.svg -- python myprogram.py 54 | ``` 55 | 56 | Which will generate an interactive SVG file looking like: 57 | 58 | ![flame graph](./images/flamegraph.svg) 59 | 60 | You can change the file format to generate 61 | [speedscope](https://github.com/jlfwong/speedscope) profiles or raw data with the ```--format``` parameter. 62 | See ```py-spy record --help``` for information on other options including changing 63 | the sampling rate, filtering to only include threads that hold the GIL, profiling native C extensions, 64 | showing thread-ids, profiling subprocesses and more. 65 | 66 | ### top 67 | 68 | Top shows a live view of what functions are taking the most time in your python program, similar 69 | to the Unix [top](https://linux.die.net/man/1/top) command. Running py-spy with: 70 | 71 | ``` bash 72 | py-spy top --pid 12345 73 | # OR 74 | py-spy top -- python myprogram.py 75 | ``` 76 | 77 | will bring up a live updating high level view of your python program: 78 | 79 | ![console viewer demo](./images/console_viewer.gif) 80 | 81 | ### dump 82 | 83 | py-spy can also display the current call stack for each python thread with the ```dump``` command: 84 | 85 | ```bash 86 | py-spy dump --pid 12345 87 | ``` 88 | 89 | This will dump out the call stacks for each thread, and some other basic process info to the 90 | console: 91 | 92 | ![dump output](./images/dump.png) 93 | 94 | This is useful for the case where you just need a single call stack to figure out where your 95 | python program is hung on. This command also has the ability to print out the local variables 96 | associated with each stack frame by setting the ```--locals``` flag. 97 | 98 | ## Frequently Asked Questions 99 | 100 | ### Why do we need another Python profiler? 101 | 102 | This project aims to let you profile and debug any running Python program, even if the program is 103 | serving production traffic. 104 | 105 | While there are many other python profiling projects, almost all of them require modifying 106 | the profiled program in some way. Usually, the profiling code runs inside of the target python process, 107 | which will slow down and change how the program operates. This means it's not generally safe 108 | to use these profilers for debugging issues in production services since they will usually have 109 | a noticeable impact on performance. 110 | 111 | ### How does py-spy work? 112 | 113 | py-spy works by directly reading the memory of the python program using the 114 | [process_vm_readv](http://man7.org/linux/man-pages/man2/process_vm_readv.2.html) system call on Linux, 115 | the [vm_read](https://developer.apple.com/documentation/kernel/1585350-vm_read?language=objc) call on OSX 116 | or the [ReadProcessMemory](https://msdn.microsoft.com/en-us/library/windows/desktop/ms680553(v=vs.85).aspx) call 117 | on Windows. 118 | 119 | Figuring out the call stack of the Python program is done by looking at the global PyInterpreterState variable 120 | to get all the Python threads running in the interpreter, and then iterating over each PyFrameObject in each thread 121 | to get the call stack. Since the Python ABI changes between versions, we use rust's [bindgen](https://github.com/rust-lang-nursery/rust-bindgen) to generate different rust structures for each Python interpreter 122 | class we care about and use these generated structs to figure out the memory layout in the Python program. 123 | 124 | Getting the memory address of the Python Interpreter can be a little tricky due to [Address Space Layout Randomization](https://en.wikipedia.org/wiki/Address_space_layout_randomization). If the target python interpreter ships 125 | with symbols it is pretty easy to figure out the memory address of the interpreter by dereferencing the 126 | ```interp_head``` or ```_PyRuntime``` variables depending on the Python version. However, many Python 127 | versions are shipped with either stripped binaries or shipped without the corresponding PDB symbol files on Windows. In 128 | these cases we scan through the BSS section for addresses that look like they may point to a valid PyInterpreterState 129 | and check if the layout of that address is what we expect. 130 | 131 | 132 | ### Can py-spy profile native extensions? 133 | 134 | Yes! py-spy supports profiling native python extensions written in languages like C/C++ or Cython, 135 | on x86_64 Linux and Windows. You can enable this mode by passing ```--native``` on the 136 | command line. For best results, you should compile your Python extension with symbols. Also worth 137 | noting for Cython programs is that py-spy needs the generated C or C++ file in order to return line 138 | numbers of the original .pyx file. Read the [blog post](https://www.benfrederickson.com/profiling-native-python-extensions-with-py-spy/) 139 | for more information. 140 | 141 | ### How can I profile subprocesses? 142 | 143 | By passing in the ```--subprocesses``` flag to either the record or top view, py-spy will also include 144 | the output from any python process that is a child process of the target program. This is useful 145 | for profiling applications that use multiprocessing or gunicorn worker pools. py-spy will monitor 146 | for new processes being created, and automatically attach to them and include samples from them in 147 | the output. The record view will include the PID and cmdline of each program in the callstack, 148 | with subprocesses appearing as children of their parent processes. 149 | 150 | ### When do you need to run as sudo? 151 | 152 | py-spy works by reading memory from a different python process, and this might not be allowed for security reasons depending on 153 | your OS and system settings. In many cases, running as a root user (with sudo or similar) gets around these security restrictions. 154 | OSX always requires running as root, but on Linux it depends on how you are launching py-spy and the system 155 | security settings. 156 | 157 | On Linux the default configuration is to require root permissions when attaching to a process that isn't a child. 158 | For py-spy this means you can profile without root access by getting py-spy to create the process 159 | (```py-spy record -- python myprogram.py```) but attaching to an existing process by specifying a 160 | PID will usually require root (```sudo py-spy record --pid 123456```). 161 | You can remove this restriction on Linux by setting the [ptrace_scope sysctl variable](https://wiki.ubuntu.com/SecurityTeam/Roadmap/KernelHardening#ptrace_Protection). 162 | 163 | ### How do you detect if a thread is idle or not? 164 | 165 | py-spy attempts to only include stack traces from threads that are actively running code, and exclude threads that 166 | are sleeping or otherwise idle. When possible, py-spy attempts to get this thread activity information 167 | from the OS: by reading in ```/proc/PID/stat``` on Linux, by using the mach 168 | [thread_basic_info](https://opensource.apple.com/source/xnu/xnu-792/osfmk/mach/thread_info.h.auto.html) 169 | call on OSX, and by looking if the current SysCall is [known to be 170 | idle](https://github.com/benfred/py-spy/blob/8326c6dbc6241d60125dfd4c01b70fed8b8b8138/remoteprocess/src/windows/mod.rs#L212-L229) 171 | on Windows. 172 | 173 | There are some limitations with this approach though that may cause idle threads to still be 174 | marked as active. First off, we have to get this thread activity information before pausing the 175 | program, because getting this from a paused program will cause it to always return that this is 176 | idle. This means there is a potential race condition, where we get the thread activity and 177 | then the thread is in a different state when we get the stack trace. Querying the OS for thread 178 | activity also isn't implemented yet for FreeBSD and i686/ARM processors on Linux. On Windows, 179 | calls that are blocked on IO also won't be marked as idle yet, for instance when reading input 180 | from stdin. Finally, on some Linux calls the ptrace attach that we are using may cause idle threads 181 | to wake up momentarily, causing false positives when reading from procfs. For these reasons, 182 | we also have a heuristic fallback that marks known certain known calls in 183 | python as being idle. 184 | 185 | You can disable this functionality by setting the ```--idle``` flag, which 186 | will include frames that py-spy considers idle. 187 | 188 | ### How does GIL detection work? 189 | 190 | We get GIL activity by looking at the threadid value pointed to by the ```_PyThreadState_Current``` symbol 191 | for Python 3.6 and earlier and by figuring out the equivalent from the ```_PyRuntime``` struct in 192 | Python 3.7 and later. These symbols might not be included in your python distribution, which will 193 | cause resolving which thread holds on to the GIL to fail. Current GIL usage is also shown in the 194 | ```top``` view as %GIL. 195 | 196 | Passing the ```--gil``` flag will only include traces for threads that are holding on to the 197 | [Global Interpreter Lock](https://wiki.python.org/moin/GlobalInterpreterLock). In some cases this 198 | might be a more accurate view of how your python program is spending its time, though you should 199 | be aware that this will miss activity in extensions that release the GIL while still active. 200 | 201 | ### Why am I having issues profiling /usr/bin/python on OSX? 202 | 203 | OSX has a feature called [System Integrity Protection](https://en.wikipedia.org/wiki/System_Integrity_Protection) that prevents even the root user from reading memory from any binary located in /usr/bin. Unfortunately, this includes the python interpreter that ships with OSX. 204 | 205 | There are a couple of different ways to deal with this: 206 | * You can install a different Python distribution. The built-in Python [will be removed](https://developer.apple.com/documentation/macos_release_notes/macos_catalina_10_15_release_notes) in a future OSX, and you probably want to migrate away from Python 2 anyways =). 207 | * You can use [virtualenv](https://virtualenv.pypa.io/en/stable/) to run the system python in an environment where SIP doesn't apply. 208 | * You can [disable System Integrity Protection](https://www.macworld.co.uk/how-to/mac/how-turn-off-mac-os-x-system-integrity-protection-rootless-3638975/). 209 | 210 | ### How do I run py-spy in Docker? 211 | 212 | Running py-spy inside of a docker container will also usually bring up a permissions denied error even when running as root. 213 | 214 | This error is caused by docker restricting the process_vm_readv system call we are using. This can 215 | be overridden by setting 216 | [```--cap-add SYS_PTRACE```](https://docs.docker.com/engine/security/seccomp/) when starting the docker container. 217 | 218 | Alternatively you can edit the docker-compose yaml file 219 | 220 | ``` 221 | your_service: 222 | cap_add: 223 | - SYS_PTRACE 224 | ``` 225 | 226 | Note that you'll need to restart the docker container in order for this setting to take effect. 227 | 228 | You can also use py-spy from the Host OS to profile a running process running inside the docker 229 | container. 230 | 231 | ### How do I run py-spy in Kubernetes? 232 | 233 | py-spy needs `SYS_PTRACE` to be able to read process memory. Kubernetes drops that capability by default, resulting in the error 234 | ``` 235 | Permission Denied: Try running again with elevated permissions by going 'sudo env "PATH=$PATH" !!' 236 | ``` 237 | The recommended way to deal with this is to edit the spec and add that capability. For a deployment, this is done by adding this to `Deployment.spec.template.spec.containers` 238 | ``` 239 | securityContext: 240 | capabilities: 241 | add: 242 | - SYS_PTRACE 243 | ``` 244 | More details on this here: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/#set-capabilities-for-a-container 245 | Note that this will remove the existing pods and create those again. 246 | 247 | ### How do I install py-spy on Alpine Linux? 248 | 249 | Alpine python opts out of the `manylinux` wheels: [pypa/pip#3969 (comment)](https://github.com/pypa/pip/issues/3969#issuecomment-247381915). 250 | You can override this behaviour to use pip to install py-spy on Alpine by going: 251 | 252 | echo 'manylinux1_compatible = True' > /usr/local/lib/python3.7/site-packages/_manylinux.py 253 | 254 | Alternatively you can download a musl binary from the [GitHub releases page](https://github.com/benfred/py-spy/releases). 255 | 256 | ### How can I avoid pausing the Python program? 257 | 258 | By setting the ```--nonblocking``` option, py-spy won't pause the target python you are profiling from. While 259 | the performance impact of sampling from a process with py-spy is usually extremely low, setting this option 260 | will totally avoid interrupting your running python program. 261 | 262 | With this option set, py-spy will instead read the interpreter state from the python process as it is running. 263 | Since the calls we use to read memory from are not atomic, and we have to issue multiple calls to get a stack trace this 264 | means that occasionally we get errors when sampling. This can show up as an increased error rate when sampling, or as 265 | partial stack frames being included in the output. 266 | 267 | ### Does py-spy support 32-bit Windows? Integrate with PyPy? Work with USC2 versions of Python2? 268 | 269 | Not yet =). 270 | 271 | If there are features you'd like to see in py-spy either thumb up the [appropriate 272 | issue](https://github.com/benfred/py-spy/issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc) or create a new one that describes what functionality is missing. 273 | 274 | ### How to force colored output when piping to a pager? 275 | 276 | py-spy follows the [CLICOLOR](https://bixense.com/clicolors/) specification, thus setting `CLICOLOR_FORCE=1` in your environment will have py-spy print colored output even when piped to a pager. 277 | 278 | ## Credits 279 | 280 | py-spy is heavily inspired by [Julia Evans](https://github.com/jvns/) excellent work on [rbspy](http://github.com/rbspy/rbspy). 281 | In particular, the code to generate flamegraph and speedscope files is taken directly from rbspy, and this project uses the 282 | [read-process-memory](https://github.com/luser/read-process-memory) and [proc-maps](https://github.com/benfred/proc-maps) crates that were spun off from rbspy. 283 | 284 | ## License 285 | 286 | py-spy is released under the MIT License, see the [LICENSE](https://github.com/benfred/py-spy/blob/master/LICENSE) file for the full text. 287 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | Please email any security vulnerabilities to ben@benfrederickson.com. 6 | 7 | ## Supported Versions 8 | 9 | Only the most recent version of py-spy will get security updates. 10 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | 3 | fn main() { 4 | if env::var("CARGO_CFG_TARGET_ARCH").unwrap() != "x86_64" { 5 | return; 6 | } 7 | 8 | match env::var("CARGO_CFG_TARGET_OS").unwrap().as_ref() { 9 | "windows" => println!("cargo:rustc-cfg=unwind"), 10 | "linux" => println!("cargo:rustc-cfg=unwind"), 11 | _ => {} 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /ci/Vagrantfile: -------------------------------------------------------------------------------- 1 | Vagrant.configure("2") do |config| 2 | config.vm.define "freebsd-14" do |c| 3 | c.vm.box = "roboxes/freebsd14" 4 | end 5 | 6 | config.vm.boot_timeout = 600 7 | 8 | config.vm.provider "libvirt" do |qe| 9 | # https://vagrant-libvirt.github.io/vagrant-libvirt/configuration.html 10 | qe.driver = "kvm" 11 | qe.cpus = 3 12 | qe.memory = 8192 13 | end 14 | 15 | config.vm.synced_folder ".", "/vagrant", type: "rsync", 16 | rsync__exclude: [".git", ".vagrant.d"] 17 | 18 | config.vm.provision "shell", inline: <<~SHELL 19 | set -e 20 | pkg install -y curl bash python llvm 21 | chsh -s /usr/local/bin/bash vagrant 22 | pw groupmod wheel -m vagrant 23 | su -l vagrant <<'EOF' 24 | curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal --default-toolchain 1.80.1 25 | EOF 26 | SHELL 27 | end 28 | -------------------------------------------------------------------------------- /ci/publish_freebsd.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -ex 3 | 4 | if [[ "$CIRRUS_RELEASE" == "" ]]; then 5 | echo "Not a release. No need to deploy!" 6 | exit 0 7 | fi 8 | 9 | if [[ "$GITHUB_TOKEN" == "" ]]; then 10 | echo "Please provide GitHub access token via GITHUB_TOKEN environment variable!" 11 | exit 1 12 | fi 13 | 14 | file_content_type="application/octet-stream" 15 | fpath=py-spy-$CIRRUS_TAG-x86_64-freebsd.tar.gz 16 | 17 | tar -C ./target/release -czf $fpath py-spy 18 | 19 | echo "Uploading $fpath..." 20 | name=$(basename "$fpath") 21 | url_to_upload="https://uploads.github.com/repos/$CIRRUS_REPO_FULL_NAME/releases/$CIRRUS_RELEASE/assets?name=$name" 22 | curl -X POST \ 23 | --data-binary @$fpath \ 24 | --header "Authorization: token $GITHUB_TOKEN" \ 25 | --header "Content-Type: $file_content_type" \ 26 | $url_to_upload 27 | -------------------------------------------------------------------------------- /ci/test_freebsd.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | source "$HOME/.cargo/env" 4 | 5 | set -e 6 | 7 | python --version 8 | cargo --version 9 | 10 | cd /vagrant 11 | 12 | if [ -f build-artifacts.tar ]; then 13 | echo "Unpacking cached build artifacts..." 14 | tar xf build-artifacts.tar 15 | rm -f build-artifacts.tar 16 | fi 17 | 18 | cargo build --release --workspace --all-targets 19 | 20 | # TODO: re-enable integration tests 21 | # cargo test --release 22 | 23 | set +e 24 | tar cf build-artifacts.tar target 25 | tar rf build-artifacts.tar "$HOME/.cargo/git" 26 | tar rf build-artifacts.tar "$HOME/.cargo/registry" 27 | 28 | exit 0 29 | -------------------------------------------------------------------------------- /ci/testdata/cython_test.pyx: -------------------------------------------------------------------------------- 1 | """ simple test file for cython source mapping: defines a function 2 | that uses newtowns method to compute the square root of a number """ 3 | 4 | from cython cimport floating 5 | 6 | cpdef sqrt(floating value): 7 | # solve for the square root of value by finding the zeros of 8 | # 'x * x - value = 0' using newtons method 9 | cdef double x = value / 2 10 | for _ in range(8): 11 | x -= (x * x - value) / (2 * x) 12 | return x 13 | -------------------------------------------------------------------------------- /ci/update_python_test_versions.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | import requests 3 | import pathlib 4 | import yaml 5 | import re 6 | 7 | 8 | _VERSIONS_URL = "https://raw.githubusercontent.com/actions/python-versions/main/versions-manifest.json" # noqa 9 | 10 | 11 | def parse_version(v): 12 | return tuple(int(part) for part in re.split(r"\W", v)[:3]) 13 | 14 | 15 | def get_github_python_versions(): 16 | versions_json = requests.get(_VERSIONS_URL).json() 17 | 18 | # windows platform support isn't great for older versions of python 19 | # get a map of version: platform/arch so we can exclude here 20 | platforms = {} 21 | for v in versions_json: 22 | platforms[v["version"]] = set((f["platform"], f["arch"]) for f in v["files"]) 23 | 24 | raw_versions = [v["version"] for v in versions_json] 25 | minor_versions = defaultdict(list) 26 | 27 | for version_str in raw_versions: 28 | if "-" in version_str: 29 | continue 30 | 31 | major, minor, patch = parse_version(version_str) 32 | if major == 3 and minor < 6: 33 | # we don't support python 3.0/3.1/3.2 , and don't bother testing 3.3/3.4/3.5 34 | continue 35 | 36 | elif major == 2 and minor < 7: 37 | # we don't test python support before 2.7 38 | continue 39 | minor_versions[(major, minor)].append(patch) 40 | 41 | versions = [] 42 | for (major, minor), patches in minor_versions.items(): 43 | patches.sort() 44 | 45 | # for older versions of python, don't test all patches 46 | # (just test first and last) to keep the test matrix down 47 | if major == 2 or minor <= 11: 48 | patches = [patches[0], patches[-1]] 49 | 50 | if major == 3 and minor > 13: 51 | continue 52 | 53 | versions.extend(f"{major}.{minor}.{patch}" for patch in patches) 54 | 55 | return versions, platforms 56 | 57 | 58 | def update_python_test_versions(): 59 | versions, platforms = get_github_python_versions() 60 | versions = sorted(versions, key=parse_version) 61 | 62 | build_yml_path = ( 63 | pathlib.Path(__file__).parent.parent / ".github" / "workflows" / "build.yml" 64 | ) 65 | 66 | build_yml = yaml.safe_load(open(".github/workflows/build.yml")) 67 | test_matrix = build_yml["jobs"]["test-wheels"]["strategy"]["matrix"] 68 | existing_python_versions = test_matrix["python-version"] 69 | if versions == existing_python_versions: 70 | return 71 | 72 | print("Adding new versions") 73 | print("Old:", existing_python_versions) 74 | print("New:", versions) 75 | 76 | # we can't use the yaml package to update the GHA script, since 77 | # the data in build_yml is treated as an unordered dictionary. 78 | # instead modify the file in place 79 | lines = list(open(build_yml_path)) 80 | first_line = lines.index( 81 | " # automatically generated by ci/update_python_test_versions.py\n" 82 | ) 83 | 84 | first_version_line = lines.index(" [\n", first_line) 85 | last_version_line = lines.index(" ]\n", first_version_line) 86 | new_versions = [f" {v},\n" for v in versions] 87 | lines = lines[: first_version_line + 1] + new_versions + lines[last_version_line:] 88 | 89 | # also automatically exclude >= v3.11.* from running on OSX, 90 | # since it currently fails in GHA on SIP errors 91 | exclusions = [] 92 | for v in versions: 93 | # if we don't have a python version for osx/windows skip 94 | if ("darwin", "x64") not in platforms[v] or v.startswith("3.12"): 95 | exclusions.append(" - os: macos-13\n") 96 | exclusions.append(f" python-version: {v}\n") 97 | 98 | if ("win32", "x64") not in platforms[v] or v.startswith("3.12"): 99 | exclusions.append(" - os: windows-latest\n") 100 | exclusions.append(f" python-version: {v}\n") 101 | 102 | first_exclude_line = lines.index(" exclude:\n", first_line) 103 | last_exclude_line = lines.index("\n", first_exclude_line) 104 | lines = lines[: first_exclude_line + 1] + exclusions + lines[last_exclude_line:] 105 | 106 | with open(build_yml_path, "w") as o: 107 | o.write("".join(lines)) 108 | 109 | 110 | if __name__ == "__main__": 111 | update_python_test_versions() 112 | -------------------------------------------------------------------------------- /examples/dump_traces.rs: -------------------------------------------------------------------------------- 1 | use log::error; 2 | 3 | // Simple example of showing how to use the rust API to 4 | // print out stack traces from a python program 5 | 6 | fn print_python_stacks(pid: remoteprocess::Pid) -> Result<(), anyhow::Error> { 7 | // Create a new PythonSpy object with the default config options 8 | let config = py_spy::Config::default(); 9 | let mut process = py_spy::PythonSpy::new(pid, &config)?; 10 | 11 | // get stack traces for each thread in the process 12 | let traces = process.get_stack_traces()?; 13 | 14 | // Print out the python stack for each thread 15 | for trace in traces { 16 | println!("Thread {:#X} ({})", trace.thread_id, trace.status_str()); 17 | for frame in &trace.frames { 18 | println!("\t {} ({}:{})", frame.name, frame.filename, frame.line); 19 | } 20 | } 21 | Ok(()) 22 | } 23 | 24 | fn main() { 25 | env_logger::init(); 26 | let args: Vec = std::env::args().collect(); 27 | let pid = if args.len() > 1 { 28 | args[1].parse().expect("invalid pid") 29 | } else { 30 | error!("you must specify a pid!"); 31 | return; 32 | }; 33 | 34 | if let Err(e) = print_python_stacks(pid) { 35 | error!("failed to print stack traces: {:?}", e); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /generate_bindings.py: -------------------------------------------------------------------------------- 1 | """ Scripts to generate bindings of different python interpreter versions 2 | 3 | Requires bindgen to be installed (cargo install bindgen), and probably needs a nightly 4 | compiler with rustfmt-nightly. 5 | 6 | Also requires a git repo of cpython to be checked out somewhere. As a hack, this can 7 | also build different versions of cpython for testing out 8 | """ 9 | import argparse 10 | import os 11 | import sys 12 | import tempfile 13 | 14 | 15 | def build_python(cpython_path, version): 16 | # TODO: probably easier to use pyenv for this? 17 | print("Compiling python %s from repo at %s" % (version, cpython_path)) 18 | install_path = os.path.abspath(os.path.join(cpython_path, version)) 19 | 20 | ret = os.system( 21 | f""" 22 | cd {cpython_path} 23 | git checkout {version} 24 | 25 | # build in a subdirectory 26 | mkdir -p build_{version} 27 | cd build_{version} 28 | ../configure prefix={install_path} 29 | make 30 | make install 31 | """ 32 | ) 33 | if ret: 34 | return ret 35 | 36 | # also install setuptools_rust/wheel here for building packages 37 | pip = os.path.join(install_path, "bin", "pip3" if version.startswith("v3") else "pip") 38 | return os.system(f"{pip} install setuptools_rust wheel") 39 | 40 | 41 | def calculate_pyruntime_offsets(cpython_path, version, configure=False): 42 | ret = os.system(f"""cd {cpython_path} && git checkout {version}""") 43 | if ret: 44 | return ret 45 | 46 | if configure: 47 | os.system(f"cd {cpython_path} && ./configure prefix=" + os.path.abspath(os.path.join(cpython_path, version))) 48 | 49 | # simple little c program to get the offsets we need from the pyruntime struct 50 | # (using rust bindgen here is more complicated than necessary) 51 | program = r""" 52 | #include 53 | #include 54 | #define Py_BUILD_CORE 1 55 | #include "Include/Python.h" 56 | #include "Include/internal/pystate.h" 57 | 58 | int main(int argc, const char * argv[]) { 59 | size_t interp_head = offsetof(_PyRuntimeState, interpreters.head); 60 | printf("pub static INTERP_HEAD_OFFSET: usize = %i;\n", interp_head); 61 | 62 | // tstate_current has been replaced by a thread-local variable in python 3.12 63 | // size_t tstate_current = offsetof(_PyRuntimeState, gilstate.tstate_current); 64 | // printf("pub static TSTATE_CURRENT: usize = %i;\n", tstate_current); 65 | } 66 | """ 67 | 68 | if not os.path.isfile(os.path.join(cpython_path, "Include", "internal", "pystate.h")): 69 | if os.path.isfile(os.path.join(cpython_path, "Include", "internal", "pycore_pystate.h")): 70 | program = program.replace("pystate.h", "pycore_pystate.h") 71 | else: 72 | print("failed to find Include/internal/pystate.h in cpython directory =(") 73 | return 74 | 75 | with tempfile.TemporaryDirectory() as path: 76 | if sys.platform.startswith("win"): 77 | source_filename = os.path.join(path, "pyruntime_offsets.cpp") 78 | exe = os.path.join("pyruntime_offsets.exe") 79 | else: 80 | source_filename = os.path.join(path, "pyruntime_offsets.c") 81 | exe = os.path.join(path, "pyruntime_offsets") 82 | 83 | with open(source_filename, "w") as o: 84 | o.write(program) 85 | if sys.platform.startswith("win"): 86 | # this requires a 'x64 Native Tools Command Prompt' to work out properly for 64 bit installs 87 | # also expects that you have run something like 'PCBuild\build.bat' first 88 | ret = os.system(f"cl {source_filename} /I {cpython_path} /I {cpython_path}\PC /I {cpython_path}\Include") 89 | elif sys.platform.startswith("freebsd"): 90 | ret = os.system(f"""cc {source_filename} -I {cpython_path} -I {cpython_path}/Include -o {exe}""") 91 | else: 92 | ret = os.system(f"""gcc {source_filename} -I {cpython_path} -I {cpython_path}/Include -o {exe}""") 93 | if ret: 94 | print("Failed to compile") 95 | return ret 96 | 97 | ret = os.system(exe) 98 | if ret: 99 | print("Failed to run pyruntime file") 100 | return ret 101 | 102 | 103 | def extract_bindings(cpython_path, version, configure=False): 104 | print("Generating bindings for python %s from repo at %s" % (version, cpython_path)) 105 | 106 | ret = os.system( 107 | f""" 108 | cd {cpython_path} 109 | git checkout {version} 110 | 111 | # need to run configure on the current branch to generate pyconfig.h sometimes 112 | {("./configure prefix=" + os.path.abspath(os.path.join(cpython_path, version))) if configure else ""} 113 | 114 | 115 | echo "// autogenerated by generate_bindings.py " > bindgen_input.h 116 | echo '#define Py_BUILD_CORE 1\n' >> bindgen_input.h 117 | cat Include/Python.h >> bindgen_input.h 118 | echo '#undef HAVE_STD_ATOMIC' >> bindgen_input.h 119 | cat Include/frameobject.h >> bindgen_input.h 120 | cat Include/internal/pycore_interp.h >> bindgen_input.h 121 | cat Include/internal/pycore_dict.h >> bindgen_input.h 122 | 123 | bindgen bindgen_input.h -o bindgen_output.rs \ 124 | --with-derive-default \ 125 | --no-layout-tests --no-doc-comments \ 126 | --whitelist-type PyInterpreterState \ 127 | --whitelist-type PyFrameObject \ 128 | --whitelist-type PyThreadState \ 129 | --whitelist-type PyCodeObject \ 130 | --whitelist-type PyVarObject \ 131 | --whitelist-type PyBytesObject \ 132 | --whitelist-type PyASCIIObject \ 133 | --whitelist-type PyUnicodeObject \ 134 | --whitelist-type PyCompactUnicodeObject \ 135 | --whitelist-type PyTupleObject \ 136 | --whitelist-type PyListObject \ 137 | --whitelist-type PyLongObject \ 138 | --whitelist-type PyFloatObject \ 139 | --whitelist-type PyDictObject \ 140 | --whitelist-type PyDictKeysObject \ 141 | --whitelist-type PyObject \ 142 | --whitelist-type PyTypeObject \ 143 | --whitelist-type PyHeapTypeObject \ 144 | -- -I . -I ./Include -I ./Include/internal 145 | """ 146 | ) 147 | if ret: 148 | return ret 149 | 150 | # write the file out to the appropriate place, disabling some warnings 151 | with open(os.path.join("src", "python_bindings", version.replace(".", "_") + ".rs"), "w") as o: 152 | o.write(f"// Generated bindings for python {version}\n") 153 | o.write("#![allow(dead_code)]\n") 154 | o.write("#![allow(non_upper_case_globals)]\n") 155 | o.write("#![allow(non_camel_case_types)]\n") 156 | o.write("#![allow(non_snake_case)]\n") 157 | o.write("#![allow(clippy::useless_transmute)]\n") 158 | o.write("#![allow(clippy::default_trait_access)]\n") 159 | o.write("#![allow(clippy::cast_lossless)]\n") 160 | o.write("#![allow(clippy::trivially_copy_pass_by_ref)]\n") 161 | o.write("#![allow(clippy::upper_case_acronyms)]\n") 162 | o.write("#![allow(clippy::too_many_arguments)]\n\n") 163 | 164 | o.write(open(os.path.join(cpython_path, "bindgen_output.rs")).read()) 165 | 166 | 167 | if __name__ == "__main__": 168 | if sys.platform.startswith("win"): 169 | default_cpython_path = os.path.join(os.getenv("userprofile"), "code", "cpython") 170 | else: 171 | default_cpython_path = os.path.join(os.getenv("HOME"), "code", "cpython") 172 | 173 | parser = argparse.ArgumentParser( 174 | description="runs bindgen on cpython version", 175 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, 176 | ) 177 | parser.add_argument( 178 | "--cpython", 179 | type=str, 180 | default=default_cpython_path, 181 | dest="cpython", 182 | help="path to cpython repo", 183 | ) 184 | parser.add_argument( 185 | "--configure", 186 | help="Run configure script prior to generating bindings", 187 | action="store_true", 188 | ) 189 | parser.add_argument("--pyruntime", help="generate offsets for pyruntime", action="store_true") 190 | parser.add_argument("--build", help="Build python for this version", action="store_true") 191 | parser.add_argument("--all", help="Build all versions", action="store_true") 192 | 193 | parser.add_argument("versions", type=str, nargs="*", help="versions to extract") 194 | 195 | args = parser.parse_args() 196 | 197 | if not os.path.isdir(args.cpython): 198 | print(f"Directory '{args.cpython}' doesn't exist!") 199 | print("Pass a valid cpython path in with --cpython ") 200 | sys.exit(1) 201 | 202 | if args.all: 203 | versions = [ 204 | "v3.8.0b4", 205 | "v3.7.0", 206 | "v3.6.6", 207 | "v3.5.5", 208 | "v3.4.8", 209 | "v3.3.7", 210 | "v3.2.6", 211 | "v2.7.15", 212 | ] 213 | else: 214 | versions = args.versions 215 | if not versions: 216 | print("You must specify versions of cpython to generate bindings for, or --all\n") 217 | parser.print_help() 218 | 219 | for version in versions: 220 | if args.build: 221 | # todo: this probably should be a separate script 222 | if build_python(args.cpython, version): 223 | print("Failed to build python") 224 | elif args.pyruntime: 225 | calculate_pyruntime_offsets(args.cpython, version, configure=args.configure) 226 | 227 | else: 228 | if extract_bindings(args.cpython, version, configure=args.configure): 229 | print("Failed to generate bindings") 230 | -------------------------------------------------------------------------------- /images/console_viewer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benfred/py-spy/1fa3a6ded252d7c1c0ff974a4fcd1af67a1577cf/images/console_viewer.gif -------------------------------------------------------------------------------- /images/dump.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benfred/py-spy/1fa3a6ded252d7c1c0ff974a4fcd1af67a1577cf/images/dump.png -------------------------------------------------------------------------------- /images/flamegraph.svg: -------------------------------------------------------------------------------- 1 | py-spy Reset ZoomSearch fit (implicit/als.py:130) (48 samples, 1.66%)tocsr (scipy/sparse/csc.py:151) (48 samples, 1.66%)fit (implicit/als.py:138) (8 samples, 0.28%)fit (implicit/als.py:140) (5 samples, 0.17%)fit (implicit/als.py:159) (625 samples, 21.60%)fit (implicit/als.py:159)calculate_similar_artists (lastfm.py:79) (1,490 samples, 51.50%)calculate_similar_artists (lastfm.py:79)fit (implicit/als.py:163) (804 samples, 27.79%)fit (implicit/als.py:163)calculate_similar_artists (lastfm.py:87) (20 samples, 0.69%)<lambda> (lastfm.py:87) (7 samples, 0.24%)similar_items (implicit/recommender_base.py:201) (5 samples, 0.17%)item_norms (implicit/recommender_base.py:223) (5 samples, 0.17%)norm (numpy/linalg/linalg.py:2389) (5 samples, 0.17%)_get_similarity_score (implicit/recommender_base.py:208) (545 samples, 18.84%)_get_similarity_score (implic.._get_similarity_score (implicit/recommender_base.py:209) (814 samples, 28.14%)_get_similarity_score (implicit/recommender_..argpartition (numpy/core/fromnumeric.py:757) (813 samples, 28.10%)argpartition (numpy/core/fromnumeric.py:757)_wrapfunc (numpy/core/fromnumeric.py:51) (813 samples, 28.10%)_wrapfunc (numpy/core/fromnumeric.py:51)calculate_similar_artists (lastfm.py:95) (1,371 samples, 47.39%)calculate_similar_artists (lastfm.py:95)similar_items (implicit/recommender_base.py:203) (1,366 samples, 47.22%)similar_items (implicit/recommender_base.py:203)_get_similarity_score (implicit/recommender_base.py:210) (6 samples, 0.21%)calculate_similar_artists (lastfm.py:96) (11 samples, 0.38%)write (codecs.py:721) (4 samples, 0.14%)all (2,893 samples, 100%)<module> (lastfm.py:161) (2,893 samples, 100.00%)<module> (lastfm.py:161) -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["maturin>=1.0,<2.0"] 3 | build-backend = "maturin" 4 | 5 | [project] 6 | name = "py-spy" 7 | classifiers = [ 8 | "Development Status :: 5 - Production/Stable", 9 | "Programming Language :: Python :: 3", 10 | "Programming Language :: Python :: 2", 11 | "Intended Audience :: Developers", 12 | "License :: OSI Approved :: MIT License", 13 | "Topic :: Software Development :: Libraries", 14 | "Topic :: Utilities" 15 | ] 16 | 17 | [tool.maturin] 18 | bindings = "bin" 19 | 20 | [tool.codespell] 21 | ignore-words-list = "crate" 22 | skip = "./.git,./.github,./target,./ci/testdata,./images/" 23 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.4.0 3 | commit = True 4 | tag = True 5 | parse = (?P\d+)\.(?P\d+)\.(?P\d+)((?P(a|b|rc|\.dev)\d+))? 6 | serialize = 7 | {major}.{minor}.{patch}{release} 8 | {major}.{minor}.{patch} 9 | 10 | [bumpversion:file:Cargo.toml] 11 | search = name = "py-spy" 12 | version = "{current_version}" 13 | replace = name = "py-spy" 14 | version = "{new_version}" 15 | 16 | [bumpversion:file:Cargo.lock] 17 | search = name = "py-spy" 18 | version = "{current_version}" 19 | replace = name = "py-spy" 20 | version = "{new_version}" 21 | 22 | [flake8] 23 | max-line-length = 100 24 | exclude = build,.eggs,.tox 25 | -------------------------------------------------------------------------------- /src/binary_parser.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::fs::File; 3 | use std::path::Path; 4 | 5 | use anyhow::Error; 6 | use goblin::Object; 7 | use memmap2::Mmap; 8 | 9 | use crate::utils::is_subrange; 10 | 11 | pub struct BinaryInfo { 12 | pub symbols: HashMap, 13 | pub bss_addr: u64, 14 | pub bss_size: u64, 15 | pub pyruntime_addr: u64, 16 | pub pyruntime_size: u64, 17 | #[allow(dead_code)] 18 | pub addr: u64, 19 | #[allow(dead_code)] 20 | pub size: u64, 21 | } 22 | 23 | impl BinaryInfo { 24 | #[cfg(feature = "unwind")] 25 | pub fn contains(&self, addr: u64) -> bool { 26 | addr >= self.addr && addr < (self.addr + self.size) 27 | } 28 | } 29 | 30 | /// Uses goblin to parse a binary file, returns information on symbols/bss/adjusted offset etc 31 | pub fn parse_binary(filename: &Path, addr: u64, size: u64) -> Result { 32 | let offset = addr; 33 | 34 | let mut symbols = HashMap::new(); 35 | 36 | // Read in the filename 37 | let file = File::open(filename)?; 38 | let buffer = unsafe { Mmap::map(&file)? }; 39 | 40 | // Use goblin to parse the binary 41 | match Object::parse(&buffer)? { 42 | Object::Mach(mach) => { 43 | // Get the mach binary from the archive 44 | let mach = match mach { 45 | goblin::mach::Mach::Binary(mach) => mach, 46 | goblin::mach::Mach::Fat(fat) => { 47 | let arch = fat 48 | .iter_arches() 49 | .find(|arch| match arch { 50 | Ok(arch) => arch.is_64(), 51 | Err(_) => false, 52 | }) 53 | .ok_or_else(|| { 54 | format_err!( 55 | "Failed to find 64 bit arch in FAT archive in {}", 56 | filename.display() 57 | ) 58 | })??; 59 | if !is_subrange(0, buffer.len(), arch.offset as usize, arch.size as usize) { 60 | return Err(format_err!( 61 | "Invalid offset/size in FAT archive in {}", 62 | filename.display() 63 | )); 64 | } 65 | let bytes = &buffer[arch.offset as usize..][..arch.size as usize]; 66 | goblin::mach::MachO::parse(bytes, 0)? 67 | } 68 | }; 69 | 70 | let mut pyruntime_addr = 0; 71 | let mut pyruntime_size = 0; 72 | let mut bss_addr = 0; 73 | let mut bss_size = 0; 74 | for segment in mach.segments.iter() { 75 | for (section, _) in &segment.sections()? { 76 | let name = section.name()?; 77 | if name == "PyRuntime" { 78 | if let Some(addr) = section.addr.checked_add(offset) { 79 | if addr.checked_add(section.size).is_some() { 80 | pyruntime_addr = addr; 81 | pyruntime_size = section.size; 82 | } 83 | } 84 | } 85 | 86 | if name == "__bss" { 87 | if let Some(addr) = section.addr.checked_add(offset) { 88 | if addr.checked_add(section.size).is_some() { 89 | bss_addr = addr; 90 | bss_size = section.size; 91 | } 92 | } 93 | } 94 | } 95 | } 96 | 97 | if let Some(syms) = mach.symbols { 98 | for symbol in syms.iter() { 99 | let (name, value) = symbol?; 100 | // almost every symbol we care about starts with an extra _, remove to normalize 101 | // with the entries seen on linux/windows 102 | if let Some(stripped_name) = name.strip_prefix('_') { 103 | symbols.insert(stripped_name.to_string(), value.n_value + offset); 104 | } 105 | } 106 | } 107 | Ok(BinaryInfo { 108 | symbols, 109 | bss_addr, 110 | bss_size, 111 | pyruntime_addr, 112 | pyruntime_size, 113 | addr, 114 | size, 115 | }) 116 | } 117 | 118 | Object::Elf(elf) => { 119 | let strtab = elf.shdr_strtab; 120 | let bss_header = elf 121 | .section_headers 122 | .iter() 123 | // filter down to things that are both NOBITS sections and are named .bss 124 | .filter(|header| header.sh_type == goblin::elf::section_header::SHT_NOBITS) 125 | .filter(|header| { 126 | strtab 127 | .get_at(header.sh_name) 128 | .map_or(true, |name| name == ".bss") 129 | }) 130 | // if we have multiple sections here, take the largest 131 | .max_by_key(|header| header.sh_size) 132 | .ok_or_else(|| { 133 | format_err!( 134 | "Failed to find BSS section header in {}", 135 | filename.display() 136 | ) 137 | })?; 138 | 139 | let program_header = elf 140 | .program_headers 141 | .iter() 142 | .find(|header| { 143 | header.p_type == goblin::elf::program_header::PT_LOAD 144 | && header.p_flags & goblin::elf::program_header::PF_X != 0 145 | }) 146 | .ok_or_else(|| { 147 | format_err!( 148 | "Failed to find executable PT_LOAD program header in {}", 149 | filename.display() 150 | ) 151 | })?; 152 | 153 | // p_vaddr may be larger than the map address in case when the header has an offset and 154 | // the map address is relatively small. In this case we can default to 0. 155 | let offset = offset.saturating_sub(program_header.p_vaddr); 156 | 157 | let mut bss_addr = 0; 158 | let mut bss_size = 0; 159 | let mut bss_end = 0; 160 | if let Some(addr) = bss_header.sh_addr.checked_add(offset) { 161 | if bss_header.sh_size.checked_add(addr).is_none() { 162 | return Err(format_err!( 163 | "Invalid bss address/size in {}", 164 | filename.display() 165 | )); 166 | } 167 | bss_addr = addr; 168 | bss_size = bss_header.sh_size; 169 | bss_end = bss_header.sh_addr + bss_header.sh_size; 170 | } 171 | 172 | let pyruntime_header = elf.section_headers.iter().find(|header| { 173 | strtab 174 | .get_at(header.sh_name) 175 | .map_or(false, |name| name == ".PyRuntime") 176 | }); 177 | 178 | let mut pyruntime_addr = 0; 179 | let mut pyruntime_size = 0; 180 | if let Some(header) = pyruntime_header { 181 | if let Some(addr) = header.sh_addr.checked_add(offset) { 182 | pyruntime_addr = addr; 183 | pyruntime_size = header.sh_size; 184 | } 185 | } 186 | 187 | for sym in elf.syms.iter() { 188 | // Skip undefined symbols. 189 | if sym.st_shndx == goblin::elf::section_header::SHN_UNDEF as usize { 190 | continue; 191 | } 192 | // Skip imported symbols 193 | if sym.is_import() 194 | || (bss_end != 0 195 | && sym.st_size != 0 196 | && !is_subrange(0u64, bss_end, sym.st_value, sym.st_size)) 197 | { 198 | continue; 199 | } 200 | if let Some(pos) = sym.st_value.checked_add(offset) { 201 | if sym.is_function() && !is_subrange(addr, size, pos, sym.st_size) { 202 | continue; 203 | } 204 | if let Some(name) = elf.strtab.get_unsafe(sym.st_name) { 205 | symbols.insert(name.to_string(), pos); 206 | } 207 | } 208 | } 209 | for dynsym in elf.dynsyms.iter() { 210 | // Skip undefined symbols. 211 | if dynsym.st_shndx == goblin::elf::section_header::SHN_UNDEF as usize { 212 | continue; 213 | } 214 | // Skip imported symbols 215 | if dynsym.is_import() 216 | || (bss_end != 0 217 | && dynsym.st_size != 0 218 | && !is_subrange(0u64, bss_end, dynsym.st_value, dynsym.st_size)) 219 | { 220 | continue; 221 | } 222 | if let Some(pos) = dynsym.st_value.checked_add(offset) { 223 | if dynsym.is_function() && !is_subrange(addr, size, pos, dynsym.st_size) { 224 | continue; 225 | } 226 | if let Some(name) = elf.dynstrtab.get_unsafe(dynsym.st_name) { 227 | symbols.insert(name.to_string(), pos); 228 | } 229 | } 230 | } 231 | 232 | Ok(BinaryInfo { 233 | symbols, 234 | bss_addr, 235 | bss_size, 236 | pyruntime_addr, 237 | pyruntime_size, 238 | addr, 239 | size, 240 | }) 241 | } 242 | Object::PE(pe) => { 243 | for export in pe.exports { 244 | if let Some(name) = export.name { 245 | if let Some(addr) = offset.checked_add(export.rva as u64) { 246 | symbols.insert(name.to_string(), addr); 247 | } 248 | } 249 | } 250 | 251 | let mut bss_addr = 0; 252 | let mut bss_size = 0; 253 | let mut pyruntime_addr = 0; 254 | let mut pyruntime_size = 0; 255 | let mut found_data = false; 256 | for section in pe.sections.iter() { 257 | if section.name.starts_with(b".data") { 258 | found_data = true; 259 | if let Some(addr) = offset.checked_add(section.virtual_address as u64) { 260 | if addr.checked_add(section.virtual_size as u64).is_some() { 261 | bss_addr = addr; 262 | bss_size = u64::from(section.virtual_size); 263 | } 264 | } 265 | } else if section.name.starts_with(b"PyRuntim") { 266 | // note that the name is only 8 chars here, so we don't check for 267 | // trailing 'e' in PyRuntime 268 | if let Some(addr) = offset.checked_add(section.virtual_address as u64) { 269 | if addr.checked_add(section.virtual_size as u64).is_some() { 270 | pyruntime_addr = addr; 271 | pyruntime_size = u64::from(section.virtual_size); 272 | } 273 | } 274 | } 275 | } 276 | 277 | if !found_data { 278 | return Err(format_err!( 279 | "Failed to find .data section in PE binary of {}", 280 | filename.display() 281 | )); 282 | } 283 | 284 | Ok(BinaryInfo { 285 | symbols, 286 | bss_addr, 287 | bss_size, 288 | pyruntime_size, 289 | pyruntime_addr, 290 | addr, 291 | size, 292 | }) 293 | } 294 | _ => Err(format_err!("Unhandled binary type")), 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /src/chrometrace.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::min; 2 | use std::collections::HashMap; 3 | use std::io::Write; 4 | use std::time::Instant; 5 | 6 | use anyhow::Error; 7 | use serde_derive::Serialize; 8 | 9 | use crate::stack_trace::Frame; 10 | use crate::stack_trace::StackTrace; 11 | 12 | #[derive(Clone, Debug, Serialize)] 13 | struct Args { 14 | pub filename: String, 15 | pub line: Option, 16 | } 17 | 18 | #[derive(Clone, Debug, Serialize)] 19 | struct Event { 20 | pub args: Args, 21 | pub cat: String, 22 | pub name: String, 23 | pub ph: String, 24 | pub pid: u64, 25 | pub tid: u64, 26 | pub ts: u64, 27 | } 28 | 29 | pub struct Chrometrace { 30 | events: Vec, 31 | start_ts: Instant, 32 | prev_traces: HashMap, 33 | show_linenumbers: bool, 34 | } 35 | 36 | impl Chrometrace { 37 | pub fn new(show_linenumbers: bool) -> Chrometrace { 38 | Chrometrace { 39 | events: Vec::new(), 40 | start_ts: Instant::now(), 41 | prev_traces: HashMap::new(), 42 | show_linenumbers, 43 | } 44 | } 45 | 46 | // Return whether these frames are similar enough such that we should merge 47 | // them, instead of creating separate events for them. 48 | fn should_merge_frames(&self, a: &Frame, b: &Frame) -> bool { 49 | a.name == b.name && a.filename == b.filename && (!self.show_linenumbers || a.line == b.line) 50 | } 51 | 52 | fn event(&self, trace: &StackTrace, frame: &Frame, phase: &str, ts: u64) -> Event { 53 | Event { 54 | tid: trace.thread_id, 55 | pid: trace.pid as u64, 56 | name: frame.name.to_string(), 57 | cat: "py-spy".to_owned(), 58 | ph: phase.to_owned(), 59 | ts, 60 | args: Args { 61 | filename: frame.filename.to_string(), 62 | line: if self.show_linenumbers { 63 | Some(frame.line as u32) 64 | } else { 65 | None 66 | }, 67 | }, 68 | } 69 | } 70 | 71 | pub fn increment(&mut self, trace: &StackTrace) -> std::io::Result<()> { 72 | let now = self.start_ts.elapsed().as_micros() as u64; 73 | 74 | // Load the previous frames for this thread. 75 | let prev_frames = self 76 | .prev_traces 77 | .remove(&trace.thread_id) 78 | .map(|t| t.frames) 79 | .unwrap_or_default(); 80 | 81 | // Find the index where we first see new frames. 82 | let new_idx = prev_frames 83 | .iter() 84 | .rev() 85 | .zip(trace.frames.iter().rev()) 86 | .position(|(a, b)| !self.should_merge_frames(a, b)) 87 | .unwrap_or(min(prev_frames.len(), trace.frames.len())); 88 | 89 | // Publish end events for the previous frames that got dropped in the 90 | // most recent trace. 91 | for frame in prev_frames.iter().rev().skip(new_idx).rev() { 92 | self.events.push(self.event(trace, frame, "E", now)); 93 | } 94 | 95 | // Publish start events for frames that got added in the most recent 96 | // trace. 97 | for frame in trace.frames.iter().rev().skip(new_idx) { 98 | self.events.push(self.event(trace, frame, "B", now)); 99 | } 100 | 101 | // Save this stack trace for the next iteration. 102 | self.prev_traces.insert(trace.thread_id, trace.clone()); 103 | 104 | Ok(()) 105 | } 106 | 107 | pub fn write(&self, w: &mut dyn Write) -> Result<(), Error> { 108 | let mut events = Vec::new(); 109 | events.extend(self.events.to_vec()); 110 | 111 | // Add end events for any unfinished slices. 112 | let now = self.start_ts.elapsed().as_micros() as u64; 113 | for trace in self.prev_traces.values() { 114 | for frame in &trace.frames { 115 | events.push(self.event(trace, frame, "E", now)); 116 | } 117 | } 118 | 119 | writeln!(w, "{}", serde_json::to_string(&events)?)?; 120 | Ok(()) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/cython.rs: -------------------------------------------------------------------------------- 1 | use regex::Regex; 2 | use std::collections::{BTreeMap, HashMap}; 3 | 4 | use anyhow::Error; 5 | use lazy_static::lazy_static; 6 | 7 | use crate::stack_trace::Frame; 8 | use crate::utils::resolve_filename; 9 | 10 | pub struct SourceMaps { 11 | maps: HashMap>, 12 | } 13 | 14 | impl SourceMaps { 15 | pub fn new() -> SourceMaps { 16 | let maps = HashMap::new(); 17 | SourceMaps { maps } 18 | } 19 | 20 | pub fn translate(&mut self, frame: &mut Frame) { 21 | if self.translate_frame(frame) { 22 | self.load_map(frame); 23 | self.translate_frame(frame); 24 | } 25 | } 26 | 27 | // tries to replace the frame using a cython sourcemap if possible 28 | // returns true if the corresponding cython sourcemap hasn't been loaded yet 29 | fn translate_frame(&mut self, frame: &mut Frame) -> bool { 30 | let line = frame.line as u32; 31 | if line == 0 { 32 | return false; 33 | } 34 | if let Some(map) = self.maps.get(&frame.filename) { 35 | if let Some(map) = map { 36 | if let Some((file, line)) = map.lookup(line) { 37 | frame.filename = file.clone(); 38 | frame.line = *line as i32; 39 | } 40 | } 41 | return false; 42 | } 43 | true 44 | } 45 | 46 | // loads the corresponding cython source map for the frame 47 | fn load_map(&mut self, frame: &Frame) { 48 | if !(frame.filename.ends_with(".cpp") || frame.filename.ends_with(".c")) { 49 | self.maps.insert(frame.filename.clone(), None); 50 | return; 51 | } 52 | 53 | let map = match SourceMap::new(&frame.filename, &frame.module) { 54 | Ok(map) => map, 55 | Err(e) => { 56 | info!("Failed to load cython file {}: {:?}", &frame.filename, e); 57 | self.maps.insert(frame.filename.clone(), None); 58 | return; 59 | } 60 | }; 61 | 62 | self.maps.insert(frame.filename.clone(), Some(map)); 63 | } 64 | } 65 | 66 | struct SourceMap { 67 | lookup: BTreeMap, 68 | } 69 | 70 | impl SourceMap { 71 | pub fn new(filename: &str, module: &Option) -> Result { 72 | let contents = std::fs::read_to_string(filename)?; 73 | SourceMap::from_contents(&contents, filename, module) 74 | } 75 | 76 | pub fn from_contents( 77 | contents: &str, 78 | cpp_filename: &str, 79 | module: &Option, 80 | ) -> Result { 81 | lazy_static! { 82 | static ref RE: Regex = Regex::new(r#"^\s*/\* "(.+\..+)":([0-9]+)"#).unwrap(); 83 | } 84 | 85 | let mut lookup = BTreeMap::new(); 86 | let mut resolved: HashMap = HashMap::new(); 87 | 88 | let mut line_count = 0; 89 | for (lineno, line) in contents.lines().enumerate() { 90 | if let Some(captures) = RE.captures(line) { 91 | let cython_file = captures.get(1).map_or("", |m| m.as_str()); 92 | let cython_line = captures.get(2).map_or("", |m| m.as_str()); 93 | 94 | if let Ok(cython_line) = cython_line.parse::() { 95 | // try resolving the cython filename 96 | let filename = match resolved.get(cython_file) { 97 | Some(filename) => filename.clone(), 98 | None => { 99 | let filename = resolve_cython_file(cpp_filename, cython_file, module); 100 | resolved.insert(cython_file.to_string(), filename.clone()); 101 | filename 102 | } 103 | }; 104 | 105 | lookup.insert(lineno as u32, (filename, cython_line)); 106 | } 107 | } 108 | line_count += 1; 109 | } 110 | 111 | lookup.insert(line_count + 1, ("".to_owned(), 0)); 112 | Ok(SourceMap { lookup }) 113 | } 114 | 115 | pub fn lookup(&self, lineno: u32) -> Option<&(String, u32)> { 116 | match self.lookup.range(..lineno).next_back() { 117 | // handle EOF 118 | Some((_, (_, 0))) => None, 119 | Some((_, val)) => Some(val), 120 | None => None, 121 | } 122 | } 123 | } 124 | 125 | pub fn ignore_frame(name: &str) -> bool { 126 | let ignorable = [ 127 | "__Pyx_PyFunction_FastCallDict", 128 | "__Pyx_PyObject_CallOneArg", 129 | "__Pyx_PyObject_Call", 130 | "__pyx_FusedFunction_call", 131 | ]; 132 | 133 | ignorable.iter().any(|&f| f == name) 134 | } 135 | 136 | pub fn demangle(name: &str) -> &str { 137 | // slice off any leading cython prefix. 138 | let prefixes = [ 139 | "__pyx_fuse_1_0__pyx_pw", 140 | "__pyx_fuse_0__pyx_f", 141 | "__pyx_fuse_1__pyx_f", 142 | "__pyx_pf", 143 | "__pyx_pw", 144 | "__pyx_f", 145 | "___pyx_f", 146 | "___pyx_pw", 147 | ]; 148 | let mut current = match prefixes.iter().find(|&prefix| name.starts_with(prefix)) { 149 | Some(prefix) => &name[prefix.len()..], 150 | None => return name, 151 | }; 152 | 153 | let mut next = current; 154 | 155 | // get the function name from the cython mangled string (removing module/file/class 156 | // prefixes) 157 | loop { 158 | let mut chars = next.chars(); 159 | if chars.next() != Some('_') { 160 | break; 161 | } 162 | 163 | let mut digit_index = 1; 164 | for ch in chars { 165 | if !ch.is_ascii_digit() { 166 | break; 167 | } 168 | digit_index += 1; 169 | } 170 | 171 | if digit_index == 1 { 172 | break; 173 | } 174 | 175 | match &next[1..digit_index].parse::() { 176 | Ok(digits) => { 177 | current = &next[digit_index..]; 178 | if digits + digit_index >= current.len() { 179 | break; 180 | } 181 | next = &next[digits + digit_index..]; 182 | } 183 | Err(_) => break, 184 | }; 185 | } 186 | debug!("cython_demangle(\"{}\") -> \"{}\"", name, current); 187 | 188 | current 189 | } 190 | 191 | fn resolve_cython_file( 192 | cpp_filename: &str, 193 | cython_filename: &str, 194 | module: &Option, 195 | ) -> String { 196 | let cython_path = std::path::PathBuf::from(cython_filename); 197 | if let Some(ext) = cython_path.extension() { 198 | let mut path_buf = std::path::PathBuf::from(cpp_filename); 199 | path_buf.set_extension(ext); 200 | if path_buf.ends_with(&cython_path) && path_buf.exists() { 201 | return path_buf.to_string_lossy().to_string(); 202 | } 203 | } 204 | 205 | match module { 206 | Some(module) => { 207 | resolve_filename(cython_filename, module).unwrap_or_else(|| cython_filename.to_owned()) 208 | } 209 | None => cython_filename.to_owned(), 210 | } 211 | } 212 | 213 | #[cfg(test)] 214 | mod tests { 215 | use super::*; 216 | #[test] 217 | fn test_demangle() { 218 | // all of these were wrong at certain points when writing cython_demangle =( 219 | assert_eq!( 220 | demangle("__pyx_pf_8implicit_4_als_30_least_squares_cg"), 221 | "_least_squares_cg" 222 | ); 223 | assert_eq!( 224 | demangle("__pyx_pw_8implicit_4_als_5least_squares_cg"), 225 | "least_squares_cg" 226 | ); 227 | assert_eq!( 228 | demangle("__pyx_fuse_1_0__pyx_pw_8implicit_4_als_31_least_squares_cg"), 229 | "_least_squares_cg" 230 | ); 231 | assert_eq!( 232 | demangle("__pyx_f_6mtrand_cont0_array"), 233 | "mtrand_cont0_array" 234 | ); 235 | // in both of these cases we should ideally slice off the module (_als/bpr), but it gets tricky 236 | // implementation wise 237 | assert_eq!( 238 | demangle("__pyx_fuse_0__pyx_f_8implicit_4_als_axpy"), 239 | "_als_axpy" 240 | ); 241 | assert_eq!( 242 | demangle("__pyx_fuse_1__pyx_f_8implicit_3bpr_has_non_zero"), 243 | "bpr_has_non_zero" 244 | ); 245 | } 246 | 247 | #[test] 248 | fn test_source_map() { 249 | let map = SourceMap::from_contents( 250 | include_str!("../ci/testdata/cython_test.c"), 251 | "cython_test.c", 252 | &None, 253 | ) 254 | .unwrap(); 255 | 256 | // we don't have info on cython line numbers until line 1261 257 | assert_eq!(map.lookup(1000), None); 258 | // past the end of the file should also return none 259 | assert_eq!(map.lookup(10000), None); 260 | 261 | let lookup = |lineno: u32, cython_file: &str, cython_line: u32| match map.lookup(lineno) { 262 | Some((file, line)) => { 263 | assert_eq!(file, cython_file); 264 | assert_eq!(line, &cython_line); 265 | } 266 | None => { 267 | panic!( 268 | "Failed to lookup line {} (expected {}:{})", 269 | lineno, cython_file, cython_line 270 | ); 271 | } 272 | }; 273 | lookup(1298, "cython_test.pyx", 6); 274 | lookup(1647, "cython_test.pyx", 10); 275 | lookup(1763, "cython_test.pyx", 9); 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /src/dump.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Error; 2 | use console::{style, Term}; 3 | 4 | use crate::config::Config; 5 | use crate::python_spy::PythonSpy; 6 | use crate::stack_trace::StackTrace; 7 | 8 | use remoteprocess::Pid; 9 | 10 | pub fn print_traces(pid: Pid, config: &Config, parent: Option) -> Result<(), Error> { 11 | let mut process = PythonSpy::new(pid, config)?; 12 | if config.dump_json { 13 | let traces = process.get_stack_traces()?; 14 | println!("{}", serde_json::to_string_pretty(&traces)?); 15 | return Ok(()); 16 | } 17 | 18 | println!( 19 | "Process {}: {}", 20 | style(process.pid).bold().yellow(), 21 | process.process.cmdline()?.join(" ") 22 | ); 23 | 24 | println!( 25 | "Python v{} ({})", 26 | style(&process.version).bold(), 27 | style(process.process.exe()?).dim() 28 | ); 29 | 30 | if let Some(parentpid) = parent { 31 | let parentprocess = remoteprocess::Process::new(parentpid)?; 32 | println!( 33 | "Parent Process {}: {}", 34 | style(parentpid).bold().yellow(), 35 | parentprocess.cmdline()?.join(" ") 36 | ); 37 | } 38 | println!(); 39 | let traces = process.get_stack_traces()?; 40 | for trace in traces.iter().rev() { 41 | print_trace(trace, true); 42 | if config.subprocesses { 43 | for (childpid, parentpid) in process 44 | .process 45 | .child_processes() 46 | .expect("failed to get subprocesses") 47 | { 48 | let term = Term::stdout(); 49 | let (_, width) = term.size(); 50 | 51 | println!("\n{}", &style("-".repeat(width as usize)).dim()); 52 | // child_processes() returns the whole process tree, since we're recursing here 53 | // though we could end up printing grandchild processes multiple times. Limit down 54 | // to just once 55 | if parentpid == pid { 56 | print_traces(childpid, config, Some(parentpid))?; 57 | } 58 | } 59 | } 60 | } 61 | Ok(()) 62 | } 63 | 64 | pub fn print_trace(trace: &StackTrace, include_activity: bool) { 65 | let thread_id = trace.format_threadid(); 66 | 67 | let status = if include_activity { 68 | format!(" ({})", trace.status_str()) 69 | } else if trace.owns_gil { 70 | " (gil)".to_owned() 71 | } else { 72 | "".to_owned() 73 | }; 74 | 75 | match trace.thread_name.as_ref() { 76 | Some(name) => { 77 | println!( 78 | "Thread {}{}: \"{}\"", 79 | style(thread_id).bold().yellow(), 80 | status, 81 | name 82 | ); 83 | } 84 | None => { 85 | println!("Thread {}{}", style(thread_id).bold().yellow(), status); 86 | } 87 | }; 88 | 89 | for frame in &trace.frames { 90 | let filename = match &frame.short_filename { 91 | Some(f) => f, 92 | None => &frame.filename, 93 | }; 94 | if frame.line != 0 { 95 | println!( 96 | " {} ({}:{})", 97 | style(&frame.name).green(), 98 | style(&filename).cyan(), 99 | style(frame.line).dim() 100 | ); 101 | } else { 102 | println!( 103 | " {} ({})", 104 | style(&frame.name).green(), 105 | style(&filename).cyan() 106 | ); 107 | } 108 | 109 | if let Some(locals) = &frame.locals { 110 | let mut shown_args = false; 111 | let mut shown_locals = false; 112 | for local in locals { 113 | if local.arg && !shown_args { 114 | println!(" {}", style("Arguments:").dim()); 115 | shown_args = true; 116 | } else if !local.arg && !shown_locals { 117 | println!(" {}", style("Locals:").dim()); 118 | shown_locals = true; 119 | } 120 | 121 | let repr = local.repr.as_deref().unwrap_or("?"); 122 | println!(" {}: {}", local.name, repr); 123 | } 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/flamegraph.rs: -------------------------------------------------------------------------------- 1 | // This code is taken from the flamegraph.rs from rbspy 2 | // https://github.com/rbspy/rbspy/tree/master/src/ui/flamegraph.rs 3 | // licensed under the MIT License: 4 | /* 5 | MIT License 6 | 7 | Copyright (c) 2016 Julia Evans, Kamal Marhubi 8 | Portions (continuous integration setup) Copyright (c) 2016 Jorge Aparicio 9 | 10 | Permission is hereby granted, free of charge, to any person obtaining a copy 11 | of this software and associated documentation files (the "Software"), to deal 12 | in the Software without restriction, including without limitation the rights 13 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | copies of the Software, and to permit persons to whom the Software is 15 | furnished to do so, subject to the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be included in all 18 | copies or substantial portions of the Software. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | SOFTWARE. 27 | */ 28 | 29 | use std::collections::HashMap; 30 | use std::io::Write; 31 | 32 | use anyhow::Error; 33 | use inferno::flamegraph::{Direction, Options}; 34 | 35 | use crate::stack_trace::StackTrace; 36 | 37 | pub struct Flamegraph { 38 | pub counts: HashMap, 39 | pub show_linenumbers: bool, 40 | } 41 | 42 | impl Flamegraph { 43 | pub fn new(show_linenumbers: bool) -> Flamegraph { 44 | Flamegraph { 45 | counts: HashMap::new(), 46 | show_linenumbers, 47 | } 48 | } 49 | 50 | pub fn increment(&mut self, trace: &StackTrace) -> std::io::Result<()> { 51 | // convert the frame into a single ';' delimited String 52 | let frame = trace 53 | .frames 54 | .iter() 55 | .rev() 56 | .map(|frame| { 57 | let filename = match &frame.short_filename { 58 | Some(f) => f, 59 | None => &frame.filename, 60 | }; 61 | if self.show_linenumbers && frame.line != 0 { 62 | format!("{} ({}:{})", frame.name, filename, frame.line) 63 | } else if !filename.is_empty() { 64 | format!("{} ({})", frame.name, filename) 65 | } else { 66 | frame.name.clone() 67 | } 68 | }) 69 | .collect::>() 70 | .join(";"); 71 | // update counts for that frame 72 | *self.counts.entry(frame).or_insert(0) += 1; 73 | Ok(()) 74 | } 75 | 76 | fn get_lines(&self) -> Vec { 77 | self.counts 78 | .iter() 79 | .map(|(k, v)| format!("{} {}", k, v)) 80 | .collect() 81 | } 82 | 83 | pub fn write(&self, w: &mut dyn Write) -> Result<(), Error> { 84 | let mut opts = Options::default(); 85 | opts.direction = Direction::Inverted; 86 | opts.min_width = 0.1; 87 | opts.title = std::env::args().collect::>().join(" "); 88 | 89 | let lines = self.get_lines(); 90 | inferno::flamegraph::from_lines(&mut opts, lines.iter().map(|x| x.as_str()), w) 91 | .map_err(|e| format_err!("Failed to write flamegraph: {}", e))?; 92 | Ok(()) 93 | } 94 | 95 | pub fn write_raw(&self, w: &mut dyn Write) -> Result<(), Error> { 96 | for line in self.get_lines() { 97 | w.write_all(line.as_bytes())?; 98 | w.write_all(b"\n")?; 99 | } 100 | Ok(()) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! py-spy: a sampling profiler for python programs 2 | //! 3 | //! This crate lets you use py-spy as a rust library, and gather stack traces from 4 | //! your python process programmatically. 5 | //! 6 | //! # Example: 7 | //! 8 | //! ```rust,no_run 9 | //! fn print_python_stacks(pid: py_spy::Pid) -> Result<(), anyhow::Error> { 10 | //! // Create a new PythonSpy object with the default config options 11 | //! let config = py_spy::Config::default(); 12 | //! let mut process = py_spy::PythonSpy::new(pid, &config)?; 13 | //! 14 | //! // get stack traces for each thread in the process 15 | //! let traces = process.get_stack_traces()?; 16 | //! 17 | //! // Print out the python stack for each thread 18 | //! for trace in traces { 19 | //! println!("Thread {:#X} ({})", trace.thread_id, trace.status_str()); 20 | //! for frame in &trace.frames { 21 | //! println!("\t {} ({}:{})", frame.name, frame.filename, frame.line); 22 | //! } 23 | //! } 24 | //! Ok(()) 25 | //! } 26 | //! ``` 27 | #[macro_use] 28 | extern crate anyhow; 29 | #[macro_use] 30 | extern crate log; 31 | 32 | pub mod binary_parser; 33 | pub mod config; 34 | #[cfg(target_os = "linux")] 35 | pub mod coredump; 36 | #[cfg(feature = "unwind")] 37 | mod cython; 38 | pub mod dump; 39 | #[cfg(feature = "unwind")] 40 | mod native_stack_trace; 41 | mod python_bindings; 42 | mod python_data_access; 43 | mod python_interpreters; 44 | pub mod python_process_info; 45 | pub mod python_spy; 46 | mod python_threading; 47 | pub mod sampler; 48 | pub mod stack_trace; 49 | pub mod timer; 50 | mod utils; 51 | mod version; 52 | 53 | pub use config::Config; 54 | pub use python_spy::PythonSpy; 55 | pub use remoteprocess::Pid; 56 | pub use stack_trace::Frame; 57 | pub use stack_trace::StackTrace; 58 | -------------------------------------------------------------------------------- /src/native_stack_trace.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Error; 2 | use std::collections::HashSet; 3 | use std::num::NonZeroUsize; 4 | 5 | use cpp_demangle::{BorrowedSymbol, DemangleOptions}; 6 | use lazy_static::lazy_static; 7 | use lru::LruCache; 8 | use remoteprocess::{self, Pid}; 9 | 10 | use crate::binary_parser::BinaryInfo; 11 | use crate::cython; 12 | use crate::stack_trace::Frame; 13 | use crate::utils::resolve_filename; 14 | 15 | pub struct NativeStack { 16 | should_reload: bool, 17 | python: Option, 18 | libpython: Option, 19 | cython_maps: cython::SourceMaps, 20 | unwinder: remoteprocess::Unwinder, 21 | symbolicator: remoteprocess::Symbolicator, 22 | // TODO: right now on windows if we don't hold on the process handle unwinding will fail 23 | #[allow(dead_code)] 24 | process: remoteprocess::Process, 25 | symbol_cache: LruCache, 26 | } 27 | 28 | impl NativeStack { 29 | pub fn new( 30 | pid: Pid, 31 | python: Option, 32 | libpython: Option, 33 | ) -> Result { 34 | let cython_maps = cython::SourceMaps::new(); 35 | 36 | let process = remoteprocess::Process::new(pid)?; 37 | let unwinder = process.unwinder()?; 38 | let symbolicator = process.symbolicator()?; 39 | 40 | Ok(NativeStack { 41 | cython_maps, 42 | unwinder, 43 | symbolicator, 44 | should_reload: false, 45 | python, 46 | libpython, 47 | process, 48 | symbol_cache: LruCache::new(NonZeroUsize::new(65536).unwrap()), 49 | }) 50 | } 51 | 52 | pub fn merge_native_thread( 53 | &mut self, 54 | frames: &Vec, 55 | thread: &remoteprocess::Thread, 56 | ) -> Result, Error> { 57 | if self.should_reload { 58 | self.symbolicator.reload()?; 59 | self.should_reload = false; 60 | } 61 | 62 | // get the native stack from the thread 63 | let native_stack = self.get_thread(thread)?; 64 | 65 | // TODO: merging the two stack together could happen outside of thread lock 66 | self.merge_native_stack(frames, native_stack) 67 | } 68 | pub fn merge_native_stack( 69 | &mut self, 70 | frames: &Vec, 71 | native_stack: Vec, 72 | ) -> Result, Error> { 73 | let mut python_frame_index = 0; 74 | let mut merged = Vec::new(); 75 | 76 | // merge the native_stack and python stack together 77 | for addr in native_stack { 78 | // check in the symbol cache if we have looked up this symbol yet 79 | let cached_symbol = self.symbol_cache.get(&addr).cloned(); 80 | 81 | // merges a remoteprocess::StackFrame into the current merged vec 82 | let is_python_addr = self.python.as_ref().map_or(false, |m| m.contains(addr)) 83 | || self.libpython.as_ref().map_or(false, |m| m.contains(addr)); 84 | let merge_frame = &mut |frame: &remoteprocess::StackFrame| { 85 | match self.get_merge_strategy(is_python_addr, frame) { 86 | MergeType::Ignore => {} 87 | MergeType::MergeNativeFrame => { 88 | if let Some(python_frame) = self.translate_native_frame(frame) { 89 | merged.push(python_frame); 90 | } 91 | } 92 | MergeType::MergePythonFrame => { 93 | // if we have a corresponding python frame for the evalframe 94 | // merge it into the stack. (if we're out of bounds a later 95 | // check will pick up - and report overall totals mismatch) 96 | 97 | // Merge all python frames until we hit one with `is_entry`. 98 | while python_frame_index < frames.len() { 99 | merged.push(frames[python_frame_index].clone()); 100 | 101 | if frames[python_frame_index].is_entry { 102 | break; 103 | } 104 | 105 | python_frame_index += 1; 106 | } 107 | python_frame_index += 1; 108 | } 109 | } 110 | }; 111 | 112 | if let Some(frame) = cached_symbol { 113 | merge_frame(&frame); 114 | continue; 115 | } 116 | 117 | // Keep track of the first symbolicated frame for caching. We don't cache anything (yet) where 118 | // symoblicationg returns multiple frames for an address, like in the case of inlined function calls. 119 | // so track how many frames we get for the address, and only update cache in the happy case 120 | // of 1 frame 121 | let mut symbolicated_count = 0; 122 | let mut first_frame = None; 123 | 124 | self.symbolicator 125 | .symbolicate( 126 | addr, 127 | !is_python_addr, 128 | &mut |frame: &remoteprocess::StackFrame| { 129 | symbolicated_count += 1; 130 | if symbolicated_count == 1 { 131 | first_frame = Some(frame.clone()); 132 | } 133 | merge_frame(frame); 134 | }, 135 | ) 136 | .unwrap_or_else(|e| { 137 | if let remoteprocess::Error::NoBinaryForAddress(_) = e { 138 | debug!( 139 | "don't have a binary for symbols at 0x{:x} - reloading", 140 | addr 141 | ); 142 | self.should_reload = true; 143 | } 144 | // if we can't symbolicate, just insert a stub here. 145 | merged.push(Frame { 146 | filename: "?".to_owned(), 147 | name: format!("0x{:x}", addr), 148 | line: 0, 149 | short_filename: None, 150 | module: None, 151 | locals: None, 152 | is_entry: true, 153 | }); 154 | }); 155 | 156 | if symbolicated_count == 1 { 157 | self.symbol_cache.put(addr, first_frame.unwrap()); 158 | } 159 | } 160 | 161 | if python_frame_index != frames.len() { 162 | if python_frame_index == 0 { 163 | // I've seen a problem come up a bunch where we only get 1-2 native stack traces and then it fails 164 | // (with a valid python stack trace on top of that). both the gimli and libunwind unwinder don't 165 | // return the full stack, and connecting up to the process with GDB brings a corrupt stack error: 166 | // from /home/ben/anaconda3/lib/python3.7/site-packages/numpy/core/../../../../libmkl_avx512.so 167 | // Backtrace stopped: previous frame inner to this frame (corrupt stack?) 168 | // 169 | // rather than fail here, lets just insert the python frames after the native frames 170 | for frame in frames { 171 | merged.push(frame.clone()); 172 | } 173 | } else if python_frame_index == frames.len() + 1 { 174 | // if we have seen exactly one more python frame in the native stack than the python stack - let it go. 175 | // (can happen when the python stack has been unwound, but haven't exited the PyEvalFrame function 176 | // yet) 177 | info!( 178 | "Have {} native and {} python threads in stack - allowing for now", 179 | python_frame_index, 180 | frames.len() 181 | ); 182 | } else { 183 | return Err(format_err!( 184 | "Failed to merge native and python frames (Have {} native and {} python)", 185 | python_frame_index, 186 | frames.len() 187 | )); 188 | } 189 | } 190 | 191 | // TODO: can this by merged into translate_frame? 192 | for frame in merged.iter_mut() { 193 | self.cython_maps.translate(frame); 194 | } 195 | 196 | Ok(merged) 197 | } 198 | 199 | fn get_merge_strategy( 200 | &self, 201 | check_python: bool, 202 | frame: &remoteprocess::StackFrame, 203 | ) -> MergeType { 204 | if check_python { 205 | if let Some(ref function) = frame.function { 206 | // We want to include some internal python functions. For example, calls like time.sleep 207 | // or os.kill etc are implemented as builtins in the interpreter and filtering them out 208 | // is misleading. Create a set of whitelisted python function prefixes to include 209 | lazy_static! { 210 | static ref WHITELISTED_PREFIXES: HashSet<&'static str> = { 211 | let mut prefixes = HashSet::new(); 212 | prefixes.insert("time"); 213 | prefixes.insert("sys"); 214 | prefixes.insert("gc"); 215 | prefixes.insert("os"); 216 | prefixes.insert("unicode"); 217 | prefixes.insert("thread"); 218 | prefixes.insert("stringio"); 219 | prefixes.insert("sre"); 220 | // likewise reasoning about lock contention inside python is also useful 221 | prefixes.insert("PyGilState"); 222 | prefixes.insert("PyThread"); 223 | prefixes.insert("lock"); 224 | prefixes 225 | }; 226 | } 227 | 228 | // Figure out the merge type by looking at the function name, frames that 229 | // are used in evaluating python code are ignored, aside from PyEval_EvalFrame* 230 | // which is replaced by the function from the python stack 231 | // note: we're splitting on both _ and . to handle symbols like 232 | // _PyEval_EvalFrameDefault.cold.2962 233 | let mut tokens = function.split(&['_', '.'][..]).filter(|&x| !x.is_empty()); 234 | match tokens.next() { 235 | Some("PyEval") => match tokens.next() { 236 | Some("EvalFrameDefault") => MergeType::MergePythonFrame, 237 | Some("EvalFrameEx") => MergeType::MergePythonFrame, 238 | _ => MergeType::Ignore, 239 | }, 240 | Some(prefix) if WHITELISTED_PREFIXES.contains(prefix) => { 241 | MergeType::MergeNativeFrame 242 | } 243 | _ => MergeType::Ignore, 244 | } 245 | } else { 246 | // is this correct? if we don't have a function name and in python binary should ignore? 247 | MergeType::Ignore 248 | } 249 | } else { 250 | MergeType::MergeNativeFrame 251 | } 252 | } 253 | 254 | /// translates a native frame into a optional frame. none indicates we should ignore this frame 255 | fn translate_native_frame(&self, frame: &remoteprocess::StackFrame) -> Option { 256 | match &frame.function { 257 | Some(func) => { 258 | if ignore_frame(func, &frame.module) { 259 | return None; 260 | } 261 | 262 | // Get the filename/line/function name here 263 | let line = frame.line.unwrap_or(0) as i32; 264 | 265 | // try to resolve the filename relative to the module if given 266 | let filename = match frame.filename.as_ref() { 267 | Some(filename) => resolve_filename(filename, &frame.module) 268 | .unwrap_or_else(|| filename.clone()), 269 | None => frame.module.clone(), 270 | }; 271 | 272 | let mut demangled = None; 273 | if func.starts_with('_') { 274 | if let Ok((sym, _)) = BorrowedSymbol::with_tail(func.as_bytes()) { 275 | let options = DemangleOptions::new().no_params().no_return_type(); 276 | if let Ok(sym) = sym.demangle(&options) { 277 | demangled = Some(sym); 278 | } 279 | } 280 | } 281 | let name = demangled.as_ref().unwrap_or(func); 282 | if cython::ignore_frame(name) { 283 | return None; 284 | } 285 | let name = cython::demangle(name).to_owned(); 286 | Some(Frame { 287 | filename, 288 | line, 289 | name, 290 | short_filename: None, 291 | module: Some(frame.module.clone()), 292 | locals: None, 293 | is_entry: true, 294 | }) 295 | } 296 | None => Some(Frame { 297 | filename: frame.module.clone(), 298 | name: format!("0x{:x}", frame.addr), 299 | locals: None, 300 | line: 0, 301 | short_filename: None, 302 | module: Some(frame.module.clone()), 303 | is_entry: true, 304 | }), 305 | } 306 | } 307 | 308 | fn get_thread(&mut self, thread: &remoteprocess::Thread) -> Result, Error> { 309 | let mut stack = Vec::new(); 310 | for ip in self.unwinder.cursor(thread)? { 311 | stack.push(ip?); 312 | } 313 | Ok(stack) 314 | } 315 | } 316 | 317 | #[derive(Debug)] 318 | enum MergeType { 319 | Ignore, 320 | MergePythonFrame, 321 | MergeNativeFrame, 322 | } 323 | 324 | // the intent here is to remove top-level libc or pthreads calls 325 | // from the stack traces. This almost certainly can be done better 326 | #[cfg(target_os = "linux")] 327 | fn ignore_frame(function: &str, module: &str) -> bool { 328 | if function == "__libc_start_main" && module.contains("/libc") { 329 | return true; 330 | } 331 | 332 | if function == "__clone" && module.contains("/libc") { 333 | return true; 334 | } 335 | 336 | if function == "start_thread" && module.contains("/libpthread") { 337 | return true; 338 | } 339 | 340 | false 341 | } 342 | 343 | #[cfg(target_os = "macos")] 344 | fn ignore_frame(function: &str, module: &str) -> bool { 345 | if function == "_start" && module.contains("/libdyld.dylib") { 346 | return true; 347 | } 348 | 349 | if function == "__pthread_body" && module.contains("/libsystem_pthread") { 350 | return true; 351 | } 352 | 353 | if function == "_thread_start" && module.contains("/libsystem_pthread") { 354 | return true; 355 | } 356 | 357 | false 358 | } 359 | 360 | #[cfg(windows)] 361 | fn ignore_frame(function: &str, module: &str) -> bool { 362 | if function == "RtlUserThreadStart" && module.to_lowercase().ends_with("ntdll.dll") { 363 | return true; 364 | } 365 | 366 | if function == "BaseThreadInitThunk" && module.to_lowercase().ends_with("kernel32.dll") { 367 | return true; 368 | } 369 | 370 | false 371 | } 372 | -------------------------------------------------------------------------------- /src/python_bindings/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod v2_7_15; 2 | pub mod v3_10_0; 3 | pub mod v3_11_0; 4 | pub mod v3_12_0; 5 | pub mod v3_13_0; 6 | pub mod v3_3_7; 7 | pub mod v3_5_5; 8 | pub mod v3_6_6; 9 | pub mod v3_7_0; 10 | pub mod v3_8_0; 11 | pub mod v3_9_5; 12 | 13 | // currently the PyRuntime struct used from Python 3.7 on really can't be 14 | // exposed in a cross platform way using bindgen. PyRuntime has several mutex's 15 | // as member variables, and these have different sizes depending on the operating 16 | // system and system architecture. 17 | // Instead we will define some constants here that define valid offsets for the 18 | // member variables we care about here 19 | // (note 'generate_bindings.py' has code to figure out these offsets) 20 | pub mod pyruntime { 21 | use crate::version::Version; 22 | 23 | // There aren't any OS specific members of PyRuntime before pyinterpreters.head, 24 | // so these offsets should be valid for all OS'es 25 | #[cfg(target_arch = "x86")] 26 | pub fn get_interp_head_offset(version: &Version) -> usize { 27 | match version { 28 | Version { 29 | major: 3, 30 | minor: 8, 31 | patch: 0, 32 | .. 33 | } => match version.release_flags.as_ref() { 34 | "a1" | "a2" => 16, 35 | "a3" | "a4" => 20, 36 | _ => 24, 37 | }, 38 | Version { 39 | major: 3, 40 | minor: 8..=10, 41 | .. 42 | } => 24, 43 | _ => 16, 44 | } 45 | } 46 | 47 | #[cfg(target_arch = "arm")] 48 | pub fn get_interp_head_offset(version: &Version) -> usize { 49 | match version { 50 | Version { 51 | major: 3, minor: 7, .. 52 | } => 20, 53 | _ => 28, 54 | } 55 | } 56 | 57 | #[cfg(target_pointer_width = "64")] 58 | pub fn get_interp_head_offset(version: &Version) -> usize { 59 | match version { 60 | Version { 61 | major: 3, 62 | minor: 8, 63 | patch: 0, 64 | .. 65 | } => match version.release_flags.as_ref() { 66 | "a1" | "a2" => 24, 67 | _ => 32, 68 | }, 69 | Version { 70 | major: 3, 71 | minor: 8..=10, 72 | .. 73 | } => 32, 74 | Version { 75 | major: 3, 76 | minor: 11..=12, 77 | .. 78 | } => 40, 79 | _ => 24, 80 | } 81 | } 82 | 83 | // getting gilstate.tstate_current is different for all OS 84 | // and is also different for each python version, and even 85 | // between v3.8.0a1 and v3.8.0a2 =( 86 | #[cfg(target_os = "macos")] 87 | pub fn get_tstate_current_offset(version: &Version) -> Option { 88 | match version { 89 | Version { 90 | major: 3, 91 | minor: 7, 92 | patch: 0..=3, 93 | .. 94 | } => Some(1440), 95 | Version { 96 | major: 3, minor: 7, .. 97 | } => Some(1528), 98 | Version { 99 | major: 3, 100 | minor: 8, 101 | patch: 0, 102 | .. 103 | } => match version.release_flags.as_ref() { 104 | "a1" => Some(1432), 105 | "a2" => Some(888), 106 | "a3" | "a4" => Some(1448), 107 | _ => Some(1416), 108 | }, 109 | Version { 110 | major: 3, minor: 8, .. 111 | } => Some(1416), 112 | Version { 113 | major: 3, 114 | minor: 9..=10, 115 | .. 116 | } => Some(616), 117 | Version { 118 | major: 3, 119 | minor: 11, 120 | .. 121 | } => Some(624), 122 | _ => None, 123 | } 124 | } 125 | 126 | #[cfg(all(target_os = "linux", target_arch = "x86"))] 127 | pub fn get_tstate_current_offset(version: &Version) -> Option { 128 | match version { 129 | Version { 130 | major: 3, minor: 7, .. 131 | } => Some(796), 132 | Version { 133 | major: 3, 134 | minor: 8, 135 | patch: 0, 136 | .. 137 | } => match version.release_flags.as_ref() { 138 | "a1" => Some(792), 139 | "a2" => Some(512), 140 | "a3" | "a4" => Some(800), 141 | _ => Some(788), 142 | }, 143 | Version { 144 | major: 3, minor: 8, .. 145 | } => Some(788), 146 | Version { 147 | major: 3, 148 | minor: 9..=10, 149 | .. 150 | } => Some(352), 151 | _ => None, 152 | } 153 | } 154 | 155 | #[cfg(all(target_os = "linux", target_arch = "arm"))] 156 | pub fn get_tstate_current_offset(version: &Version) -> Option { 157 | match version { 158 | Version { 159 | major: 3, minor: 7, .. 160 | } => Some(828), 161 | Version { 162 | major: 3, minor: 8, .. 163 | } => Some(804), 164 | Version { 165 | major: 3, 166 | minor: 9..=11, 167 | .. 168 | } => Some(364), 169 | _ => None, 170 | } 171 | } 172 | 173 | #[cfg(all(target_os = "linux", target_arch = "aarch64"))] 174 | pub fn get_tstate_current_offset(version: &Version) -> Option { 175 | match version { 176 | Version { 177 | major: 3, 178 | minor: 7, 179 | patch: 0..=3, 180 | .. 181 | } => Some(1408), 182 | Version { 183 | major: 3, minor: 7, .. 184 | } => Some(1496), 185 | Version { 186 | major: 3, minor: 8, .. 187 | } => Some(1384), 188 | Version { 189 | major: 3, 190 | minor: 9..=10, 191 | .. 192 | } => Some(584), 193 | Version { 194 | major: 3, 195 | minor: 11, 196 | .. 197 | } => Some(592), 198 | _ => None, 199 | } 200 | } 201 | 202 | #[cfg(all(target_os = "linux", target_arch = "x86_64"))] 203 | pub fn get_tstate_current_offset(version: &Version) -> Option { 204 | match version { 205 | Version { 206 | major: 3, 207 | minor: 7, 208 | patch: 0..=3, 209 | .. 210 | } => Some(1392), 211 | Version { 212 | major: 3, minor: 7, .. 213 | } => Some(1480), 214 | Version { 215 | major: 3, 216 | minor: 8, 217 | patch: 0, 218 | .. 219 | } => match version.release_flags.as_ref() { 220 | "a1" => Some(1384), 221 | "a2" => Some(840), 222 | "a3" | "a4" => Some(1400), 223 | _ => Some(1368), 224 | }, 225 | Version { 226 | major: 3, minor: 8, .. 227 | } => match version.build_metadata.as_deref() { 228 | Some("cinder") => Some(1384), 229 | _ => Some(1368), 230 | }, 231 | Version { 232 | major: 3, 233 | minor: 9..=10, 234 | .. 235 | } => Some(568), 236 | Version { 237 | major: 3, 238 | minor: 11, 239 | .. 240 | } => Some(576), 241 | _ => None, 242 | } 243 | } 244 | 245 | #[cfg(all( 246 | target_os = "linux", 247 | any( 248 | target_arch = "powerpc64", 249 | target_arch = "powerpc", 250 | target_arch = "mips" 251 | ) 252 | ))] 253 | pub fn get_tstate_current_offset(version: &Version) -> Option { 254 | None 255 | } 256 | 257 | #[cfg(windows)] 258 | pub fn get_tstate_current_offset(version: &Version) -> Option { 259 | match version { 260 | Version { 261 | major: 3, 262 | minor: 7, 263 | patch: 0..=3, 264 | .. 265 | } => Some(1320), 266 | Version { 267 | major: 3, 268 | minor: 8, 269 | patch: 0, 270 | .. 271 | } => match version.release_flags.as_ref() { 272 | "a1" => Some(1312), 273 | "a2" => Some(768), 274 | "a3" | "a4" => Some(1328), 275 | _ => Some(1296), 276 | }, 277 | Version { 278 | major: 3, minor: 8, .. 279 | } => Some(1296), 280 | Version { 281 | major: 3, 282 | minor: 9..=10, 283 | .. 284 | } => Some(496), 285 | Version { 286 | major: 3, 287 | minor: 11, 288 | .. 289 | } => Some(504), 290 | _ => None, 291 | } 292 | } 293 | 294 | #[cfg(target_os = "freebsd")] 295 | pub fn get_tstate_current_offset(version: &Version) -> Option { 296 | match version { 297 | Version { 298 | major: 3, 299 | minor: 7, 300 | patch: 0..=3, 301 | .. 302 | } => Some(1248), 303 | Version { 304 | major: 3, 305 | minor: 7, 306 | patch: 4..=7, 307 | .. 308 | } => Some(1336), 309 | Version { 310 | major: 3, 311 | minor: 8, 312 | patch: 0, 313 | .. 314 | } => match version.release_flags.as_ref() { 315 | "a1" => Some(1240), 316 | "a2" => Some(696), 317 | "a3" | "a4" => Some(1256), 318 | _ => Some(1224), 319 | }, 320 | Version { 321 | major: 3, minor: 8, .. 322 | } => Some(1224), 323 | Version { 324 | major: 3, 325 | minor: 9..=10, 326 | .. 327 | } => Some(424), 328 | Version { 329 | major: 3, 330 | minor: 11, 331 | .. 332 | } => Some(432), 333 | _ => None, 334 | } 335 | } 336 | } 337 | -------------------------------------------------------------------------------- /src/python_threading.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use anyhow::Error; 4 | 5 | use crate::python_bindings::{v3_10_0, v3_11_0, v3_12_0, v3_13_0, v3_6_6, v3_7_0, v3_8_0, v3_9_5}; 6 | use crate::python_data_access::{copy_long, copy_string, DictIterator, PY_TPFLAGS_MANAGED_DICT}; 7 | use crate::python_interpreters::{InterpreterState, Object, TypeObject}; 8 | use crate::python_spy::PythonSpy; 9 | 10 | use crate::version::Version; 11 | 12 | use remoteprocess::ProcessMemory; 13 | 14 | /// Returns a hashmap of threadid: threadname, by inspecting the '_active' variable in the 15 | /// 'threading' module. 16 | pub fn thread_names_from_interpreter( 17 | interp: &I, 18 | process: &P, 19 | version: &Version, 20 | ) -> Result, Error> { 21 | let mut ret = HashMap::new(); 22 | for entry in DictIterator::from(process, version, interp.modules() as usize)? { 23 | let (key, value) = entry?; 24 | let module_name = copy_string(key as *const I::StringObject, process)?; 25 | if module_name == "threading" { 26 | let module: I::Object = process.copy_struct(value)?; 27 | let module_type = process.copy_pointer(module.ob_type())?; 28 | let dictptr: usize = process.copy_struct(value + module_type.dictoffset() as usize)?; 29 | for i in DictIterator::from(process, version, dictptr)? { 30 | let (key, value) = i?; 31 | let name = copy_string(key as *const I::StringObject, process)?; 32 | if name == "_active" { 33 | for i in DictIterator::from(process, version, value)? { 34 | let (key, value) = i?; 35 | let (threadid, _) = copy_long(process, version, key)?; 36 | 37 | let thread: I::Object = process.copy_struct(value)?; 38 | let thread_type = process.copy_pointer(thread.ob_type())?; 39 | let flags = thread_type.flags(); 40 | 41 | let dict_iter = if flags & PY_TPFLAGS_MANAGED_DICT != 0 { 42 | DictIterator::from_managed_dict( 43 | process, 44 | version, 45 | value, 46 | thread.ob_type() as usize, 47 | flags, 48 | )? 49 | } else { 50 | let dict_offset = thread_type.dictoffset(); 51 | let dict_addr = (value as isize + dict_offset) as usize; 52 | let thread_dict_addr: usize = process.copy_struct(dict_addr)?; 53 | DictIterator::from(process, version, thread_dict_addr)? 54 | }; 55 | 56 | for i in dict_iter { 57 | let (key, value) = i?; 58 | let varname = copy_string(key as *const I::StringObject, process)?; 59 | if varname == "_name" { 60 | let threadname = 61 | copy_string(value as *const I::StringObject, process)?; 62 | ret.insert(threadid as u64, threadname); 63 | break; 64 | } 65 | } 66 | } 67 | break; 68 | } 69 | } 70 | break; 71 | } 72 | } 73 | Ok(ret) 74 | } 75 | 76 | /// Returns a hashmap of threadid: threadname, by inspecting the '_active' variable in the 77 | /// 'threading' module. 78 | fn _thread_name_lookup( 79 | spy: &PythonSpy, 80 | ) -> Result, Error> { 81 | let interp: I = spy.process.copy_struct(spy.interpreter_address)?; 82 | thread_names_from_interpreter(&interp, &spy.process, &spy.version) 83 | } 84 | 85 | // try getting the threadnames, but don't sweat it if we can't. Since this relies on dictionary 86 | // processing we only handle py3.6+ right now, and this doesn't work at all if the 87 | // threading module isn't imported in the target program 88 | pub fn thread_name_lookup(process: &PythonSpy) -> Option> { 89 | let err = match process.version { 90 | Version { 91 | major: 3, minor: 6, .. 92 | } => _thread_name_lookup::(process), 93 | Version { 94 | major: 3, minor: 7, .. 95 | } => _thread_name_lookup::(process), 96 | Version { 97 | major: 3, minor: 8, .. 98 | } => _thread_name_lookup::(process), 99 | Version { 100 | major: 3, minor: 9, .. 101 | } => _thread_name_lookup::(process), 102 | Version { 103 | major: 3, 104 | minor: 10, 105 | .. 106 | } => _thread_name_lookup::(process), 107 | Version { 108 | major: 3, 109 | minor: 11, 110 | .. 111 | } => _thread_name_lookup::(process), 112 | Version { 113 | major: 3, 114 | minor: 12, 115 | .. 116 | } => _thread_name_lookup::(process), 117 | Version { 118 | major: 3, 119 | minor: 13, 120 | .. 121 | } => _thread_name_lookup::(process), 122 | _ => return None, 123 | }; 124 | err.ok() 125 | } 126 | -------------------------------------------------------------------------------- /src/sampler.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::type_complexity)] 2 | 3 | use std::collections::HashMap; 4 | use std::sync::mpsc::{self, Receiver, Sender}; 5 | use std::sync::{Arc, Mutex}; 6 | use std::thread; 7 | use std::time::Duration; 8 | 9 | use anyhow::Error; 10 | 11 | use remoteprocess::Pid; 12 | 13 | use crate::config::Config; 14 | use crate::python_spy::PythonSpy; 15 | use crate::stack_trace::{ProcessInfo, StackTrace}; 16 | use crate::timer::Timer; 17 | use crate::version::Version; 18 | 19 | pub struct Sampler { 20 | pub version: Option, 21 | rx: Option>, 22 | sampling_thread: Option>, 23 | } 24 | 25 | pub struct Sample { 26 | pub traces: Vec, 27 | pub sampling_errors: Option>, 28 | pub late: Option, 29 | } 30 | 31 | impl Sampler { 32 | pub fn new(pid: Pid, config: &Config) -> Result { 33 | if config.subprocesses { 34 | Self::new_subprocess_sampler(pid, config) 35 | } else { 36 | Self::new_sampler(pid, config) 37 | } 38 | } 39 | 40 | /// Creates a new sampler object, reading from a single process only 41 | fn new_sampler(pid: Pid, config: &Config) -> Result { 42 | let (tx, rx): (Sender, Receiver) = mpsc::channel(); 43 | let (initialized_tx, initialized_rx): ( 44 | Sender>, 45 | Receiver>, 46 | ) = mpsc::channel(); 47 | let config = config.clone(); 48 | let sampling_thread = thread::spawn(move || { 49 | // We need to create this object inside the thread here since PythonSpy objects don't 50 | // have the Send trait implemented on linux 51 | let mut spy = match PythonSpy::retry_new(pid, &config, 20) { 52 | Ok(spy) => { 53 | if initialized_tx.send(Ok(spy.version.clone())).is_err() { 54 | return; 55 | } 56 | spy 57 | } 58 | Err(e) => { 59 | initialized_tx.send(Err(e)).unwrap(); 60 | return; 61 | } 62 | }; 63 | 64 | for sleep in Timer::new(spy.config.sampling_rate as f64) { 65 | let mut sampling_errors = None; 66 | let traces = match spy.get_stack_traces() { 67 | Ok(traces) => traces, 68 | Err(e) => { 69 | if spy.process.exe().is_err() { 70 | info!( 71 | "stopped sampling pid {} because the process exited", 72 | spy.pid 73 | ); 74 | break; 75 | } 76 | sampling_errors = Some(vec![(spy.pid, e)]); 77 | Vec::new() 78 | } 79 | }; 80 | 81 | let late = sleep.err(); 82 | if tx 83 | .send(Sample { 84 | traces, 85 | sampling_errors, 86 | late, 87 | }) 88 | .is_err() 89 | { 90 | break; 91 | } 92 | } 93 | }); 94 | 95 | let version = initialized_rx.recv()??; 96 | Ok(Sampler { 97 | rx: Some(rx), 98 | version: Some(version), 99 | sampling_thread: Some(sampling_thread), 100 | }) 101 | } 102 | 103 | /// Creates a new sampler object that samples any python process in the 104 | /// process or child processes 105 | fn new_subprocess_sampler(pid: Pid, config: &Config) -> Result { 106 | let process = remoteprocess::Process::new(pid)?; 107 | 108 | // Initialize a PythonSpy object per child, and build up the process tree 109 | let mut spies = HashMap::new(); 110 | let mut retries = 10; 111 | spies.insert(pid, PythonSpyThread::new(pid, None, config)?); 112 | 113 | loop { 114 | for (childpid, parentpid) in process.child_processes()? { 115 | // If we can't create the child process, don't worry about it 116 | // can happen with zombie child processes etc 117 | match PythonSpyThread::new(childpid, Some(parentpid), config) { 118 | Ok(spy) => { 119 | spies.insert(childpid, spy); 120 | } 121 | Err(e) => { 122 | warn!("Failed to open process {}: {}", childpid, e); 123 | } 124 | } 125 | } 126 | 127 | // wait for all the various python spy objects to initialize, and break out of here 128 | // if we have one of them started. 129 | if spies.values_mut().any(|spy| spy.wait_initialized()) { 130 | break; 131 | } 132 | 133 | // Otherwise sleep for a short time and retry 134 | retries -= 1; 135 | if retries == 0 { 136 | return Err(format_err!( 137 | "No python processes found in process {} or any of its subprocesses", 138 | pid 139 | )); 140 | } 141 | std::thread::sleep(std::time::Duration::from_millis(100)); 142 | } 143 | 144 | // Create a new thread to periodically monitor for new child processes, and update 145 | // the procesess map 146 | let spies = Arc::new(Mutex::new(spies)); 147 | let monitor_spies = spies.clone(); 148 | let monitor_config = config.clone(); 149 | std::thread::spawn(move || { 150 | while process.exe().is_ok() { 151 | match monitor_spies.lock() { 152 | Ok(mut spies) => { 153 | for (childpid, parentpid) in process 154 | .child_processes() 155 | .expect("failed to get subprocesses") 156 | { 157 | if spies.contains_key(&childpid) { 158 | continue; 159 | } 160 | match PythonSpyThread::new(childpid, Some(parentpid), &monitor_config) { 161 | Ok(spy) => { 162 | spies.insert(childpid, spy); 163 | } 164 | Err(e) => { 165 | warn!("Failed to create spy for {}: {}", childpid, e); 166 | } 167 | } 168 | } 169 | } 170 | Err(e) => { 171 | error!("Failed to acquire lock: {}", e); 172 | } 173 | } 174 | std::thread::sleep(Duration::from_millis(100)); 175 | } 176 | }); 177 | 178 | let mut process_info = HashMap::new(); 179 | 180 | // Create a new thread to generate samples 181 | let config = config.clone(); 182 | let (tx, rx): (Sender, Receiver) = mpsc::channel(); 183 | let sampling_thread = std::thread::spawn(move || { 184 | for sleep in Timer::new(config.sampling_rate as f64) { 185 | let mut traces = Vec::new(); 186 | let mut sampling_errors = None; 187 | 188 | let mut spies = match spies.lock() { 189 | Ok(current) => current, 190 | Err(e) => { 191 | error!("Failed to get process tree: {}", e); 192 | continue; 193 | } 194 | }; 195 | 196 | // Notify all the initialized spies to generate a trace 197 | for spy in spies.values_mut() { 198 | if spy.initialized() { 199 | spy.notify(); 200 | } 201 | } 202 | 203 | // collect the traces from each python spy if possible 204 | for spy in spies.values_mut() { 205 | match spy.collect() { 206 | Some(Ok(mut t)) => traces.append(&mut t), 207 | Some(Err(e)) => { 208 | let errors = sampling_errors.get_or_insert_with(Vec::new); 209 | errors.push((spy.process.pid, e)); 210 | } 211 | None => {} 212 | } 213 | } 214 | 215 | // Annotate each trace with the process info 216 | for trace in traces.iter_mut() { 217 | let pid = trace.pid; 218 | // Annotate each trace with the process info for the current 219 | let process = process_info 220 | .entry(pid) 221 | .or_insert_with(|| get_process_info(pid, &spies).map(|p| Arc::new(*p))); 222 | trace.process_info = process.clone(); 223 | } 224 | 225 | // Send the collected info back 226 | let late = sleep.err(); 227 | if tx 228 | .send(Sample { 229 | traces, 230 | sampling_errors, 231 | late, 232 | }) 233 | .is_err() 234 | { 235 | break; 236 | } 237 | 238 | // If all of our spies have stopped, we're done 239 | if spies.len() == 0 || spies.values().all(|x| !x.running) { 240 | break; 241 | } 242 | } 243 | }); 244 | 245 | Ok(Sampler { 246 | rx: Some(rx), 247 | version: None, 248 | sampling_thread: Some(sampling_thread), 249 | }) 250 | } 251 | } 252 | 253 | impl Iterator for Sampler { 254 | type Item = Sample; 255 | fn next(&mut self) -> Option { 256 | self.rx.as_ref().unwrap().recv().ok() 257 | } 258 | } 259 | 260 | impl Drop for Sampler { 261 | fn drop(&mut self) { 262 | self.rx = None; 263 | if let Some(t) = self.sampling_thread.take() { 264 | t.join().unwrap(); 265 | } 266 | } 267 | } 268 | 269 | struct PythonSpyThread { 270 | initialized_rx: Receiver>, 271 | notify_tx: Sender<()>, 272 | sample_rx: Receiver, Error>>, 273 | initialized: Option>, 274 | pub running: bool, 275 | notified: bool, 276 | pub process: remoteprocess::Process, 277 | pub parent: Option, 278 | pub command_line: String, 279 | } 280 | 281 | impl PythonSpyThread { 282 | fn new(pid: Pid, parent: Option, config: &Config) -> Result { 283 | let (initialized_tx, initialized_rx): ( 284 | Sender>, 285 | Receiver>, 286 | ) = mpsc::channel(); 287 | let (notify_tx, notify_rx): (Sender<()>, Receiver<()>) = mpsc::channel(); 288 | let (sample_tx, sample_rx): ( 289 | Sender, Error>>, 290 | Receiver, Error>>, 291 | ) = mpsc::channel(); 292 | let config = config.clone(); 293 | let process = remoteprocess::Process::new(pid)?; 294 | let command_line = process 295 | .cmdline() 296 | .map(|x| x.join(" ")) 297 | .unwrap_or_else(|_| "".to_owned()); 298 | 299 | thread::spawn(move || { 300 | // We need to create this object inside the thread here since PythonSpy objects don't 301 | // have the Send trait implemented on linux 302 | let mut spy = match PythonSpy::retry_new(pid, &config, 5) { 303 | Ok(spy) => { 304 | if initialized_tx.send(Ok(spy.version.clone())).is_err() { 305 | return; 306 | } 307 | spy 308 | } 309 | Err(e) => { 310 | warn!("Failed to profile python from process {}: {}", pid, e); 311 | initialized_tx.send(Err(e)).unwrap(); 312 | return; 313 | } 314 | }; 315 | 316 | for _ in notify_rx.iter() { 317 | let result = spy.get_stack_traces(); 318 | if result.is_err() && spy.process.exe().is_err() { 319 | info!( 320 | "stopped sampling pid {} because the process exited", 321 | spy.pid 322 | ); 323 | break; 324 | } 325 | if sample_tx.send(result).is_err() { 326 | break; 327 | } 328 | } 329 | }); 330 | Ok(PythonSpyThread { 331 | initialized_rx, 332 | notify_tx, 333 | sample_rx, 334 | process, 335 | command_line, 336 | parent, 337 | initialized: None, 338 | running: false, 339 | notified: false, 340 | }) 341 | } 342 | 343 | fn wait_initialized(&mut self) -> bool { 344 | match self.initialized_rx.recv() { 345 | Ok(status) => { 346 | self.running = status.is_ok(); 347 | self.initialized = Some(status); 348 | self.running 349 | } 350 | Err(e) => { 351 | // shouldn't happen, but will be ok if it does 352 | warn!( 353 | "Failed to get initialization status from PythonSpyThread: {}", 354 | e 355 | ); 356 | false 357 | } 358 | } 359 | } 360 | 361 | fn initialized(&mut self) -> bool { 362 | if let Some(init) = self.initialized.as_ref() { 363 | return init.is_ok(); 364 | } 365 | match self.initialized_rx.try_recv() { 366 | Ok(status) => { 367 | self.running = status.is_ok(); 368 | self.initialized = Some(status); 369 | self.running 370 | } 371 | Err(std::sync::mpsc::TryRecvError::Empty) => false, 372 | Err(std::sync::mpsc::TryRecvError::Disconnected) => { 373 | // this *shouldn't* happen 374 | warn!("Failed to get initialization status from PythonSpyThread: disconnected"); 375 | false 376 | } 377 | } 378 | } 379 | 380 | fn notify(&mut self) { 381 | match self.notify_tx.send(()) { 382 | Ok(_) => { 383 | self.notified = true; 384 | } 385 | Err(_) => { 386 | self.running = false; 387 | } 388 | } 389 | } 390 | 391 | fn collect(&mut self) -> Option, Error>> { 392 | if !self.notified { 393 | return None; 394 | } 395 | self.notified = false; 396 | match self.sample_rx.recv() { 397 | Ok(sample) => Some(sample), 398 | Err(_) => { 399 | self.running = false; 400 | None 401 | } 402 | } 403 | } 404 | } 405 | 406 | fn get_process_info(pid: Pid, spies: &HashMap) -> Option> { 407 | spies.get(&pid).map(|spy| { 408 | let parent = spy 409 | .parent 410 | .and_then(|parentpid| get_process_info(parentpid, spies)); 411 | Box::new(ProcessInfo { 412 | pid, 413 | parent, 414 | command_line: spy.command_line.clone(), 415 | }) 416 | }) 417 | } 418 | -------------------------------------------------------------------------------- /src/speedscope.rs: -------------------------------------------------------------------------------- 1 | // This code is adapted from rbspy: 2 | // https://github.com/rbspy/rbspy/tree/master/src/ui/speedscope.rs 3 | // licensed under the MIT License: 4 | /* 5 | MIT License 6 | 7 | Copyright (c) 2016 Julia Evans, Kamal Marhubi 8 | Portions (continuous integration setup) Copyright (c) 2016 Jorge Aparicio 9 | 10 | Permission is hereby granted, free of charge, to any person obtaining a copy 11 | of this software and associated documentation files (the "Software"), to deal 12 | in the Software without restriction, including without limitation the rights 13 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | copies of the Software, and to permit persons to whom the Software is 15 | furnished to do so, subject to the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be included in all 18 | copies or substantial portions of the Software. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | SOFTWARE. 27 | */ 28 | 29 | use std::collections::HashMap; 30 | use std::io; 31 | use std::io::Write; 32 | 33 | use crate::stack_trace; 34 | use remoteprocess::{Pid, Tid}; 35 | 36 | use anyhow::Error; 37 | use serde_derive::{Deserialize, Serialize}; 38 | 39 | use crate::config::Config; 40 | 41 | /* 42 | * This file contains code to export rbspy profiles for use in https://speedscope.app 43 | * 44 | * The TypeScript definitions that define this file format can be found here: 45 | * https://github.com/jlfwong/speedscope/blob/9d13d9/src/lib/file-format-spec.ts 46 | * 47 | * From the TypeScript definition, a JSON schema is generated. The latest 48 | * schema can be found here: https://speedscope.app/file-format-schema.json 49 | * 50 | * This JSON schema conveniently allows to generate type bindings for generating JSON. 51 | * You can use https://app.quicktype.io/ to generate serde_json Rust bindings for the 52 | * given JSON schema. 53 | * 54 | * There are multiple variants of the file format. The variant we're going to generate 55 | * is the "type: sampled" profile, since it most closely maps to rbspy's data recording 56 | * structure. 57 | */ 58 | 59 | #[derive(Debug, Deserialize, Serialize)] 60 | struct SpeedscopeFile { 61 | #[serde(rename = "$schema")] 62 | schema: String, 63 | profiles: Vec, 64 | shared: Shared, 65 | 66 | #[serde(rename = "activeProfileIndex")] 67 | active_profile_index: Option, 68 | 69 | exporter: Option, 70 | 71 | name: Option, 72 | } 73 | 74 | #[derive(Debug, Deserialize, Serialize)] 75 | struct Profile { 76 | #[serde(rename = "type")] 77 | profile_type: ProfileType, 78 | 79 | name: String, 80 | unit: ValueUnit, 81 | 82 | #[serde(rename = "startValue")] 83 | start_value: f64, 84 | 85 | #[serde(rename = "endValue")] 86 | end_value: f64, 87 | 88 | samples: Vec>, 89 | weights: Vec, 90 | } 91 | 92 | #[derive(Debug, Serialize, Deserialize)] 93 | struct Shared { 94 | frames: Vec, 95 | } 96 | 97 | #[derive(Debug, Clone, Serialize, Deserialize)] 98 | struct Frame { 99 | name: String, 100 | file: Option, 101 | line: Option, 102 | col: Option, 103 | } 104 | 105 | #[derive(Debug, Serialize, Deserialize)] 106 | enum ProfileType { 107 | #[serde(rename = "evented")] 108 | Evented, 109 | #[serde(rename = "sampled")] 110 | Sampled, 111 | } 112 | 113 | #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] 114 | enum ValueUnit { 115 | #[serde(rename = "bytes")] 116 | Bytes, 117 | #[serde(rename = "microseconds")] 118 | Microseconds, 119 | #[serde(rename = "milliseconds")] 120 | Milliseconds, 121 | #[serde(rename = "nanoseconds")] 122 | Nanoseconds, 123 | #[serde(rename = "none")] 124 | None, 125 | #[serde(rename = "seconds")] 126 | Seconds, 127 | } 128 | 129 | impl SpeedscopeFile { 130 | pub fn new( 131 | samples: &HashMap<(Pid, Tid), Vec>>, 132 | frames: &[Frame], 133 | thread_name_map: &HashMap<(Pid, Tid), String>, 134 | sample_rate: u64, 135 | ) -> SpeedscopeFile { 136 | let mut profiles: Vec = samples 137 | .iter() 138 | .map(|(thread_id, samples)| { 139 | let end_value = samples.len(); 140 | // we sample at 100 Hz, so scale the end value and weights to match the time unit 141 | let scaled_end_value = end_value as f64 / sample_rate as f64; 142 | let weights: Vec = samples 143 | .iter() 144 | .map(|_s| 1_f64 / sample_rate as f64) 145 | .collect(); 146 | 147 | Profile { 148 | profile_type: ProfileType::Sampled, 149 | name: thread_name_map 150 | .get(thread_id) 151 | .map_or_else(|| "py-spy".to_string(), |x| x.clone()), 152 | unit: ValueUnit::Seconds, 153 | start_value: 0.0, 154 | end_value: scaled_end_value, 155 | samples: samples.clone(), 156 | weights, 157 | } 158 | }) 159 | .collect(); 160 | 161 | profiles.sort_by(|a, b| a.name.cmp(&b.name)); 162 | 163 | SpeedscopeFile { 164 | // This is always the same 165 | schema: "https://www.speedscope.app/file-format-schema.json".to_string(), 166 | active_profile_index: None, 167 | name: Some("py-spy profile".to_string()), 168 | exporter: Some(format!("py-spy@{}", env!("CARGO_PKG_VERSION"))), 169 | profiles, 170 | shared: Shared { 171 | frames: frames.to_owned(), 172 | }, 173 | } 174 | } 175 | } 176 | 177 | impl Frame { 178 | pub fn new(stack_frame: &stack_trace::Frame, show_line_numbers: bool) -> Frame { 179 | Frame { 180 | name: stack_frame.name.clone(), 181 | // TODO: filename? 182 | file: Some(stack_frame.filename.clone()), 183 | line: if show_line_numbers { 184 | Some(stack_frame.line as u32) 185 | } else { 186 | None 187 | }, 188 | col: None, 189 | } 190 | } 191 | } 192 | 193 | pub struct Stats { 194 | samples: HashMap<(Pid, Tid), Vec>>, 195 | frames: Vec, 196 | frame_to_index: HashMap, 197 | thread_name_map: HashMap<(Pid, Tid), String>, 198 | config: Config, 199 | } 200 | 201 | impl Stats { 202 | pub fn new(config: &Config) -> Stats { 203 | Stats { 204 | samples: HashMap::new(), 205 | frames: vec![], 206 | frame_to_index: HashMap::new(), 207 | thread_name_map: HashMap::new(), 208 | config: config.clone(), 209 | } 210 | } 211 | 212 | pub fn record(&mut self, stack: &stack_trace::StackTrace) -> Result<(), io::Error> { 213 | let show_line_numbers = self.config.show_line_numbers; 214 | let mut frame_indices: Vec = stack 215 | .frames 216 | .iter() 217 | .map(|frame| { 218 | let frames = &mut self.frames; 219 | let mut key = frame.clone(); 220 | if !show_line_numbers { 221 | key.line = 0; 222 | } 223 | *self.frame_to_index.entry(key).or_insert_with(|| { 224 | let len = frames.len(); 225 | frames.push(Frame::new(frame, show_line_numbers)); 226 | len 227 | }) 228 | }) 229 | .collect(); 230 | frame_indices.reverse(); 231 | 232 | let key = (stack.pid as Pid, stack.thread_id as Tid); 233 | 234 | self.samples.entry(key).or_default().push(frame_indices); 235 | let subprocesses = self.config.subprocesses; 236 | self.thread_name_map.entry(key).or_insert_with(|| { 237 | let thread_name = stack 238 | .thread_name 239 | .as_ref() 240 | .map_or_else(|| "".to_string(), |x| x.clone()); 241 | if subprocesses { 242 | format!( 243 | "Process {} Thread {} \"{}\"", 244 | stack.pid, 245 | stack.format_threadid(), 246 | thread_name 247 | ) 248 | } else { 249 | format!("Thread {} \"{}\"", stack.format_threadid(), thread_name) 250 | } 251 | }); 252 | 253 | Ok(()) 254 | } 255 | 256 | pub fn write(&self, w: &mut dyn Write) -> Result<(), Error> { 257 | let json = serde_json::to_string(&SpeedscopeFile::new( 258 | &self.samples, 259 | &self.frames, 260 | &self.thread_name_map, 261 | self.config.sampling_rate, 262 | ))?; 263 | writeln!(w, "{}", json)?; 264 | Ok(()) 265 | } 266 | } 267 | 268 | #[cfg(test)] 269 | mod tests { 270 | use super::*; 271 | use std::io::{Cursor, Read, Seek, SeekFrom}; 272 | 273 | #[test] 274 | fn test_speedscope_units() { 275 | let sample_rate = 100; 276 | let config = Config { 277 | show_line_numbers: true, 278 | sampling_rate: sample_rate, 279 | ..Default::default() 280 | }; 281 | let mut stats = Stats::new(&config); 282 | let mut cursor = Cursor::new(Vec::new()); 283 | 284 | let frame = stack_trace::Frame { 285 | name: String::from("test"), 286 | filename: String::from("test.py"), 287 | module: None, 288 | short_filename: None, 289 | line: 0, 290 | locals: None, 291 | is_entry: true, 292 | }; 293 | 294 | let trace = stack_trace::StackTrace { 295 | pid: 1, 296 | thread_id: 1, 297 | thread_name: None, 298 | os_thread_id: None, 299 | active: true, 300 | owns_gil: false, 301 | frames: vec![frame], 302 | process_info: None, 303 | }; 304 | 305 | stats.record(&trace).unwrap(); 306 | stats.write(&mut cursor).unwrap(); 307 | 308 | cursor.seek(SeekFrom::Start(0)).unwrap(); 309 | let mut s = String::new(); 310 | let read = cursor.read_to_string(&mut s).unwrap(); 311 | assert!(read > 0); 312 | let trace: SpeedscopeFile = serde_json::from_str(&s).unwrap(); 313 | 314 | assert_eq!(trace.profiles[0].unit, ValueUnit::Seconds); 315 | assert_eq!(trace.profiles[0].end_value, 1.0 / sample_rate as f64); 316 | } 317 | } 318 | -------------------------------------------------------------------------------- /src/stack_trace.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use anyhow::{Context, Error, Result}; 4 | 5 | use remoteprocess::{Pid, ProcessMemory}; 6 | use serde_derive::Serialize; 7 | 8 | use crate::config::{Config, LineNo}; 9 | use crate::python_data_access::{copy_bytes, copy_string}; 10 | use crate::python_interpreters::{ 11 | CodeObject, FrameObject, InterpreterState, ThreadState, TupleObject, 12 | }; 13 | 14 | /// Call stack for a single python thread 15 | #[derive(Debug, Clone, Serialize)] 16 | pub struct StackTrace { 17 | /// The process id than generated this stack trace 18 | pub pid: Pid, 19 | /// The python thread id for this stack trace 20 | pub thread_id: u64, 21 | // The python thread name for this stack trace 22 | pub thread_name: Option, 23 | /// The OS thread id for this stack tracee 24 | pub os_thread_id: Option, 25 | /// Whether or not the thread was active 26 | pub active: bool, 27 | /// Whether or not the thread held the GIL 28 | pub owns_gil: bool, 29 | /// The frames 30 | pub frames: Vec, 31 | /// process commandline / parent process info 32 | pub process_info: Option>, 33 | } 34 | 35 | /// Information about a single function call in a stack trace 36 | #[derive(Debug, Hash, Eq, PartialEq, Ord, PartialOrd, Clone, Serialize)] 37 | pub struct Frame { 38 | /// The function name 39 | pub name: String, 40 | /// The full filename of the file 41 | pub filename: String, 42 | /// The module/shared library the 43 | pub module: Option, 44 | /// A short, more readable, representation of the filename 45 | pub short_filename: Option, 46 | /// The line number inside the file (or 0 for native frames without line information) 47 | pub line: i32, 48 | /// Local Variables associated with the frame 49 | pub locals: Option>, 50 | /// If this is an entry frame. Each entry frame corresponds to one native frame. 51 | pub is_entry: bool, 52 | } 53 | 54 | #[derive(Debug, Hash, Eq, PartialEq, Ord, PartialOrd, Clone, Serialize)] 55 | pub struct LocalVariable { 56 | pub name: String, 57 | pub addr: usize, 58 | pub arg: bool, 59 | pub repr: Option, 60 | } 61 | 62 | #[derive(Debug, Clone, Serialize)] 63 | pub struct ProcessInfo { 64 | pub pid: Pid, 65 | pub command_line: String, 66 | pub parent: Option>, 67 | } 68 | 69 | /// Given an InterpreterState, this function returns a vector of stack traces for each thread 70 | pub fn get_stack_traces( 71 | interpreter: &I, 72 | process: &P, 73 | threadstate_address: usize, 74 | config: Option<&Config>, 75 | ) -> Result, Error> 76 | where 77 | I: InterpreterState, 78 | P: ProcessMemory, 79 | { 80 | let gil_thread_id = if interpreter.gil_locked().unwrap_or(true) { 81 | get_gil_threadid::(threadstate_address, process)? 82 | } else { 83 | 0 84 | }; 85 | 86 | let mut ret = Vec::new(); 87 | let mut threads = interpreter.head(); 88 | 89 | let lineno = config.map(|c| c.lineno).unwrap_or(LineNo::NoLine); 90 | let dump_locals = config.map(|c| c.dump_locals).unwrap_or(0); 91 | 92 | while !threads.is_null() { 93 | let thread = process 94 | .copy_pointer(threads) 95 | .context("Failed to copy PyThreadState")?; 96 | 97 | let mut trace = get_stack_trace(&thread, process, dump_locals > 0, lineno)?; 98 | trace.owns_gil = trace.thread_id == gil_thread_id; 99 | 100 | ret.push(trace); 101 | // This seems to happen occasionally when scanning BSS addresses for valid interpreters 102 | if ret.len() > 4096 { 103 | return Err(format_err!("Max thread recursion depth reached")); 104 | } 105 | threads = thread.next(); 106 | } 107 | Ok(ret) 108 | } 109 | 110 | /// Gets a stack trace for an individual thread 111 | pub fn get_stack_trace( 112 | thread: &T, 113 | process: &P, 114 | copy_locals: bool, 115 | lineno: LineNo, 116 | ) -> Result 117 | where 118 | T: ThreadState, 119 | P: ProcessMemory, 120 | { 121 | // TODO: just return frames here? everything else probably should be returned out of scope 122 | let mut frames = Vec::new(); 123 | 124 | // python 3.11+ has an extra level of indirection to get the Frame from the threadstate 125 | let mut frame_address = thread.frame_address(); 126 | if let Some(addr) = frame_address { 127 | frame_address = Some(process.copy_struct(addr)?); 128 | } 129 | 130 | let mut frame_ptr = thread.frame(frame_address); 131 | while !frame_ptr.is_null() { 132 | let frame = process 133 | .copy_pointer(frame_ptr) 134 | .context("Failed to copy PyFrameObject")?; 135 | 136 | let code = process 137 | .copy_pointer(frame.code()) 138 | .context("Failed to copy PyCodeObject")?; 139 | 140 | let filename = copy_string(code.filename(), process).context("Failed to copy filename"); 141 | let name = copy_string(code.name(), process).context("Failed to copy function name"); 142 | 143 | // just skip processing the current frame if we can't load the filename or function name. 144 | // this can happen in python 3.13+ since the f_executable isn't guaranteed to be 145 | // a PyCodeObject. We could check the type (and mimic the logic of PyCode_Check here) 146 | // but that would require extra overhead of reading the ob_type per frame - and we 147 | // would also have to figure out what the address of PyCode_Type is (which will be 148 | // easier if something like https://github.com/python/cpython/issues/100987#issuecomment-1487227139 149 | // is merged ) 150 | if filename.is_err() || name.is_err() { 151 | frame_ptr = frame.back(); 152 | continue; 153 | } 154 | let filename = filename?; 155 | let name = name?; 156 | 157 | // skip entries in python 3.12+ 158 | if filename == "" { 159 | frame_ptr = frame.back(); 160 | continue; 161 | } 162 | 163 | let line = match lineno { 164 | LineNo::NoLine => 0, 165 | LineNo::First => code.first_lineno(), 166 | LineNo::LastInstruction => match get_line_number(&code, frame.lasti(), process) { 167 | Ok(line) => line, 168 | Err(e) => { 169 | // Failling to get the line number really shouldn't be fatal here, but 170 | // can happen in extreme cases (https://github.com/benfred/py-spy/issues/164) 171 | // Rather than fail set the linenumber to 0. This is used by the native extensions 172 | // to indicate that we can't load a line number and it should be handled gracefully 173 | warn!( 174 | "Failed to get line number from {}.{}: {}", 175 | filename, name, e 176 | ); 177 | 0 178 | } 179 | }, 180 | }; 181 | 182 | let locals = if copy_locals { 183 | Some(get_locals(&code, frame_ptr, &frame, process)?) 184 | } else { 185 | None 186 | }; 187 | 188 | let is_entry = frame.is_entry(); 189 | 190 | frames.push(Frame { 191 | name, 192 | filename, 193 | line, 194 | short_filename: None, 195 | module: None, 196 | locals, 197 | is_entry, 198 | }); 199 | if frames.len() > 4096 { 200 | return Err(format_err!("Max frame recursion depth reached")); 201 | } 202 | 203 | frame_ptr = frame.back(); 204 | } 205 | 206 | Ok(StackTrace { 207 | pid: 0, 208 | frames, 209 | thread_id: thread.thread_id(), 210 | thread_name: None, 211 | owns_gil: false, 212 | active: true, 213 | os_thread_id: thread.native_thread_id(), 214 | process_info: None, 215 | }) 216 | } 217 | 218 | impl StackTrace { 219 | pub fn status_str(&self) -> &str { 220 | match (self.owns_gil, self.active) { 221 | (_, false) => "idle", 222 | (true, true) => "active+gil", 223 | (false, true) => "active", 224 | } 225 | } 226 | 227 | pub fn format_threadid(&self) -> String { 228 | // native threadids in osx are kinda useless, use the pthread id instead 229 | #[cfg(target_os = "macos")] 230 | return format!("{:#X}", self.thread_id); 231 | 232 | // otherwise use the native threadid if given 233 | #[cfg(not(target_os = "macos"))] 234 | match self.os_thread_id { 235 | Some(tid) => format!("{}", tid), 236 | None => format!("{:#X}", self.thread_id), 237 | } 238 | } 239 | } 240 | 241 | /// Returns the line number from a PyCodeObject (given the lasti index from a PyFrameObject) 242 | fn get_line_number( 243 | code: &C, 244 | lasti: i32, 245 | process: &P, 246 | ) -> Result { 247 | let table = 248 | copy_bytes(code.line_table(), process).context("Failed to copy line number table")?; 249 | Ok(code.get_line_number(lasti, &table)) 250 | } 251 | 252 | fn get_locals( 253 | code: &C, 254 | frameptr: *const F, 255 | frame: &F, 256 | process: &P, 257 | ) -> Result, Error> { 258 | let local_count = code.nlocals() as usize; 259 | let argcount = code.argcount() as usize; 260 | let varnames = process.copy_pointer(code.varnames())?; 261 | 262 | let ptr_size = std::mem::size_of::<*const i32>(); 263 | let locals_addr = frameptr as usize + std::mem::size_of_val(frame) - ptr_size; 264 | 265 | let mut ret = Vec::new(); 266 | 267 | for i in 0..local_count { 268 | let nameptr: *const C::StringObject = 269 | process.copy_struct(varnames.address(code.varnames() as usize, i))?; 270 | let name = copy_string(nameptr, process)?; 271 | let addr: usize = process.copy_struct(locals_addr + i * ptr_size)?; 272 | if addr == 0 { 273 | continue; 274 | } 275 | ret.push(LocalVariable { 276 | name, 277 | addr, 278 | arg: i < argcount, 279 | repr: None, 280 | }); 281 | } 282 | Ok(ret) 283 | } 284 | 285 | pub fn get_gil_threadid( 286 | threadstate_address: usize, 287 | process: &P, 288 | ) -> Result { 289 | // figure out what thread has the GIL by inspecting _PyThreadState_Current 290 | if threadstate_address > 0 { 291 | let addr: usize = process.copy_struct(threadstate_address)?; 292 | 293 | // if the addr is 0, no thread is currently holding the GIL 294 | if addr != 0 { 295 | let threadstate: I::ThreadState = process.copy_struct(addr)?; 296 | return Ok(threadstate.thread_id()); 297 | } 298 | } 299 | Ok(0) 300 | } 301 | 302 | impl ProcessInfo { 303 | pub fn to_frame(&self) -> Frame { 304 | Frame { 305 | name: format!("process {}:\"{}\"", self.pid, self.command_line), 306 | filename: String::from(""), 307 | module: None, 308 | short_filename: None, 309 | line: 0, 310 | locals: None, 311 | is_entry: true, 312 | } 313 | } 314 | } 315 | 316 | #[cfg(test)] 317 | mod tests { 318 | use super::*; 319 | use crate::python_bindings::v3_7_0::PyCodeObject; 320 | use crate::python_data_access::tests::to_byteobject; 321 | use remoteprocess::LocalProcess; 322 | 323 | #[test] 324 | fn test_get_line_number() { 325 | let mut lnotab = to_byteobject(&[0u8, 1, 10, 1, 8, 1, 4, 1]); 326 | let code = PyCodeObject { 327 | co_firstlineno: 3, 328 | co_lnotab: &mut lnotab.base.ob_base.ob_base, 329 | ..Default::default() 330 | }; 331 | let lineno = get_line_number(&code, 30, &LocalProcess).unwrap(); 332 | assert_eq!(lineno, 7); 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /src/timer.rs: -------------------------------------------------------------------------------- 1 | use std::time::{Duration, Instant}; 2 | #[cfg(windows)] 3 | use winapi::um::timeapi; 4 | 5 | use rand_distr::{Distribution, Exp}; 6 | 7 | /// Timer is an iterator that sleeps an appropriate amount of time between iterations 8 | /// so that we can sample the process a certain number of times a second. 9 | /// We're using an irregular sampling strategy to avoid aliasing effects that can happen 10 | /// if the target process runs code at a similar schedule as the profiler: 11 | /// https://github.com/benfred/py-spy/issues/94 12 | pub struct Timer { 13 | start: Instant, 14 | desired: Duration, 15 | exp: Exp, 16 | } 17 | 18 | impl Timer { 19 | pub fn new(rate: f64) -> Timer { 20 | // This changes a system-wide setting on Windows so that the OS wakes up every 1ms 21 | // instead of the default 15.6ms. This is required to have a sleep call 22 | // take less than 15ms, which we need since we usually profile at more than 64hz. 23 | // The downside is that this will increase power usage: good discussions are: 24 | // https://randomascii.wordpress.com/2013/07/08/windows-timer-resolution-megawatts-wasted/ 25 | // and http://www.belshe.com/2010/06/04/chrome-cranking-up-the-clock/ 26 | #[cfg(windows)] 27 | unsafe { 28 | timeapi::timeBeginPeriod(1); 29 | } 30 | 31 | let start = Instant::now(); 32 | Timer { 33 | start, 34 | desired: Duration::from_secs(0), 35 | exp: Exp::new(rate).unwrap(), 36 | } 37 | } 38 | } 39 | 40 | impl Iterator for Timer { 41 | type Item = Result; 42 | 43 | fn next(&mut self) -> Option { 44 | let elapsed = self.start.elapsed(); 45 | 46 | // figure out how many nanoseconds should come between the previous and 47 | // the next sample using an exponential distribution to avoid aliasing 48 | let nanos = 1_000_000_000.0 * self.exp.sample(&mut rand::thread_rng()); 49 | 50 | // since we want to account for the amount of time the sampling takes 51 | // we keep track of when we should sleep to (rather than just sleeping 52 | // the amount of time from the previous line). 53 | self.desired += Duration::from_nanos(nanos as u64); 54 | 55 | // sleep if appropriate, or warn if we are behind in sampling 56 | if self.desired > elapsed { 57 | std::thread::sleep(self.desired - elapsed); 58 | Some(Ok(self.desired - elapsed)) 59 | } else { 60 | Some(Err(elapsed - self.desired)) 61 | } 62 | } 63 | } 64 | 65 | impl Drop for Timer { 66 | fn drop(&mut self) { 67 | #[cfg(windows)] 68 | unsafe { 69 | timeapi::timeEndPeriod(1); 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use num_traits::{CheckedAdd, Zero}; 2 | use std::ops::Add; 3 | 4 | #[cfg(feature = "unwind")] 5 | pub fn resolve_filename(filename: &str, modulename: &str) -> Option { 6 | // check the filename first, if it exists use it 7 | use std::path::Path; 8 | let path = Path::new(filename); 9 | if path.exists() { 10 | return Some(filename.to_owned()); 11 | } 12 | 13 | // try resolving relative the shared library the file is in 14 | let module = Path::new(modulename); 15 | if let Some(parent) = module.parent() { 16 | if let Some(name) = path.file_name() { 17 | let temp = parent.join(name); 18 | if temp.exists() { 19 | return Some(temp.to_string_lossy().to_string()); 20 | } 21 | } 22 | } 23 | 24 | None 25 | } 26 | 27 | pub fn is_subrange( 28 | start: T, 29 | size: T, 30 | sub_start: T, 31 | sub_size: T, 32 | ) -> bool { 33 | !size.is_zero() 34 | && !sub_size.is_zero() 35 | && start.checked_add(&size).is_some() 36 | && sub_start.checked_add(&sub_size).is_some() 37 | && sub_start >= start 38 | && sub_start + sub_size <= start + size 39 | } 40 | 41 | pub fn offset_of(object: *const T, member: *const M) -> usize { 42 | member as usize - object as usize 43 | } 44 | 45 | #[cfg(test)] 46 | mod tests { 47 | use super::*; 48 | 49 | #[test] 50 | fn test_is_subrange() { 51 | assert!(is_subrange( 52 | 0u64, 53 | 0xffff_ffff_ffff_ffff, 54 | 0, 55 | 0xffff_ffff_ffff_ffff 56 | )); 57 | assert!(is_subrange(0, 1, 0, 1)); 58 | assert!(is_subrange(0, 100, 0, 10)); 59 | assert!(is_subrange(0, 100, 90, 10)); 60 | 61 | assert!(!is_subrange(0, 0, 0, 0)); 62 | assert!(!is_subrange(1, 0, 0, 0)); 63 | assert!(!is_subrange(1, 0, 1, 0)); 64 | assert!(!is_subrange(0, 0, 0, 1)); 65 | assert!(!is_subrange(0, 0, 1, 0)); 66 | assert!(!is_subrange( 67 | 1u64, 68 | 0xffff_ffff_ffff_ffff, 69 | 0, 70 | 0xffff_ffff_ffff_ffff 71 | )); 72 | assert!(!is_subrange( 73 | 0u64, 74 | 0xffff_ffff_ffff_ffff, 75 | 1, 76 | 0xffff_ffff_ffff_ffff 77 | )); 78 | assert!(!is_subrange(0, 10, 0, 11)); 79 | assert!(!is_subrange(0, 10, 1, 10)); 80 | assert!(!is_subrange(0, 10, 9, 2)); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/version.rs: -------------------------------------------------------------------------------- 1 | use lazy_static::lazy_static; 2 | use regex::bytes::Regex; 3 | 4 | use anyhow::Error; 5 | 6 | #[derive(Debug, PartialEq, Eq, Clone)] 7 | pub struct Version { 8 | pub major: u64, 9 | pub minor: u64, 10 | pub patch: u64, 11 | pub release_flags: String, 12 | pub build_metadata: Option, 13 | } 14 | 15 | impl Version { 16 | pub fn scan_bytes(data: &[u8]) -> Result { 17 | lazy_static! { 18 | static ref RE: Regex = Regex::new( 19 | r"((2|3)\.(3|4|5|6|7|8|9|10|11|12|13)\.(\d{1,2}))((a|b|c|rc)\d{1,2})?(\+(?:[0-9a-z-]+(?:[.][0-9a-z-]+)*)?)? (.{1,64})" 20 | ) 21 | .unwrap(); 22 | } 23 | 24 | if let Some(cap) = RE.captures_iter(data).next() { 25 | let release = match cap.get(5) { 26 | Some(x) => std::str::from_utf8(x.as_bytes())?, 27 | None => "", 28 | }; 29 | let major = std::str::from_utf8(&cap[2])?.parse::()?; 30 | let minor = std::str::from_utf8(&cap[3])?.parse::()?; 31 | let patch = std::str::from_utf8(&cap[4])?.parse::()?; 32 | let build_metadata = if let Some(s) = cap.get(7) { 33 | Some(std::str::from_utf8(&s.as_bytes()[1..])?.to_owned()) 34 | } else { 35 | None 36 | }; 37 | 38 | let version = std::str::from_utf8(&cap[0])?; 39 | info!("Found matching version string '{}'", version); 40 | #[cfg(windows)] 41 | { 42 | if version.contains("32 bit") { 43 | error!("32-bit python is not yet supported on windows! See https://github.com/benfred/py-spy/issues/31 for updates"); 44 | // we're panic'ing rather than returning an error, since we can't recover from this 45 | // and returning an error would just get the calling code to fall back to other 46 | // methods of trying to find the version 47 | panic!("32-bit python is unsupported on windows"); 48 | } 49 | } 50 | 51 | return Ok(Version { 52 | major, 53 | minor, 54 | patch, 55 | release_flags: release.to_owned(), 56 | build_metadata, 57 | }); 58 | } 59 | Err(format_err!("failed to find version string")) 60 | } 61 | } 62 | 63 | impl std::fmt::Display for Version { 64 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 65 | write!( 66 | f, 67 | "{}.{}.{}{}", 68 | self.major, self.minor, self.patch, self.release_flags 69 | )?; 70 | if let Some(build_metadata) = &self.build_metadata { 71 | write!(f, "+{}", build_metadata,)? 72 | } 73 | Ok(()) 74 | } 75 | } 76 | 77 | #[cfg(test)] 78 | mod tests { 79 | use super::*; 80 | #[test] 81 | fn test_find_version() { 82 | let version = Version::scan_bytes(b"2.7.10 (default, Oct 6 2017, 22:29:07)").unwrap(); 83 | assert_eq!( 84 | version, 85 | Version { 86 | major: 2, 87 | minor: 7, 88 | patch: 10, 89 | release_flags: "".to_owned(), 90 | build_metadata: None, 91 | } 92 | ); 93 | 94 | let version = Version::scan_bytes( 95 | b"3.6.3 |Anaconda custom (64-bit)| (default, Oct 6 2017, 12:04:38)", 96 | ) 97 | .unwrap(); 98 | assert_eq!( 99 | version, 100 | Version { 101 | major: 3, 102 | minor: 6, 103 | patch: 3, 104 | release_flags: "".to_owned(), 105 | build_metadata: None, 106 | } 107 | ); 108 | 109 | let version = 110 | Version::scan_bytes(b"Python 3.7.0rc1 (v3.7.0rc1:dfad352267, Jul 20 2018, 13:27:54)") 111 | .unwrap(); 112 | assert_eq!( 113 | version, 114 | Version { 115 | major: 3, 116 | minor: 7, 117 | patch: 0, 118 | release_flags: "rc1".to_owned(), 119 | build_metadata: None, 120 | } 121 | ); 122 | 123 | let version = 124 | Version::scan_bytes(b"Python 3.10.0rc1 (tags/v3.10.0rc1, Aug 28 2021, 18:25:40)") 125 | .unwrap(); 126 | assert_eq!( 127 | version, 128 | Version { 129 | major: 3, 130 | minor: 10, 131 | patch: 0, 132 | release_flags: "rc1".to_owned(), 133 | build_metadata: None, 134 | } 135 | ); 136 | 137 | let version = 138 | Version::scan_bytes(b"1.7.0rc1 (v1.7.0rc1:dfad352267, Jul 20 2018, 13:27:54)"); 139 | assert!(version.is_err(), "don't match unsupported "); 140 | 141 | let version = Version::scan_bytes(b"3.7 10 "); 142 | assert!(version.is_err(), "needs dotted version"); 143 | 144 | let version = Version::scan_bytes(b"3.7.10fooboo "); 145 | assert!(version.is_err(), "limit suffixes"); 146 | 147 | // v2.7.15+ is a valid version string apparently: https://github.com/benfred/py-spy/issues/81 148 | let version = Version::scan_bytes(b"2.7.15+ (default, Oct 2 2018, 22:12:08)").unwrap(); 149 | assert_eq!( 150 | version, 151 | Version { 152 | major: 2, 153 | minor: 7, 154 | patch: 15, 155 | release_flags: "".to_owned(), 156 | build_metadata: Some("".to_owned()), 157 | } 158 | ); 159 | 160 | let version = Version::scan_bytes(b"2.7.10+dcba (default)").unwrap(); 161 | assert_eq!( 162 | version, 163 | Version { 164 | major: 2, 165 | minor: 7, 166 | patch: 10, 167 | release_flags: "".to_owned(), 168 | build_metadata: Some("dcba".to_owned()), 169 | } 170 | ); 171 | 172 | let version = Version::scan_bytes(b"2.7.10+5-4.abcd (default)").unwrap(); 173 | assert_eq!( 174 | version, 175 | Version { 176 | major: 2, 177 | minor: 7, 178 | patch: 10, 179 | release_flags: "".to_owned(), 180 | build_metadata: Some("5-4.abcd".to_owned()), 181 | } 182 | ); 183 | 184 | let version = Version::scan_bytes(b"2.8.5+cinder (default)").unwrap(); 185 | assert_eq!( 186 | version, 187 | Version { 188 | major: 2, 189 | minor: 8, 190 | patch: 5, 191 | release_flags: "".to_owned(), 192 | build_metadata: Some("cinder".to_owned()), 193 | } 194 | ); 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /tests/integration_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import json 4 | import os 5 | import subprocess 6 | import sys 7 | import re 8 | import tempfile 9 | import unittest 10 | from collections import defaultdict, namedtuple 11 | from shutil import which 12 | 13 | Frame = namedtuple("Frame", ["file", "name", "line", "col"]) 14 | 15 | # disable gil checks on windows - just rely on active 16 | # (doesn't seem to be working quite right - TODO: investigate) 17 | GIL = ["--gil"] if not sys.platform.startswith("win") else [] 18 | 19 | PYSPY = which("py-spy") 20 | 21 | 22 | class TestPyspy(unittest.TestCase): 23 | """Basic tests of using py-spy as a commandline application""" 24 | 25 | def _sample_process(self, script_name, options=None, include_profile_name=False): 26 | if not PYSPY: 27 | raise ValueError("Failed to find py-spy on the path") 28 | 29 | # for permissions reasons, we really want to run the sampled python process as a 30 | # subprocess of the py-spy (works best on linux etc). So we're running the 31 | # record option, and setting different flags. To get the profile output 32 | # we're using the speedscope format (since we can read that in as json) 33 | with tempfile.NamedTemporaryFile() as profile_file: 34 | filename = profile_file.name 35 | if sys.platform.startswith("win"): 36 | filename = "profile.json" 37 | 38 | cmdline = [ 39 | PYSPY, 40 | "record", 41 | "-o", 42 | filename, 43 | "--format", 44 | "speedscope", 45 | "-d", 46 | "2", 47 | ] 48 | cmdline.extend(options or []) 49 | cmdline.extend(["--", sys.executable, script_name]) 50 | env = dict(os.environ, RUST_LOG="info") 51 | subprocess.check_output(cmdline, env=env) 52 | with open(filename) as f: 53 | profiles = json.load(f) 54 | 55 | frames = profiles["shared"]["frames"] 56 | samples = defaultdict(int) 57 | for p in profiles["profiles"]: 58 | for sample in p["samples"]: 59 | if include_profile_name: 60 | samples[ 61 | tuple( 62 | [p["name"]] + [Frame(**frames[frame]) for frame in sample] 63 | ) 64 | ] += 1 65 | else: 66 | samples[tuple(Frame(**frames[frame]) for frame in sample)] += 1 67 | return samples 68 | 69 | def test_longsleep(self): 70 | # running with the gil flag should have ~ no samples returned 71 | if GIL: 72 | profile = self._sample_process(_get_script("longsleep.py"), GIL) 73 | print(profile) 74 | assert sum(profile.values()) <= 10 75 | 76 | # running with the idle flag should have > 95% of samples in the sleep call 77 | profile = self._sample_process(_get_script("longsleep.py"), ["--idle"]) 78 | sample, count = _most_frequent_sample(profile) 79 | assert count >= 95 80 | assert len(sample) == 2 81 | assert sample[0].name == "" 82 | assert sample[0].line == 9 83 | assert sample[1].name == "longsleep" 84 | assert sample[1].line == 5 85 | 86 | def test_busyloop(self): 87 | # can't be sure what line we're on, but we should have ~ all samples holding the gil 88 | profile = self._sample_process(_get_script("busyloop.py"), GIL) 89 | assert sum(profile.values()) >= 95 90 | 91 | def test_thread_names(self): 92 | # we don't support getting thread names on python < 3.6 93 | v = sys.version_info 94 | if v.major < 3 or v.minor < 6: 95 | return 96 | 97 | for _ in range(3): 98 | profile = self._sample_process( 99 | _get_script("thread_names.py"), 100 | ["--threads", "--idle"], 101 | include_profile_name=True, 102 | ) 103 | expected_thread_names = set("CustomThreadName-" + str(i) for i in range(10)) 104 | expected_thread_names.add("MainThread") 105 | name_re = re.compile(r"\"(.*)\"") 106 | actual_thread_names = {name_re.search(p[0]).groups()[0] for p in profile} 107 | if expected_thread_names == actual_thread_names: 108 | break 109 | if expected_thread_names != actual_thread_names: 110 | print( 111 | "failed to get thread names", 112 | expected_thread_names, 113 | actual_thread_names, 114 | ) 115 | 116 | assert expected_thread_names == actual_thread_names 117 | 118 | def test_shell_completions(self): 119 | cmdline = [PYSPY, "completions", "bash"] 120 | subprocess.check_output(cmdline) 121 | 122 | 123 | def _get_script(name): 124 | base_dir = os.path.dirname(__file__) 125 | return os.path.join(base_dir, "scripts", name) 126 | 127 | 128 | def _most_frequent_sample(samples): 129 | frames, count = max(samples.items(), key=lambda x: x[1]) 130 | # lets normalize as a percentage here, rather than raw number of samples 131 | return frames, int(100 * count / sum(samples.values())) 132 | 133 | 134 | if __name__ == "__main__": 135 | print("Testing py-spy @", PYSPY) 136 | unittest.main() 137 | -------------------------------------------------------------------------------- /tests/integration_test.rs: -------------------------------------------------------------------------------- 1 | extern crate py_spy; 2 | use py_spy::{Config, Pid, PythonSpy}; 3 | use std::collections::HashSet; 4 | 5 | struct ScriptRunner { 6 | #[allow(dead_code)] 7 | child: std::process::Child, 8 | } 9 | 10 | impl ScriptRunner { 11 | fn new(process_name: &str, filename: &str) -> ScriptRunner { 12 | let child = std::process::Command::new(process_name) 13 | .arg(filename) 14 | .spawn() 15 | .unwrap(); 16 | ScriptRunner { child } 17 | } 18 | 19 | fn id(&self) -> Pid { 20 | self.child.id() as _ 21 | } 22 | } 23 | 24 | impl Drop for ScriptRunner { 25 | fn drop(&mut self) { 26 | if let Err(err) = self.child.kill() { 27 | eprintln!("Failed to kill child process {}", err); 28 | } 29 | } 30 | } 31 | 32 | struct TestRunner { 33 | #[allow(dead_code)] 34 | child: ScriptRunner, 35 | spy: PythonSpy, 36 | } 37 | 38 | impl TestRunner { 39 | fn new(config: Config, filename: &str) -> TestRunner { 40 | let child = ScriptRunner::new("python", filename); 41 | std::thread::sleep(std::time::Duration::from_millis(400)); 42 | let spy = PythonSpy::retry_new(child.id(), &config, 20).unwrap(); 43 | TestRunner { child, spy } 44 | } 45 | } 46 | 47 | #[test] 48 | fn test_busy_loop() { 49 | #[cfg(target_os = "macos")] 50 | { 51 | // We need root permissions here to run this on OSX 52 | if unsafe { libc::geteuid() } != 0 { 53 | return; 54 | } 55 | } 56 | let mut runner = TestRunner::new(Config::default(), "./tests/scripts/busyloop.py"); 57 | let traces = runner.spy.get_stack_traces().unwrap(); 58 | 59 | // we can't be guaranteed what line the script is processing, but 60 | // we should be able to say that the script is active and 61 | // catch issues like https://github.com/benfred/py-spy/issues/141 62 | assert!(traces[0].active); 63 | } 64 | 65 | #[cfg(feature = "unwind")] 66 | #[test] 67 | fn test_thread_reuse() { 68 | // on linux we had an issue with the pthread -> native thread id caching 69 | // the problem was that the pthreadids were getting re-used, 70 | // and this caused errors on native unwind (since the native thread had 71 | // exited). Test that this works with a simple script that creates 72 | // a couple short lived threads, and then profiling with native enabled 73 | let config = Config { 74 | native: true, 75 | ..Default::default() 76 | }; 77 | let mut runner = TestRunner::new(config, "./tests/scripts/thread_reuse.py"); 78 | 79 | let mut errors = 0; 80 | 81 | for _ in 0..100 { 82 | // should be able to get traces here BUT we do sometimes get errors about 83 | // not being able to suspend process ("No such file or directory (os error 2)" 84 | // when threads exit. Allow a small number of errors here. 85 | if let Err(e) = runner.spy.get_stack_traces() { 86 | println!("Failed to get traces {}", e); 87 | errors += 1; 88 | } 89 | std::thread::sleep(std::time::Duration::from_millis(20)); 90 | } 91 | 92 | assert!(errors <= 3); 93 | } 94 | 95 | #[test] 96 | fn test_long_sleep() { 97 | #[cfg(target_os = "macos")] 98 | { 99 | // We need root permissions here to run this on OSX 100 | if unsafe { libc::geteuid() } != 0 { 101 | return; 102 | } 103 | } 104 | 105 | let mut runner = TestRunner::new(Config::default(), "./tests/scripts/longsleep.py"); 106 | 107 | let traces = runner.spy.get_stack_traces().unwrap(); 108 | assert_eq!(traces.len(), 1); 109 | let trace = &traces[0]; 110 | 111 | // Make sure the stack trace is what we expect 112 | assert_eq!(trace.frames[0].name, "longsleep"); 113 | assert_eq!( 114 | trace.frames[0].short_filename, 115 | Some("longsleep.py".to_owned()) 116 | ); 117 | assert_eq!(trace.frames[0].line, 5); 118 | 119 | assert_eq!(trace.frames[1].name, ""); 120 | assert_eq!(trace.frames[1].line, 9); 121 | assert_eq!( 122 | trace.frames[1].short_filename, 123 | Some("longsleep.py".to_owned()) 124 | ); 125 | 126 | assert!(!traces[0].owns_gil); 127 | 128 | // we should reliably be able to detect the thread is sleeping on osx/windows 129 | // linux+freebsd is trickier 130 | #[cfg(any(target_os = "macos", target_os = "windows"))] 131 | assert!(!traces[0].active); 132 | } 133 | 134 | #[test] 135 | fn test_thread_names() { 136 | #[cfg(target_os = "macos")] 137 | { 138 | // We need root permissions here to run this on OSX 139 | if unsafe { libc::geteuid() } != 0 { 140 | return; 141 | } 142 | } 143 | let config = Config { 144 | include_idle: true, 145 | ..Default::default() 146 | }; 147 | let mut runner = TestRunner::new(config, "./tests/scripts/thread_names.py"); 148 | 149 | let traces = runner.spy.get_stack_traces().unwrap(); 150 | assert_eq!(traces.len(), 11); 151 | 152 | // dictionary + thread name lookup is only supported with python 3.6+ 153 | if runner.spy.version.major == 3 && runner.spy.version.minor >= 6 { 154 | let mut expected_threads: HashSet = 155 | (0..10).map(|n| format!("CustomThreadName-{}", n)).collect(); 156 | expected_threads.insert("MainThread".to_string()); 157 | let detected_threads: HashSet = traces 158 | .iter() 159 | .map(|trace| trace.thread_name.as_ref().unwrap().clone()) 160 | .collect(); 161 | assert_eq!(expected_threads, detected_threads); 162 | } else { 163 | for trace in traces.iter() { 164 | assert!(trace.thread_name.is_none()); 165 | } 166 | } 167 | } 168 | 169 | #[test] 170 | fn test_recursive() { 171 | #[cfg(target_os = "macos")] 172 | { 173 | // We need root permissions here to run this on OSX 174 | if unsafe { libc::geteuid() } != 0 { 175 | return; 176 | } 177 | } 178 | 179 | // there used to be a problem where the top-level functions being returned 180 | // weren't actually entry points: https://github.com/benfred/py-spy/issues/56 181 | // This was fixed by locking the process while we are profiling it. Test that 182 | // the fix works by generating some samples from a program that would exhibit 183 | // this behaviour 184 | let mut runner = TestRunner::new(Config::default(), "./tests/scripts/recursive.py"); 185 | 186 | for _ in 0..100 { 187 | let traces = runner.spy.get_stack_traces().unwrap(); 188 | assert_eq!(traces.len(), 1); 189 | let trace = &traces[0]; 190 | 191 | assert!(trace.frames.len() <= 22); 192 | 193 | let top_level_frame = &trace.frames[trace.frames.len() - 1]; 194 | assert_eq!(top_level_frame.name, ""); 195 | assert!((top_level_frame.line == 8) || (top_level_frame.line == 7)); 196 | 197 | std::thread::sleep(std::time::Duration::from_millis(5)); 198 | } 199 | } 200 | 201 | #[test] 202 | fn test_unicode() { 203 | #[cfg(target_os = "macos")] 204 | { 205 | if unsafe { libc::geteuid() } != 0 { 206 | return; 207 | } 208 | } 209 | let mut runner = TestRunner::new(Config::default(), "./tests/scripts/unicode💩.py"); 210 | 211 | let traces = runner.spy.get_stack_traces().unwrap(); 212 | assert_eq!(traces.len(), 1); 213 | let trace = &traces[0]; 214 | 215 | assert_eq!(trace.frames[0].name, "function1"); 216 | assert_eq!( 217 | trace.frames[0].short_filename, 218 | Some("unicode💩.py".to_owned()) 219 | ); 220 | assert_eq!(trace.frames[0].line, 6); 221 | 222 | assert_eq!(trace.frames[1].name, ""); 223 | assert_eq!(trace.frames[1].line, 9); 224 | assert_eq!( 225 | trace.frames[1].short_filename, 226 | Some("unicode💩.py".to_owned()) 227 | ); 228 | 229 | assert!(!traces[0].owns_gil); 230 | } 231 | 232 | #[test] 233 | fn test_cyrillic() { 234 | #[cfg(target_os = "macos")] 235 | { 236 | if unsafe { libc::geteuid() } != 0 { 237 | return; 238 | } 239 | } 240 | 241 | // Identifiers with characters outside the ASCII range are supported from Python 3 242 | let runner = TestRunner::new(Config::default(), "./tests/scripts/longsleep.py"); 243 | if runner.spy.version.major == 2 { 244 | return; 245 | } 246 | 247 | let mut runner = TestRunner::new(Config::default(), "./tests/scripts/cyrillic.py"); 248 | 249 | let traces = runner.spy.get_stack_traces().unwrap(); 250 | assert_eq!(traces.len(), 1); 251 | let trace = &traces[0]; 252 | 253 | assert_eq!(trace.frames[0].name, "кириллица"); 254 | assert_eq!(trace.frames[0].line, 4); 255 | 256 | assert_eq!(trace.frames[1].name, ""); 257 | assert_eq!(trace.frames[1].line, 7); 258 | } 259 | 260 | #[test] 261 | fn test_local_vars() { 262 | #[cfg(target_os = "macos")] 263 | { 264 | // We need root permissions here to run this on OSX 265 | if unsafe { libc::geteuid() } != 0 { 266 | return; 267 | } 268 | } 269 | 270 | let config = Config { 271 | dump_locals: 1, 272 | ..Default::default() 273 | }; 274 | let mut runner = TestRunner::new(config, "./tests/scripts/local_vars.py"); 275 | 276 | let traces = runner.spy.get_stack_traces().unwrap(); 277 | assert_eq!(traces.len(), 1); 278 | let trace = &traces[0]; 279 | assert_eq!(trace.frames.len(), 2); 280 | let frame = &trace.frames[0]; 281 | let locals = frame.locals.as_ref().unwrap(); 282 | 283 | assert_eq!(locals.len(), 9); 284 | 285 | let arg1 = &locals[0]; 286 | assert_eq!(arg1.name, "arg1"); 287 | assert!(arg1.arg); 288 | assert_eq!(arg1.repr, Some("\"foo\"".to_owned())); 289 | 290 | let arg2 = &locals[1]; 291 | assert_eq!(arg2.name, "arg2"); 292 | assert!(arg2.arg); 293 | assert_eq!(arg2.repr, Some("None".to_owned())); 294 | 295 | let arg3 = &locals[2]; 296 | assert_eq!(arg3.name, "arg3"); 297 | assert!(arg3.arg); 298 | assert_eq!(arg3.repr, Some("True".to_owned())); 299 | 300 | let local1 = &locals[3]; 301 | assert_eq!(local1.name, "local1"); 302 | assert!(!local1.arg); 303 | assert_eq!(local1.repr, Some("[-1234, 5678]".to_owned())); 304 | 305 | let local2 = &locals[4]; 306 | assert_eq!(local2.name, "local2"); 307 | assert!(!local2.arg); 308 | assert_eq!(local2.repr, Some("(\"a\", \"b\", \"c\")".to_owned())); 309 | 310 | let local3 = &locals[5]; 311 | assert_eq!(local3.name, "local3"); 312 | assert!(!local3.arg); 313 | 314 | assert_eq!(local3.repr, Some("123456789123456789".to_owned())); 315 | 316 | let local4 = &locals[6]; 317 | assert_eq!(local4.name, "local4"); 318 | assert!(!local4.arg); 319 | assert_eq!(local4.repr, Some("3.1415".to_owned())); 320 | 321 | let local5 = &locals[7]; 322 | assert_eq!(local5.name, "local5"); 323 | assert!(!local5.arg); 324 | 325 | let local6 = &locals[8]; 326 | assert_eq!(local6.name, "local6"); 327 | assert!(!local6.arg); 328 | 329 | // we only support dictionary lookup on python 3.6+ right now 330 | if runner.spy.version.major == 3 && runner.spy.version.minor >= 6 { 331 | assert_eq!( 332 | local5.repr, 333 | Some("{\"a\": False, \"b\": (1, 2, 3)}".to_owned()) 334 | ); 335 | } 336 | } 337 | 338 | #[cfg(not(target_os = "freebsd"))] 339 | #[test] 340 | fn test_subprocesses() { 341 | #[cfg(target_os = "macos")] 342 | { 343 | // We need root permissions here to run this on OSX 344 | if unsafe { libc::geteuid() } != 0 { 345 | return; 346 | } 347 | } 348 | 349 | // We used to not be able to create a sampler object if one of the child processes 350 | // was in a zombie state. Verify that this works now 351 | let process = ScriptRunner::new("python", "./tests/scripts/subprocesses.py"); 352 | std::thread::sleep(std::time::Duration::from_millis(1000)); 353 | let config = Config { 354 | subprocesses: true, 355 | ..Default::default() 356 | }; 357 | let sampler = py_spy::sampler::Sampler::new(process.id(), &config).unwrap(); 358 | std::thread::sleep(std::time::Duration::from_millis(1000)); 359 | 360 | // Get samples from all the subprocesses, verify that we got from all 3 processes 361 | let mut attempts = 0; 362 | 363 | for sample in sampler { 364 | // wait for other processes here if we don't have the expected number 365 | let traces = sample.traces; 366 | if traces.len() != 3 && attempts < 4 { 367 | attempts += 1; 368 | std::thread::sleep(std::time::Duration::from_millis(1000)); 369 | continue; 370 | } 371 | assert_eq!(traces.len(), 3); 372 | assert!(traces[0].pid != traces[1].pid); 373 | assert!(traces[1].pid != traces[2].pid); 374 | break; 375 | } 376 | } 377 | 378 | #[cfg(not(target_os = "freebsd"))] 379 | #[test] 380 | fn test_subprocesses_zombiechild() { 381 | #[cfg(target_os = "macos")] 382 | { 383 | // We need root permissions here to run this on OSX 384 | if unsafe { libc::geteuid() } != 0 { 385 | return; 386 | } 387 | } 388 | 389 | // We used to not be able to create a sampler object if one of the child processes 390 | // was in a zombie state. Verify that this works now 391 | let process = ScriptRunner::new("python", "./tests/scripts/subprocesses_zombie_child.py"); 392 | std::thread::sleep(std::time::Duration::from_millis(200)); 393 | let config = Config { 394 | subprocesses: true, 395 | ..Default::default() 396 | }; 397 | let _sampler = py_spy::sampler::Sampler::new(process.id(), &config).unwrap(); 398 | } 399 | 400 | #[test] 401 | fn test_negative_linenumber_increment() { 402 | #[cfg(target_os = "macos")] 403 | { 404 | // We need root permissions here to run this on OSX 405 | if unsafe { libc::geteuid() } != 0 { 406 | return; 407 | } 408 | } 409 | let mut runner = TestRunner::new( 410 | Config::default(), 411 | "./tests/scripts/negative_linenumber_offsets.py", 412 | ); 413 | 414 | let traces = runner.spy.get_stack_traces().unwrap(); 415 | assert_eq!(traces.len(), 1); 416 | let trace = &traces[0]; 417 | 418 | match runner.spy.version.major { 419 | 3 => { 420 | assert_eq!(trace.frames[0].name, ""); 421 | assert!(trace.frames[0].line >= 5 && trace.frames[0].line <= 10); 422 | assert_eq!(trace.frames[1].name, "f"); 423 | assert!(trace.frames[1].line >= 5 && trace.frames[0].line <= 10); 424 | assert_eq!(trace.frames[2].name, ""); 425 | assert_eq!(trace.frames[2].line, 13) 426 | } 427 | 2 => { 428 | assert_eq!(trace.frames[0].name, "f"); 429 | assert!(trace.frames[0].line >= 5 && trace.frames[0].line <= 10); 430 | assert_eq!(trace.frames[1].name, ""); 431 | assert_eq!(trace.frames[1].line, 13); 432 | } 433 | _ => panic!("Unknown python major version"), 434 | } 435 | } 436 | 437 | #[cfg(target_os = "linux")] 438 | #[test] 439 | fn test_delayed_subprocess() { 440 | let process = ScriptRunner::new("bash", "./tests/scripts/delayed_launch.sh"); 441 | let config = Config { 442 | subprocesses: true, 443 | ..Default::default() 444 | }; 445 | let sampler = py_spy::sampler::Sampler::new(process.id(), &config).unwrap(); 446 | for sample in sampler { 447 | // should have one trace from the subprocess 448 | let traces = sample.traces; 449 | assert_eq!(traces.len(), 1); 450 | assert!(traces[0].pid != process.id()); 451 | break; 452 | } 453 | } 454 | -------------------------------------------------------------------------------- /tests/scripts/busyloop.py: -------------------------------------------------------------------------------- 1 | def busy_loop(): 2 | while True: 3 | pass 4 | 5 | 6 | if __name__ == "__main__": 7 | busy_loop() 8 | -------------------------------------------------------------------------------- /tests/scripts/cyrillic.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | def кириллица(seconds): 4 | time.sleep(seconds) 5 | 6 | if __name__ == "__main__": 7 | кириллица(100) 8 | -------------------------------------------------------------------------------- /tests/scripts/delayed_launch.sh: -------------------------------------------------------------------------------- 1 | sleep 0.5 2 | python -c "import time; time.sleep(1000)" 3 | -------------------------------------------------------------------------------- /tests/scripts/local_vars.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | 4 | def local_variable_lookup(arg1="foo", arg2=None, arg3=True): 5 | local1 = [-1234, 5678] 6 | local2 = ("a", "b", "c") 7 | local3 = 123456789123456789 8 | local4 = 3.1415 9 | local5 = {"a": False, "b": (1, 2, 3)} 10 | # https://github.com/benfred/py-spy/issues/224 11 | local6 = ("-" * 115, {"key": {"key": {"key": "value"}}}) 12 | time.sleep(100000) 13 | 14 | 15 | if __name__ == "__main__": 16 | local_variable_lookup() 17 | -------------------------------------------------------------------------------- /tests/scripts/longsleep.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | 4 | def longsleep(): 5 | time.sleep(100000) 6 | 7 | 8 | if __name__ == "__main__": 9 | longsleep() 10 | -------------------------------------------------------------------------------- /tests/scripts/negative_linenumber_offsets.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | 4 | def f(): 5 | [ 6 | # Must be split over multiple lines to see the error. 7 | # https://github.com/benfred/py-spy/pull/208 8 | time.sleep(1) 9 | for _ in range(1000) 10 | ] 11 | 12 | 13 | f() 14 | -------------------------------------------------------------------------------- /tests/scripts/recursive.py: -------------------------------------------------------------------------------- 1 | def recurse(x): 2 | if x == 0: 3 | return 4 | recurse(x-1) 5 | 6 | 7 | while True: 8 | recurse(20) 9 | -------------------------------------------------------------------------------- /tests/scripts/subprocesses.py: -------------------------------------------------------------------------------- 1 | import time 2 | import multiprocessing 3 | 4 | def target(): 5 | multiprocessing.freeze_support() 6 | time.sleep(1000) 7 | 8 | def main(): 9 | child1 = multiprocessing.Process(target=target) 10 | child1.start() 11 | child2 = multiprocessing.Process(target=target) 12 | child2.start() 13 | time.sleep(10000) 14 | child1.join() 15 | child2.join() 16 | 17 | if __name__ == "__main__": 18 | multiprocessing.freeze_support() 19 | main() -------------------------------------------------------------------------------- /tests/scripts/subprocesses_zombie_child.py: -------------------------------------------------------------------------------- 1 | import time 2 | import multiprocessing 3 | 4 | def target(): 5 | pass 6 | 7 | if __name__ == "__main__": 8 | multiprocessing.freeze_support() 9 | child = multiprocessing.Process(target=target) 10 | child.start() 11 | time.sleep(10000) 12 | child.join() 13 | -------------------------------------------------------------------------------- /tests/scripts/thread_names.py: -------------------------------------------------------------------------------- 1 | import time 2 | import threading 3 | 4 | 5 | def main(): 6 | for i in range(10): 7 | th = threading.Thread(target = lambda: time.sleep(10000)) 8 | th.name = "CustomThreadName-%s" % i 9 | th.start() 10 | time.sleep(10000) 11 | 12 | if __name__ == "__main__": 13 | main() 14 | -------------------------------------------------------------------------------- /tests/scripts/thread_reuse.py: -------------------------------------------------------------------------------- 1 | import time 2 | import threading 3 | 4 | while True: 5 | th = threading.Thread(target = lambda: time.sleep(.5)) 6 | th.start() 7 | th.join() 8 | -------------------------------------------------------------------------------- /tests/scripts/unicode💩.py: -------------------------------------------------------------------------------- 1 | #!/env/bin/python 2 | # -*- coding: utf-8 -*- 3 | import time 4 | 5 | def function1(seconds): 6 | time.sleep(seconds) 7 | 8 | if __name__ == "__main__": 9 | function1(100) 10 | --------------------------------------------------------------------------------