diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f633178 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,83 @@ +name: CI + +on: + push: + pull_request: + +jobs: + # Modern source: run the unit suite on the supported development range. + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.1', '8.2', '8.3', '8.4'] + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + - run: composer install --no-interaction --no-progress + - run: vendor/bin/phpunit + + # Build the PHP 7.0 downgrade artifact (Rector needs PHP 8.2+ to run) and + # assert no 7.1+ syntax survives. Upload it for the legacy smoke jobs. + downgrade: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + - run: composer install --no-interaction --no-progress + - run: composer downgrade + - name: Assert no PHP 7.1+ syntax remains in the build + run: | + set -e + fail=0 + # Nullable types, void return types, and class-const visibility are + # the 7.1 features Rector's level set can't strip; our custom rules + # must have removed them. (Grep code lines, not docblocks.) + if grep -rnE '(function[^;{]*\)\s*:\s*\??void|\)\s*:\s*\?[A-Za-z_\\]|\([^)]*\?[A-Za-z_\\][^)]*\)\s*[:{])' build/php70/src; then + echo "found nullable/void return syntax"; fail=1 + fi + if grep -rnE '^\s*(public|protected|private)\s+const\b' build/php70/src; then + echo "found const visibility"; fail=1 + fi + if grep -rnE '\breadonly\s+\$|\breadonly\s+[A-Za-z_\\]+\s+\$' build/php70/src; then + echo "found readonly property"; fail=1 + fi + if grep -rnE '\b0o[0-7]+' build/php70/src; then + echo "found 0o octal literal"; fail=1 + fi + test "$fail" = 0 + - name: Lint the downgraded build + run: find build/php70/src -name '*.php' -print0 | xargs -0 -n1 php -l + - uses: actions/upload-artifact@v4 + with: + name: php70-build + path: build/php70/ + + # Run the downgraded build on legacy PHP, where the released package will + # actually live. No Rector/PHPUnit here — just load the 7.0 tree and dump. + smoke-legacy: + needs: downgrade + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.0', '7.1', '7.2', '7.4', '8.0'] + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + - uses: actions/download-artifact@v4 + with: + name: php70-build + path: build/php70/ + - run: find build/php70/src -name '*.php' -print0 | xargs -0 -n1 php -l + - run: php tools/smoke.php build/php70/src diff --git a/.github/workflows/reli-cross-version.yml b/.github/workflows/reli-cross-version.yml new file mode 100644 index 0000000..9354efc --- /dev/null +++ b/.github/workflows/reli-cross-version.yml @@ -0,0 +1,114 @@ +name: reli-cross-version + +# Cross-version integration, the pure-PHP analogue of ext-rdump's same-named +# workflow: a dump produced by this library on *any* supported PHP (7.0 .. 8.5, +# NTS) must be analysable by current reli (which itself needs PHP 8.4+). +# +# stage 1 (build) : downgrade the modern source to PHP 7.0 once on 8.4 +# stage 2 (make-dumps) : load that build on each PHP image and write a dump +# stage 3 (analyze) : set reli up once and analyse every dump +# stage 4 (zts) : assert the NTS-only contract fails cleanly on ZTS +on: + push: + pull_request: + workflow_dispatch: + +concurrency: + group: reli-xver-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Downgrade to PHP 7.0 (on 8.4) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + - run: composer install --no-interaction --no-progress + - run: composer downgrade + - uses: actions/upload-artifact@v4 + with: + name: php70-build + path: build/php70/ + retention-days: 1 + + make-dumps: + name: Dump on PHP ${{ matrix.php }} (NTS) + needs: build + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.0', '7.1', '7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: php70-build + path: build/php70/ + - name: Write a dump from the downgraded build (php:${{ matrix.php }}-cli) + run: | + mkdir -p out + docker run --rm \ + -e OUT_DUMP=/out/php${{ matrix.php }}-nts.rdump \ + -e PMD_SRC=/pmd/build/php70/src \ + -v "$PWD":/pmd -v "$PWD/out":/out -w /pmd \ + "php:${{ matrix.php }}-cli" \ + sh ci/reli-make-dump.sh + # Born 0600 by root in the container; relax so the runner can upload. + sudo chmod 0644 out/*.rdump + - uses: actions/upload-artifact@v4 + with: + name: dump-${{ matrix.php }} + path: out/*.rdump + retention-days: 1 + + analyze: + name: reli (PHP 8.4) analyses every dump + needs: make-dumps + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + path: pmd + - uses: actions/checkout@v4 + with: + repository: reliforp/reli-prof + path: reli + - uses: actions/download-artifact@v4 + with: + path: dumps + merge-multiple: true + - name: Set up reli once, then analyse every cross-version dump + run: | + ls -l dumps + docker run --rm \ + -e IN_DIR=/in \ + -v "$PWD/pmd":/pmd -v "$PWD/reli":/reli -v "$PWD/dumps":/in -w /reli \ + php:8.4-cli \ + sh /pmd/ci/reli-analyze.sh + + zts-rejection: + name: ZTS rejected cleanly (PHP ${{ matrix.php }}) + needs: build + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.4'] + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: php70-build + path: build/php70/ + - name: Assert dump() fails fast on a ZTS build (php:${{ matrix.php }}-zts) + run: | + docker run --rm \ + -e PMD_SRC=/pmd/build/php70/src \ + -v "$PWD":/pmd -w /pmd \ + "php:${{ matrix.php }}-zts" \ + sh ci/zts-rejection.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28e3423 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/vendor/ +/build/ +composer.lock +*.rdump +*.rmem +.phpunit.result.cache +.phpunit.cache/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c9859dd --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) sji + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 1d2d5b3..b1b684f 100644 --- a/README.md +++ b/README.md @@ -1 +1,276 @@ -# php-memory-dump \ No newline at end of file +# php-memory-dump + +Take a memory dump of the **current PHP process**, from inside the process, +in **pure PHP** — no extension, no FFI — for later analysis with +[reli](https://github.com/reliforp/reli-prof). + +```php +use Reliforp\PhpMemoryDump\MemoryDumper; + +(new MemoryDumper())->dump('/tmp/app.rdump'); +``` + +This is the pure-PHP sibling of [ext-rdump](https://github.com/reliforp/ext-rdump). +ext-rdump is a C extension that takes the dump with `memcpy`; this package +produces the **same RDUMP file** (magic `RDUMP`, format version 3) using only +`/proc/self/maps`, `/proc/self/mem`, and the PHP binary's own ELF symbol table. +The output feeds the very same reli workflow: + +```bash +reli inspector:memory:analyze app.rdump -f rmem -o app.rmem +reli inspector:memory:report app.rmem +``` + +## Why this works (and how) + +reli normally snapshots a PHP process from the **outside** via +`process_vm_readv` / `ptrace`. To do that it locates the engine globals +(`executor_globals`, `compiler_globals`, `basic_globals`) by reading the PHP +binary's symbol table and adding the load bias from `/proc//maps`, then +reads the relevant regions out of the target's address space. + +Everything reli does from the outside, a process can do to **itself** from the +inside, in plain PHP: + +1. **Resolve the engine globals.** Read `/proc/self/maps` to find the PHP + binary's load base, parse its ELF `.symtab` / `.dynsym` to get each + symbol's link-time address, and add the bias. (Debian/Ubuntu strip the PHP + binary, but the three engine globals are *exported* in `.dynsym`, so a + stock distro build is still resolvable.) +2. **Read the regions.** `/proc/self/mem` exposes the whole address space as a + seekable file: `fseek()` to a virtual address, `fread()` the bytes. On a + 64-bit build PHP's `fseek` takes a 64-bit offset, so the entire user + address range is reachable. +3. **Write the RDUMP file.** The header records the resolved addresses, the + memory map mirrors `/proc/self/maps`, and each captured region is streamed + straight from `/proc/self/mem` to disk. + +reli then does all the hard work — walking the Zend heap, the object store, +class/function tables, the VM stack — **offline**, from the dump file. + +## Status: it works + +On a stock Debian PHP 8.4 (NTS, x86-64) a self-dump analysed by reli +reconstructs the live state, including the **call stack at the moment of +capture** (which, fittingly, shows this library's own dump path): + +``` +Call Stack at capture: + #0 fread:-1 + #1 Reliforp\PhpMemoryDump\RegionReader::readAt:90 + #2 Reliforp\PhpMemoryDump\RegionReader::copyInto:75 + #3 Reliforp\PhpMemoryDump\RdumpWriter::writeRegion:129 + #4 Reliforp\PhpMemoryDump\MemoryDumper::dump:98 + #5
:23 +``` + +…and the full type breakdown / object inventory / leak findings reli produces +for any other dump. + +### Notes on the two worries you'd expect + +- **Does PHP's `fread` cope with procfs?** For `/proc/self/mem`, yes: + `fseek` to an absolute address (low heap *and* high stack addresses like + `0x7fff…`) followed by `fread` returns the bytes. We read unbuffered and + seek before every read. (Reading *another* process's `/proc//mem` from + PHP streams is a different story — that's why reli uses FFI/`pread` there — + but `self` is well-behaved.) +- **Doesn't the dumper perturb the VM while it runs?** It does — this is PHP + code mutating the heap as it dumps it, so the snapshot is not + stop-the-world. We keep the window small: the memory map is frozen *before* + any output buffer is allocated, and regions are streamed rather than + accumulated. The captured heap therefore contains the dumper's own + transient objects (you'll see `MapEntry` instances and the like in the + report), but the dump is still internally consistent enough for reli to + walk. This mirrors the inconsistency ext-rdump documents for busy ZTS + processes. + +### Memory footprint of the dump itself + +The dumper is deliberately frugal so it barely disturbs the heap it is +snapshotting. It does **not** slurp the multi-megabyte PHP binary to parse its +symbols — only the ELF/program/section headers and the `.dynsym` / `.dynstr` +slices are read — and regions are streamed through a small fixed buffer +(256 KiB) rather than buffered whole. On a stock PHP 8.4 the measured peak +*added* by `dump()` is on the order of **~0.6–1 MiB of `emalloc`**, with **no +new 2 MiB ZendMM chunk forced** (`memory_get_peak_usage(true)` delta 0) — and +it is independent of how big the dumped heap or the output file is (a 26 MB +dump costs the same as a tiny one). Permanent growth left behind is a couple +of hundred KiB at most. + +## Install + +```bash +composer require reliforp/php-memory-dump +``` + +## Usage + +```php +use Reliforp\PhpMemoryDump\MemoryDumper; + +$result = (new MemoryDumper())->dump('/tmp/app.rdump'); +// $result->region_count, $result->total_bytes, $result->memory_area_count + +// Self-contained dump (also embeds read-only file-backed segments, so it can +// be analysed on a host without the original binaries). Larger output. +(new MemoryDumper())->dump('/tmp/app-full.rdump', full: true); +``` + +### From a signal handler + +```php +pcntl_async_signals(true); +pcntl_signal(SIGUSR2, function () { + (new \Reliforp\PhpMemoryDump\MemoryDumper())->dump('/tmp/sig.rdump'); +}); +// kill -USR2 +``` + +### Auto-dump on `memory_limit` death + +`OomDumpHandler::register()` installs a shutdown handler that dumps the +process the moment it dies of `memory_limit` exhaustion — the analogue of +reli's sidecar client `MemoryLimitHandler::register()`, but doing the dump +in-process instead of asking a daemon. + +```php +use Reliforp\PhpMemoryDump\OomDumpHandler; + +// %p -> pid, %t -> unix time, %% -> literal % (so workers don't collide) +OomDumpHandler::register('/var/log/php-oom-%p-%t.rdump'); +``` + +With options: + +```php +OomDumpHandler::register( + path: '/var/log/php-oom-%p.rdump', + full: false, + on_dump: fn ($result, $path) => error_log("OOM dump: $path"), + on_error: fn (\Throwable $e) => error_log('OOM dump failed: ' . $e->getMessage()), + reserve_bytes: 4 * 1024 * 1024, // emergency reserve (must be >= 2 MiB) + memory_limit: -1, // raised inside the handler before dumping +); +``` + +Surviving the OOM that you are trying to capture takes some care, because +the handler runs *in-process* with the heap already at the wall. Two levers, +both on by default: + +- **Raising `memory_limit` (the reliable one).** `memory_limit` is enforced + on memory actually taken from the OS (ZendMM's *real* usage). The handler + lifts it (to unlimited by default — the process is exiting anyway) before + dumping, which deterministically gives the dumper the ~one 2 MiB ZendMM + chunk of working set it needs. Pass `memory_limit: false` to opt out. +- **The emergency reserve — and why it must be ≥ 2 MiB.** A pre-allocated + block is freed first thing in the handler. But freeing a **sub-2 MiB** + block only returns it to a ZendMM chunk's free list; the chunk is **not** + unmapped, so *real* usage — and thus `memory_limit` headroom — does not + change. Measured: a **1 MiB reserve frees zero headroom**, the dump then + re-OOMs mid-write, and you get a **truncated, unusable** file (reli refuses + it). 2 MiB is the practical minimum (it is a *huge* allocation, unmapped on + free); the default 4 MiB leaves margin. The reserve is mainly the fallback + for when `ini_set('memory_limit', …)` is restricted. + +> **Best-effort, by nature.** A shutdown handler cannot catch *every* OOM: +> when the exhausting allocation is itself a VM-stack page push, pushing the +> handler's own call frame needs another allocation that also fails, so the +> handler body never runs — no reserve or `memory_limit` bump can help, +> because nothing executes. Catching those needs ext-rdump's C +> `zend_error_cb` hook. In practice this handler covers the common +> "accumulated too much, then one more append tipped it over" OOM, and the +> resulting dump pinpoints the offender — e.g. reli's report flags the very +> array that overflowed the limit as the dominant retained branch. + +## Requirements & limitations + +- **64-bit little-endian Linux**, PHP **7.0+**, built **NTS**. (The released + package is downgraded to 7.0 syntax — see *PHP version support* below — so it + matches ext-rdump's 7.0–8.5 reach; develop against PHP 8.1+.) +- **NTS only.** ZTS keeps the engine globals in thread-local storage reached + through TSRM; resolving those without walking the TLS block is out of reach + for a pure-PHP reader, so ZTS is rejected with a clear error. Use ext-rdump + (or reli attaching from the outside) for ZTS. +- The PHP binary must expose `executor_globals` / `compiler_globals` / + `basic_globals` in `.symtab` or `.dynsym`. Stock distro builds do (they're + exported); a binary stripped of *both* tables is not resolvable. +- Reads `/proc/self/mem`, which a restrictive sandbox/seccomp profile or a + Yama `ptrace_scope` policy targeting the self-read path may block. +- The dump is taken synchronously in the calling code; it is not + stop-the-world (see above). + +### PHP version support (and the downgrade build) + +The library is **developed in modern PHP (8.1+)** — readonly properties, +constructor promotion, `match`, named arguments — but **released downgraded to +PHP 7.0 syntax**, so it installs and runs anywhere from PHP 7.0 to 8.5, the same +floor as ext-rdump. + +This mirrors reli's own sidecar client (`reliforp/reli-prof-sidecar-client`), +which ships the same way. The downgrade is `composer downgrade`: + +1. [Rector](https://github.com/rectorphp/rector)'s downgrade level set rewrites + 8.x → 7.1 (`withDowngradeSets(php71: true)`). +2. Three small custom rules under `tools/rector/` fill the 7.1 → 7.0 gap that + Rector no longer ships: strip nullable type hints (`?T`), `void` return + types, and class-constant visibility. (Lifted from reli's + `tools/rector/Downgrade*ToPhp70Rector`.) +3. A `sed` pass rewrites `0o…` octal literals. + +The result lands in `build/php70/src/`. CI builds it on PHP 8.4, asserts no +7.1+ syntax survives, and runs a smoke dump against the downgraded tree on PHP +7.0 / 7.1 / 7.2 / 7.4 / 8.0 — the runtimes where the published artifact lives +but Rector and PHPUnit can't. The git source stays modern; only the release +artifact is downgraded. + +### Cross-version round-trip against reli + +A separate workflow (`reli-cross-version.yml`, the pure-PHP analogue of +ext-rdump's same-named CI) proves the full producer→analyzer contract: it loads +the downgraded build on every `php:7.0-cli … php:8.5-cli` image, writes a +self-contained (`full: true`) dump, then sets reli up once on PHP 8.4 and +analyses every dump — asserting the RDUMP magic, format version 3, the recorded +origin PHP-version tag, and that `inspector:memory:analyze` produces a report. +The analyzer step reuses ext-rdump's `reli-analyze.sh` almost verbatim, since the +RDUMP format is identical regardless of which producer wrote it. A ZTS leg +asserts the NTS-only contract fails fast (no bogus dump) on `php:*-zts`. + +## What gets captured + +Mirroring ext-rdump: every VMA appears in the dump's memory map, and the bytes +of every **readable, volatile** mapping are embedded — writable segments +(ZendMM chunks, the brk heap, VM stacks, library `.data`/`.bss` where NTS +globals live), anonymous mappings, and opcache `/dev/zero` shared memory. With +`full: true`, read-only file-backed segments are embedded too. Read-only +binary segments left out of a non-full dump are recovered from the on-disk +binaries by reli at analysis time (use `full: true`, or reli's +`--dependency-root`, to analyse on a different host). + +## Security + +**A dump is a verbatim copy of your process's memory, so treat the file as +highly sensitive** — it can contain environment variables, credentials, +session tokens, decrypted request/response bodies, and private keys. The file +is created `0600`; still, write it somewhere only the intended user can read, +move it over a secure channel, and delete it once analysed. See ext-rdump's +README for the longer discussion; the same cautions apply. + +## Relationship to ext-rdump + +| | ext-rdump | php-memory-dump | +|---|---|---| +| Form | C extension | pure PHP library | +| Globals resolution | linker (compile-time) | ELF symbol table + maps load bias | +| Region copy | `memcpy` / `/proc/self/mem` | `/proc/self/mem` | +| OOM auto-dump (`zend_error_cb`) | yes | no (no hook available in pure PHP) | +| ZTS | yes | no | +| Output | RDUMP v3 | RDUMP v3 (same reader) | + +If you can install an extension, ext-rdump is faster, works under ZTS, and can +auto-dump on `memory_limit` exhaustion. This package is for when you **can't** +add an extension but can still run PHP. + +## License + +MIT. diff --git a/ci/reli-analyze.sh b/ci/reli-analyze.sh new file mode 100755 index 0000000..40566bc --- /dev/null +++ b/ci/reli-analyze.sh @@ -0,0 +1,74 @@ +#!/bin/sh +# Install reli (needs PHP 8.4+) once, then analyse every dump in $IN_DIR. The +# dumps were produced by the pure-PHP dumper on older PHP versions; this proves +# current reli can inspect/analyze cross-version RDUMP files. Setting reli up +# (FFI build + composer) is the fixed cost, so it's paid once and amortised over +# all dumps rather than per dump. +# +# Adapted from ext-rdump's ci/reli-analyze.sh — the RDUMP format is the same, so +# the analyzer side is identical regardless of which producer wrote the dump. +# +# docker run --rm -e IN_DIR=/in -v "$PWD/reli":/reli -v "$PWD/out":/in \ +# -w /reli php:8.4-cli sh /pmd/ci/reli-analyze.sh +set -eu + +: "${IN_DIR:?set IN_DIR to the directory of dumps to analyse}" + +apt-get update +apt-get install -y --no-install-recommends git unzip libffi-dev +docker-php-ext-install -j"$(nproc)" ffi pcntl + +php -r "copy('https://getcomposer.org/installer', '/tmp/composer-setup.php');" +php /tmp/composer-setup.php --install-dir=/usr/local/bin --filename=composer +rm -f /tmp/composer-setup.php + +composer install --no-interaction --no-progress + +analyze_one() { + dump="$1" + # Derive the expected origin tag from the filename (php7.0-nts.rdump -> v70). + tag="v$(basename "$dump" | sed -E 's/^php([0-9])\.([0-9]).*/\1\2/')" + echo "::group::analyze $(basename "$dump") (origin $tag)" + test -s "$dump" || { echo "::error::missing/empty dump $dump"; return 1; } + + # The dump records its origin PHP version in the header, so no + # --php-version override is needed for a file dump. + inspect="$(php -d ffi.enable=1 reli inspector:memory:dump:inspect "$dump")" + echo "$inspect" | head -20 + echo "$inspect" | grep -q 'Magic:.*RDUMP' || { echo "::error::$dump: no RDUMP magic"; return 1; } + echo "$inspect" | grep -q 'Format Version:.*3' || { echo "::error::$dump: unexpected format version"; return 1; } + echo "$inspect" | grep -qi "PHP Version:.*$tag" || { echo "::error::$dump: origin not $tag"; return 1; } + + if php -d ffi.enable=1 reli inspector:memory:analyze "$dump" -f report \ + >/tmp/reli-analyze.log 2>&1; then + head -20 /tmp/reli-analyze.log + else + echo "::error::$dump: reli analyze exited non-zero; full output:" + cat /tmp/reli-analyze.log + return 1 + fi + grep -q 'Memory Analysis Report' /tmp/reli-analyze.log || { + echo "::error::$dump: no report produced; full output:" + cat /tmp/reli-analyze.log + return 1 + } + echo "OK: $(basename "$dump")" + echo "::endgroup::" +} + +rc=0 +n=0 +for d in "$IN_DIR"/*.rdump; do + n=$((n + 1)) + analyze_one "$d" || rc=1 +done + +if [ "$n" -eq 0 ]; then + echo "::error::no dumps found in $IN_DIR" + exit 1 +fi +if [ "$rc" -ne 0 ]; then + echo "::error::one or more cross-version dumps failed to analyse" + exit 1 +fi +echo "reli cross-version analyze OK ($n dumps)" diff --git a/ci/reli-make-dump.sh b/ci/reli-make-dump.sh new file mode 100755 index 0000000..b594f3a --- /dev/null +++ b/ci/reli-make-dump.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Write a self-contained dump from the *downgraded* pure-PHP library running on +# the current (possibly old, 7.x) PHP image. Paired with ci/reli-analyze.sh, +# which runs in a PHP 8.4 image (reli needs 8.4+) and reads the dump: this +# proves a dump produced on an old PHP by the pure-PHP dumper is analysable by +# current reli — the same cross-version guarantee ext-rdump provides, without a +# compiled extension. +# +# docker run --rm -e OUT_DUMP=/out/php7.0-nts.rdump \ +# -e PMD_SRC=/pmd/build/php70/src \ +# -v "$PWD":/pmd -v "$PWD/out":/out -w /pmd php:7.0-cli \ +# sh ci/reli-make-dump.sh +# +# No phpize/configure/make: the library is plain PHP. PMD_SRC must point at the +# Rector-downgraded build/php70/src tree (produced once on PHP 8.4), since the +# modern source under src/ doesn't parse on 7.x. +set -eux + +: "${OUT_DUMP:?set OUT_DUMP to the output path}" +: "${PMD_SRC:=/pmd/build/php70/src}" + +php -v +test -d "$PMD_SRC" || { echo "downgraded src not found at $PMD_SRC" >&2; exit 1; } + +rm -f "$OUT_DUMP" +# full=true so the dump embeds the read-only segments of *this* old PHP build, +# letting reli on another host/version analyse it without the original binaries. +OUT_DUMP="$OUT_DUMP" PMD_SRC="$PMD_SRC" php -r ' + $src = getenv("PMD_SRC"); + spl_autoload_register(function ($class) use ($src) { + $prefix = "Reliforp\\PhpMemoryDump\\"; + if (strpos($class, $prefix) !== 0) { return; } + $file = $src . "/" . str_replace("\\", "/", substr($class, strlen($prefix))) . ".php"; + if (is_file($file)) { require $file; } + }); + $marker = array_fill(0, 1000, str_repeat("reli-pmd-xver", 32)); + $GLOBALS["__reli_pmd_marker"] = $marker; + $result = (new Reliforp\PhpMemoryDump\MemoryDumper())->dump(getenv("OUT_DUMP"), true); + if ($result->region_count < 1) { + fwrite(STDERR, "dump produced no regions\n"); + exit(1); + } +' +test -s "$OUT_DUMP" +echo "wrote $(wc -c < "$OUT_DUMP") bytes to $OUT_DUMP" diff --git a/ci/zts-rejection.sh b/ci/zts-rejection.sh new file mode 100755 index 0000000..9a2a848 --- /dev/null +++ b/ci/zts-rejection.sh @@ -0,0 +1,37 @@ +#!/bin/sh +# The pure-PHP dumper is NTS-only (ZTS keeps the engine globals in thread-local +# storage, out of a pure-PHP reader's reach). On a ZTS build it must fail fast +# with a clear error rather than write a bogus dump. This asserts that contract +# across ZTS images — the ZTS analogue of the make-dump matrix. +# +# docker run --rm -e PMD_SRC=/pmd/build/php70/src \ +# -v "$PWD":/pmd -w /pmd php:8.4-zts sh ci/zts-rejection.sh +set -eux + +: "${PMD_SRC:=/pmd/build/php70/src}" + +php -r 'echo PHP_ZTS ? "ZTS build confirmed\n" : "ERROR: not a ZTS build\n";' +test "$(php -r 'echo PHP_ZTS;')" = "1" || { echo "image is not ZTS" >&2; exit 1; } + +PMD_SRC="$PMD_SRC" php -r ' + $src = getenv("PMD_SRC"); + spl_autoload_register(function ($class) use ($src) { + $prefix = "Reliforp\\PhpMemoryDump\\"; + if (strpos($class, $prefix) !== 0) { return; } + $file = $src . "/" . str_replace("\\", "/", substr($class, strlen($prefix))) . ".php"; + if (is_file($file)) { require $file; } + }); + try { + (new Reliforp\PhpMemoryDump\MemoryDumper())->dump("/tmp/should-not-exist.rdump"); + fwrite(STDERR, "FAIL: dump() unexpectedly succeeded on a ZTS build\n"); + exit(1); + } catch (Reliforp\PhpMemoryDump\MemoryDumpException $e) { + if (strpos($e->getMessage(), "NTS") === false) { + fwrite(STDERR, "FAIL: wrong error: " . $e->getMessage() . "\n"); + exit(1); + } + fwrite(STDOUT, "OK: ZTS rejected cleanly: " . $e->getMessage() . "\n"); + } +' +test ! -e /tmp/should-not-exist.rdump || { echo "FAIL: a dump file was written on ZTS" >&2; exit 1; } +echo "ZTS rejection OK" diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..8eaf53c --- /dev/null +++ b/composer.json @@ -0,0 +1,44 @@ +{ + "name": "reliforp/php-memory-dump", + "description": "Capture a PHP process's own memory in reli's RDUMP format from inside the process, in pure PHP, with no extension and no FFI. The dump is analysed offline with reli (finding leaks, reference cycles, memory bottlenecks, etc.).", + "type": "library", + "license": "MIT", + "keywords": ["reli", "memory", "dump", "profiler", "debugging", "rdump"], + "homepage": "https://github.com/reliforp/php-memory-dump", + "authors": [ + { + "name": "sji", + "homepage": "https://twitter.com/sji_ch" + } + ], + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^10.5 || ^11.0", + "rector/rector": "^2.0" + }, + "autoload": { + "psr-4": { + "Reliforp\\PhpMemoryDump\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Reliforp\\PhpMemoryDump\\Tests\\": "tests/", + "Reliforp\\PhpMemoryDump\\Build\\Rector\\": "tools/rector/" + } + }, + "scripts": { + "downgrade": [ + "rm -rf build/php70", + "mkdir -p build/php70", + "cp -r src build/php70/src", + "vendor/bin/rector process build/php70/src --config=rector.php --no-diffs", + "find build/php70/src -name '*.php' -exec sed -i -E 's/\\b0o([0-7]+)\\b/0\\1/g' {} +" + ] + }, + "scripts-descriptions": { + "downgrade": "Produce a PHP 7.0-compatible copy of src/ under build/php70/ via Rector downgrade + a 0o-octal sed pass (release artifact)." + } +} diff --git a/examples/selfdump.php b/examples/selfdump.php new file mode 100644 index 0000000..07bd6e9 --- /dev/null +++ b/examples/selfdump.php @@ -0,0 +1,29 @@ + $i, 'tag' => 'leaky-' . $i]); +} +$GLOBALS['__retained'] = [$marker, $leak]; + +$path = $argv[1] ?? '/tmp/selfdump.rdump'; +$result = (new MemoryDumper())->dump($path); + +printf("wrote %s\n", $result->output_path); +printf(" memory areas : %d\n", $result->memory_area_count); +printf(" regions : %d\n", $result->region_count); +printf(" region bytes : %d (%.1f MiB)\n", $result->total_bytes, $result->total_bytes / 1048576); +printf(" file size : %d\n", filesize($path)); diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..5a9a592 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,18 @@ + + + + + tests + + + + + src + + + diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..bdc01f4 --- /dev/null +++ b/rector.php @@ -0,0 +1,45 @@ + 7.1 via + * withDowngradeSets(php71: true): readonly, promoted properties, match, + * union types, str_contains/str_starts_with, arrow functions, typed + * properties, etc. + * 2. The 7.1 -> 7.0 gap (Rector ships no rules for it) is filled by our + * own rules under tools/rector/Downgrade*ToPhp70Rector: + * - nullable type hints (?T) : params get `= null`, returns dropped + * - void return type : dropped + * - class const visibility : private/protected/public stripped + * 3. The 0o octal literal syntax (PHP 8.1+) is rewritten by a sed + * pass in the composer "downgrade" script after Rector finishes, since + * Rector's pretty-printer preserves the original token for unmodified + * number nodes. + */ + +declare(strict_types=1); + +use Rector\Config\RectorConfig; +use Rector\ValueObject\PhpVersion; +use Reliforp\PhpMemoryDump\Build\Rector\DowngradeConstVisibilityToPhp70Rector; +use Reliforp\PhpMemoryDump\Build\Rector\DowngradeNullableTypeToPhp70Rector; +use Reliforp\PhpMemoryDump\Build\Rector\DowngradeVoidReturnToPhp70Rector; + +return RectorConfig::configure() + ->withPaths([__DIR__ . '/build/php70/src']) + ->withPhpVersion(PhpVersion::PHP_70) + ->withDowngradeSets(php71: true) + ->withRules([ + DowngradeNullableTypeToPhp70Rector::class, + DowngradeVoidReturnToPhp70Rector::class, + DowngradeConstVisibilityToPhp70Rector::class, + ]); diff --git a/src/DumpResult.php b/src/DumpResult.php new file mode 100644 index 0000000..a6293b7 --- /dev/null +++ b/src/DumpResult.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Reliforp\PhpMemoryDump; + +/** Summary of a written dump. */ +final class DumpResult +{ + public function __construct( + public readonly string $output_path, + public readonly int $memory_area_count, + public readonly int $region_count, + public readonly int $total_bytes, + ) { + } +} diff --git a/src/Internal/ElfImage.php b/src/Internal/ElfImage.php new file mode 100644 index 0000000..aa01d39 --- /dev/null +++ b/src/Internal/ElfImage.php @@ -0,0 +1,475 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Reliforp\PhpMemoryDump\Internal; + +use Reliforp\PhpMemoryDump\MemoryDumpException; + +/** + * Minimal 64-bit little-endian ELF reader, just enough to resolve the + * link-time address (st_value) of a named symbol. + * + * reli resolves `executor_globals` / `compiler_globals` / `basic_globals` + * from outside the target by reading the PHP binary's symbol table and + * adding the load bias from /proc//maps. We do the very same thing + * from inside the process: this class reads an on-disk ELF object (the + * PHP executable or a libphp*.so) and reports a symbol's st_value, which + * the caller turns into a runtime address. + * + * Only the bytes actually needed are read (ELF/program/section headers and + * the chosen symbol + string tables), not the whole file: slurping the + * ~6 MB PHP binary would dwarf every other allocation the dump makes and + * needlessly churn the very heap we are about to snapshot. The reads are + * done lazily through an open handle with fseek/fread. + * + * Debian/Ubuntu strip the PHP binary (.symtab is gone), but the three + * engine globals are exported in .dynsym, so a stripped distro build is + * still resolvable. When section headers are present we read .symtab / + * .dynsym directly; otherwise we fall back to walking PT_DYNAMIC and + * sizing .dynsym via DT_GNU_HASH / DT_HASH. + */ +final class ElfImage +{ + private const SHT_SYMTAB = 2; + private const SHT_DYNSYM = 11; + + private const PT_LOAD = 1; + private const PT_DYNAMIC = 2; + + private const DT_NULL = 0; + private const DT_HASH = 4; + private const DT_STRTAB = 5; + private const DT_SYMTAB = 6; + private const DT_STRSZ = 10; + private const DT_SYMENT = 11; + private const DT_GNU_HASH = 0x6ffffef5; + + private const STN_UNDEF = 0; + + /** @var resource */ + private $fp; + + private int $file_size; + + /** ELF header (first 64 bytes). */ + private string $ehdr; + + /** link-time base: the lowest p_vaddr among PT_LOAD segments. */ + private int $elf_base = 0; + + /** @var list */ + private array $load_segments = []; + + /** @var array{vaddr: int, size: int}|null */ + private ?array $dynamic_segment = null; + + /** + * Cached symbol source once located: the raw symbol table bytes, the + * raw string table bytes, and the per-entry stride. Loaded at most + * once and reused across the three symbol lookups. + * + * @var array{symtab: string, strtab: string, entsize: int, count: int}|null + */ + private ?array $symbol_source = null; + + private bool $symbol_source_loaded = false; + + /** + * @param resource $fp + */ + private function __construct($fp, int $file_size) + { + $this->fp = $fp; + $this->file_size = $file_size; + $this->ehdr = $this->readRange(0, 64); + if (strlen($this->ehdr) < 64) { + throw new MemoryDumpException('truncated ELF header'); + } + $this->parseProgramHeaders(); + } + + public static function fromFile(string $path): self + { + $fp = @fopen($path, 'rb'); + if ($fp === false) { + throw new MemoryDumpException("failed to open ELF file: {$path}"); + } + stream_set_read_buffer($fp, 0); + $head = fread($fp, 6); + if ($head === false || strlen($head) < 6 || substr($head, 0, 4) !== "\x7fELF") { + fclose($fp); + throw new MemoryDumpException("not an ELF file: {$path}"); + } + // EI_CLASS at offset 4: 2 == ELFCLASS64. EI_DATA at 5: 1 == LSB. + if (ord($head[4]) !== 2 || ord($head[5]) !== 1) { + fclose($fp); + throw new MemoryDumpException( + "unsupported ELF (only 64-bit little-endian): {$path}", + ); + } + $stat = fstat($fp); + $size = is_array($stat) && isset($stat['size']) ? (int)$stat['size'] : PHP_INT_MAX; + return new self($fp, $size); + } + + public function __destruct() + { + if (is_resource($this->fp)) { + fclose($this->fp); + } + } + + /** The lowest p_vaddr among PT_LOAD segments (0 for a PIE/.so). */ + public function getLinkBase(): int + { + return $this->elf_base; + } + + /** + * Resolve the link-time address (st_value) of a defined symbol, or + * null when the symbol is absent or undefined in this object. + */ + public function resolveSymbolValue(string $name): ?int + { + $source = $this->symbolSource(); + if ($source === null) { + return null; + } + return $this->scanSymbols($source, $name); + } + + private function readRange(int $offset, int $len): string + { + if ($len <= 0 || $offset < 0 || $offset >= $this->file_size) { + return ''; + } + $len = (int)min($len, $this->file_size - $offset); + if (@fseek($this->fp, $offset) !== 0) { + return ''; + } + $out = ''; + while (strlen($out) < $len) { + $chunk = fread($this->fp, $len - strlen($out)); + if ($chunk === false || $chunk === '') { + break; + } + $out .= $chunk; + } + return $out; + } + + private static function u16(string $buf, int $off): int + { + return unpack('v', substr($buf, $off, 2))[1]; + } + + private static function u32(string $buf, int $off): int + { + return unpack('V', substr($buf, $off, 4))[1]; + } + + private static function u64(string $buf, int $off): int + { + return unpack('P', substr($buf, $off, 8))[1]; + } + + private function parseProgramHeaders(): void + { + $e_phoff = self::u64($this->ehdr, 0x20); + $e_phentsize = self::u16($this->ehdr, 0x36); + $e_phnum = self::u16($this->ehdr, 0x38); + if ($e_phoff === 0 || $e_phnum === 0 || $e_phentsize < 56) { + return; + } + + $block = $this->readRange($e_phoff, $e_phnum * $e_phentsize); + $base = null; + for ($i = 0; $i < $e_phnum; $i++) { + $off = $i * $e_phentsize; + if ($off + 56 > strlen($block)) { + break; + } + $p_type = self::u32($block, $off); + if ($p_type === self::PT_LOAD) { + $p_offset = self::u64($block, $off + 0x08); + $p_vaddr = self::u64($block, $off + 0x10); + $p_filesz = self::u64($block, $off + 0x20); + $this->load_segments[] = [ + 'vaddr' => $p_vaddr, + 'offset' => $p_offset, + 'filesz' => $p_filesz, + ]; + if ($base === null || $p_vaddr < $base) { + $base = $p_vaddr; + } + } elseif ($p_type === self::PT_DYNAMIC) { + $this->dynamic_segment = [ + 'vaddr' => self::u64($block, $off + 0x10), + 'size' => self::u64($block, $off + 0x20), + ]; + } + } + $this->elf_base = $base ?? 0; + } + + /** + * Translate a virtual address (as found in the dynamic section, which + * stores already-relocated link-time vaddrs) into a file offset using + * the PT_LOAD segment table. + */ + private function vaddrToOffset(int $vaddr): ?int + { + foreach ($this->load_segments as $seg) { + if ($vaddr >= $seg['vaddr'] && $vaddr < $seg['vaddr'] + $seg['filesz']) { + return $seg['offset'] + ($vaddr - $seg['vaddr']); + } + } + return null; + } + + /** + * @return array{symtab: string, strtab: string, entsize: int, count: int}|null + */ + private function symbolSource(): ?array + { + if ($this->symbol_source_loaded) { + return $this->symbol_source; + } + $this->symbol_source_loaded = true; + $this->symbol_source = $this->loadViaSections() ?? $this->loadViaDynamic(); + return $this->symbol_source; + } + + /** + * @return array{symtab: string, strtab: string, entsize: int, count: int}|null + */ + private function loadViaSections(): ?array + { + $e_shoff = self::u64($this->ehdr, 0x28); + $e_shentsize = self::u16($this->ehdr, 0x3a); + $e_shnum = self::u16($this->ehdr, 0x3c); + if ($e_shoff === 0 || $e_shnum === 0 || $e_shentsize < 64) { + return null; + } + + $block = $this->readRange($e_shoff, $e_shnum * $e_shentsize); + if (strlen($block) < $e_shnum * $e_shentsize) { + return null; + } + + // Prefer .symtab (complete) over .dynsym (exported only); either + // works for the three exported engine globals. + $candidates = []; + for ($i = 0; $i < $e_shnum; $i++) { + $off = $i * $e_shentsize; + $sh_type = self::u32($block, $off + 0x04); + if ($sh_type === self::SHT_SYMTAB || $sh_type === self::SHT_DYNSYM) { + $candidates[] = [ + 'type' => $sh_type, + 'offset' => self::u64($block, $off + 0x18), + 'size' => self::u64($block, $off + 0x20), + 'link' => self::u32($block, $off + 0x28), // strtab section index + 'entsize' => self::u64($block, $off + 0x38), + ]; + } + } + usort( + $candidates, + static fn (array $a, array $b): int + => ($a['type'] === self::SHT_SYMTAB ? 0 : 1) + <=> ($b['type'] === self::SHT_SYMTAB ? 0 : 1), + ); + + foreach ($candidates as $sym) { + $entsize = $sym['entsize'] !== 0 ? $sym['entsize'] : 24; + if ($entsize < 24 || $sym['size'] <= 0 || $sym['link'] >= $e_shnum) { + continue; + } + $strtab_hdr = $sym['link'] * $e_shentsize; + $str_offset = self::u64($block, $strtab_hdr + 0x18); + $str_size = self::u64($block, $strtab_hdr + 0x20); + + $symtab = $this->readRange($sym['offset'], $sym['size']); + $strtab = $this->readRange($str_offset, $str_size); + if ($symtab === '' || $strtab === '') { + continue; + } + return [ + 'symtab' => $symtab, + 'strtab' => $strtab, + 'entsize' => $entsize, + 'count' => intdiv(strlen($symtab), $entsize), + ]; + } + return null; + } + + /** + * @return array{symtab: string, strtab: string, entsize: int, count: int}|null + */ + private function loadViaDynamic(): ?array + { + if ($this->dynamic_segment === null) { + return null; + } + $dyn_off = $this->vaddrToOffset($this->dynamic_segment['vaddr']); + if ($dyn_off === null) { + return null; + } + $dyn = $this->readRange($dyn_off, $this->dynamic_segment['size']); + + $symtab_v = $strtab_v = $hash_v = $gnu_hash_v = null; + $syment = 24; + $strsz = 0; + $n = intdiv(strlen($dyn), 16); + for ($i = 0; $i < $n; $i++) { + $tag = self::u64($dyn, $i * 16); + $val = self::u64($dyn, $i * 16 + 8); + if ($tag === self::DT_NULL) { + break; + } + switch ($tag) { + case self::DT_SYMTAB: $symtab_v = $val; break; + case self::DT_STRTAB: $strtab_v = $val; break; + case self::DT_SYMENT: $syment = $val; break; + case self::DT_STRSZ: $strsz = $val; break; + case self::DT_HASH: $hash_v = $val; break; + case self::DT_GNU_HASH: $gnu_hash_v = $val; break; + } + } + if ($symtab_v === null || $strtab_v === null || $syment < 24) { + return null; + } + $symtab_off = $this->vaddrToOffset($symtab_v); + $strtab_off = $this->vaddrToOffset($strtab_v); + if ($symtab_off === null || $strtab_off === null) { + return null; + } + + $count = $this->dynsymCount($hash_v, $gnu_hash_v); + if ($count === null || $count <= 0) { + return null; + } + + $symtab = $this->readRange($symtab_off, $count * $syment); + // .dynstr length isn't always given by DT_STRSZ on every object; + // bound it by the symtab gap when missing, else read DT_STRSZ. + $strtab = $this->readRange($strtab_off, $strsz > 0 ? $strsz : 1 << 20); + if ($symtab === '' || $strtab === '') { + return null; + } + return [ + 'symtab' => $symtab, + 'strtab' => $strtab, + 'entsize' => $syment, + 'count' => intdiv(strlen($symtab), $syment), + ]; + } + + /** Number of entries in .dynsym, from DT_HASH or DT_GNU_HASH. */ + private function dynsymCount(?int $hash_v, ?int $gnu_hash_v): ?int + { + if ($hash_v !== null) { + $off = $this->vaddrToOffset($hash_v); + if ($off !== null) { + // Elf64 hash: nbucket(4), nchain(4), ...; nchain == symbol count. + $buf = $this->readRange($off, 8); + if (strlen($buf) >= 8) { + return self::u32($buf, 4); + } + } + } + if ($gnu_hash_v !== null) { + $off = $this->vaddrToOffset($gnu_hash_v); + if ($off !== null) { + $head = $this->readRange($off, 16); + if (strlen($head) < 16) { + return null; + } + $nbuckets = self::u32($head, 0); + $symoffset = self::u32($head, 4); + $bloom_size = self::u32($head, 8); + $bucket_off = $off + 16 + $bloom_size * 8; + $buckets = $this->readRange($bucket_off, $nbuckets * 4); + $max_sym = $symoffset; + for ($i = 0; $i < $nbuckets; $i++) { + if (($i + 1) * 4 > strlen($buckets)) { + break; + } + $b = self::u32($buckets, $i * 4); + if ($b > $max_sym) { + $max_sym = $b; + } + } + if ($max_sym < $symoffset) { + return $symoffset; + } + // Walk the chain of the highest-indexed symbol until its + // terminator (low bit set) to find the last symbol index. + $chain_off = $bucket_off + $nbuckets * 4; + $idx = $max_sym; + $guard = 0; + while (true) { + $h = $this->readRange($chain_off + ($idx - $symoffset) * 4, 4); + if (strlen($h) < 4) { + return null; + } + if ((self::u32($h, 0) & 1) === 1) { + return $idx + 1; + } + $idx++; + if (++$guard > 5_000_000) { + return null; + } + } + } + } + return null; + } + + /** + * @param array{symtab: string, strtab: string, entsize: int, count: int} $source + */ + private function scanSymbols(array $source, string $name): ?int + { + $symtab = $source['symtab']; + $strtab = $source['strtab']; + $entsize = $source['entsize']; + $count = $source['count']; + $str_size = strlen($strtab); + $sym_len = strlen($symtab); + + for ($i = 0; $i < $count; $i++) { + $e = $i * $entsize; + if ($e + 24 > $sym_len) { + break; + } + $st_name = self::u32($symtab, $e); + $st_shndx = self::u16($symtab, $e + 6); + if ($st_name === 0 || $st_shndx === self::STN_UNDEF || $st_name >= $str_size) { + continue; + } + $end = strpos($strtab, "\0", $st_name); + if ($end === false) { + continue; + } + if (substr($strtab, $st_name, $end - $st_name) === $name) { + $st_value = self::u64($symtab, $e + 8); + if ($st_value !== 0) { + return $st_value; + } + } + } + return null; + } +} diff --git a/src/Internal/GlobalsResolver.php b/src/Internal/GlobalsResolver.php new file mode 100644 index 0000000..9557ff6 --- /dev/null +++ b/src/Internal/GlobalsResolver.php @@ -0,0 +1,151 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Reliforp\PhpMemoryDump\Internal; + +use Reliforp\PhpMemoryDump\MemoryDumpException; + +/** + * Resolve the runtime addresses of the Zend engine globals + * (`executor_globals`, `compiler_globals`, `basic_globals`) for an NTS + * build, the same way reli does when it attaches from outside: + * + * runtime_addr = (maps_module_base - elf_link_base) + symbol.st_value + * + * The symbol lives either in the PHP executable (CLI / most SAPIs) or in + * libphp*.so (embed / some FPM builds), so we probe /proc/self/exe first + * and then any mapped shared object whose name looks like libphp, using + * whichever object actually defines the symbol. + * + * ZTS builds keep these globals in thread-local storage reached through + * TSRM, which a pure-PHP reader cannot resolve without walking the TLS + * block; that case is rejected with a clear error. + */ +final class GlobalsResolver +{ + private const SYMBOLS = ['executor_globals', 'compiler_globals', 'basic_globals']; + + /** @var array cache keyed by module path */ + private array $elf_cache = []; + + public function __construct(private MemoryMap $map) + { + } + + /** + * @return array{executor_globals: int, compiler_globals: int, basic_globals: int} + */ + public function resolve(): array + { + if (PHP_ZTS) { + throw new MemoryDumpException( + 'this pure-PHP dumper supports NTS builds only; the running ' + . 'PHP is ZTS, whose engine globals live in thread-local ' + . 'storage and cannot be resolved without TSRM/TLS walking. ' + . 'Use ext-rdump (or reli attaching from outside) for ZTS.', + ); + } + + foreach ($this->candidateModules() as $path) { + $result = $this->tryModule($path); + if ($result !== null) { + return $result; + } + } + + throw new MemoryDumpException( + 'could not resolve executor_globals / compiler_globals / ' + . 'basic_globals from the PHP binary. The binary may be fully ' + . 'stripped of both .symtab and .dynsym, or built statically in ' + . 'an unexpected way.', + ); + } + + /** + * Modules to probe, exe first, then libphp-looking shared objects. + * + * @return list + */ + private function candidateModules(): array + { + $candidates = []; + + $exe = @readlink('/proc/self/exe'); + if (is_string($exe) && $exe !== '') { + $candidates[] = $exe; + } + + foreach ($this->map->modulePaths() as $path) { + $base = basename($path); + if ( + str_contains($base, 'libphp') + || str_contains($base, 'php') + || in_array($path, $candidates, true) + ) { + $candidates[] = $path; + } + } + + // De-duplicate, preserving order. + return array_values(array_unique($candidates)); + } + + /** + * @return array{executor_globals: int, compiler_globals: int, basic_globals: int}|null + */ + private function tryModule(string $path): ?array + { + $module_base = $this->map->moduleBase($path); + if ($module_base === null) { + // The exe path from readlink may differ from the maps pathname + // (e.g. " (deleted)" suffix on upgrade); try a suffix match. + $module_base = $this->fuzzyModuleBase($path); + if ($module_base === null) { + return null; + } + } + + try { + $elf = $this->elf_cache[$path] ??= ElfImage::fromFile($path); + } catch (MemoryDumpException) { + return null; + } + + $load_bias = $module_base - $elf->getLinkBase(); + + $resolved = []; + foreach (self::SYMBOLS as $sym) { + $value = $elf->resolveSymbolValue($sym); + if ($value === null) { + return null; + } + $resolved[$sym] = $load_bias + $value; + } + + /** @var array{executor_globals: int, compiler_globals: int, basic_globals: int} $resolved */ + return $resolved; + } + + private function fuzzyModuleBase(string $path): ?int + { + foreach ($this->map->modulePaths() as $candidate) { + if (str_starts_with($candidate, $path)) { + $base = $this->map->moduleBase($candidate); + if ($base !== null) { + return $base; + } + } + } + return null; + } +} diff --git a/src/Internal/MapEntry.php b/src/Internal/MapEntry.php new file mode 100644 index 0000000..052a6d2 --- /dev/null +++ b/src/Internal/MapEntry.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Reliforp\PhpMemoryDump\Internal; + +/** One line of /proc/self/maps. */ +final class MapEntry +{ + public function __construct( + public readonly string $begin, + public readonly string $end, + public readonly string $perms, + public readonly string $file_offset, + public readonly string $device_id, + public readonly int $inode, + public readonly string $name, + ) { + } + + public function start(): int + { + return (int)hexdec($this->begin); + } + + public function end(): int + { + return (int)hexdec($this->end); + } + + public function size(): int + { + return $this->end() - $this->start(); + } + + public function isReadable(): bool + { + return ($this->perms[0] ?? '-') === 'r'; + } + + public function isWritable(): bool + { + return ($this->perms[1] ?? '-') === 'w'; + } + + public function isExecutable(): bool + { + return ($this->perms[2] ?? '-') === 'x'; + } + + public function isPrivate(): bool + { + return ($this->perms[3] ?? '-') === 'p'; + } + + public function isAnonymous(): bool + { + return $this->name === ''; + } + + /** + * Kernel-special pseudo-mappings we never copy bytes for: some are + * unreadable (vsyscall) and none carry PHP state. They still appear in + * the memory map so the dump stays a faithful snapshot. + */ + public function isKernelSpecial(): bool + { + return in_array( + $this->name, + ['[vvar]', '[vdso]', '[vsyscall]', '[vvar_vclock]'], + true, + ); + } + + /** + * Whether this VMA's bytes should be embedded in the dump, mirroring + * ext-rdump's rule: every readable, volatile mapping (writable, + * anonymous, or opcache /dev/zero SHM), plus — with $full — read-only + * file-backed segments for a self-contained dump. + */ + public function shouldCapture(bool $full): bool + { + if (!$this->isReadable() || $this->isKernelSpecial() || $this->size() <= 0) { + return false; + } + $is_shm = str_contains($this->name, '/dev/zero'); + return $this->isWritable() || $this->isAnonymous() || $is_shm || $full; + } +} diff --git a/src/Internal/MemoryMap.php b/src/Internal/MemoryMap.php new file mode 100644 index 0000000..133a42a --- /dev/null +++ b/src/Internal/MemoryMap.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Reliforp\PhpMemoryDump\Internal; + +use Reliforp\PhpMemoryDump\MemoryDumpException; + +/** + * A parsed snapshot of /proc/self/maps. + * + * Read once, up front: the act of dumping perturbs the very heap we are + * about to copy, so we freeze the VMA list before any region is read or + * any output buffer is allocated. + */ +final class MemoryMap +{ + /** @param list $entries */ + private function __construct(public readonly array $entries) + { + } + + public static function readSelf(): self + { + $raw = @file_get_contents('/proc/self/maps'); + if ($raw === false) { + throw new MemoryDumpException('failed to read /proc/self/maps'); + } + return self::parse($raw); + } + + public static function parse(string $raw): self + { + $entries = []; + foreach (explode("\n", $raw) as $line) { + if ($line === '') { + continue; + } + // address perms offset dev inode pathname + // 7f..-7f.. r-xp 00000000 08:01 12345 /usr/bin/php8.4 + if (!preg_match( + '/^([0-9a-fA-F]+)-([0-9a-fA-F]+)\s+(\S{4})\s+([0-9a-fA-F]+)\s+(\S+)\s+(\d+)\s*(.*)$/', + $line, + $m, + )) { + continue; + } + $entries[] = new MapEntry( + begin: $m[1], + end: $m[2], + perms: $m[3], + file_offset: $m[4], + device_id: $m[5], + inode: (int)$m[6], + name: rtrim($m[7]), + ); + } + return new self($entries); + } + + /** + * Lowest mapping start address among the VMAs whose pathname equals + * $path. This is the module's runtime load base, from which the load + * bias (base - ELF link base) is derived. + */ + public function moduleBase(string $path): ?int + { + $base = null; + foreach ($this->entries as $e) { + if ($e->name === $path) { + $start = hexdec($e->begin); + if ($base === null || $start < $base) { + $base = $start; + } + } + } + return $base; + } + + /** + * Distinct file-backed module pathnames present in the map, in order + * of first appearance. Anonymous and kernel-pseudo mappings are + * skipped. + * + * @return list + */ + public function modulePaths(): array + { + $seen = []; + $out = []; + foreach ($this->entries as $e) { + $name = $e->name; + if ($name === '' || $name[0] === '[' || str_starts_with($name, 'anon')) { + continue; + } + if (!isset($seen[$name])) { + $seen[$name] = true; + $out[] = $name; + } + } + return $out; + } +} diff --git a/src/MemoryDumpException.php b/src/MemoryDumpException.php new file mode 100644 index 0000000..e8006fa --- /dev/null +++ b/src/MemoryDumpException.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Reliforp\PhpMemoryDump; + +final class MemoryDumpException extends \RuntimeException +{ +} diff --git a/src/MemoryDumper.php b/src/MemoryDumper.php new file mode 100644 index 0000000..b410dc8 --- /dev/null +++ b/src/MemoryDumper.php @@ -0,0 +1,164 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Reliforp\PhpMemoryDump; + +use Reliforp\PhpMemoryDump\Internal\GlobalsResolver; +use Reliforp\PhpMemoryDump\Internal\MemoryMap; + +/** + * Take a memory dump of the current PHP process, from inside the process, + * in pure PHP — no extension, no FFI — and write it in reli's RDUMP + * format for offline analysis. + * + * (new MemoryDumper())->dump('/tmp/app.rdump'); + * + * This is the pure-PHP sibling of ext-rdump. Where the C extension takes + * the engine-globals addresses straight from the linker and copies region + * bytes with memcpy, this class resolves those same addresses by reading + * the PHP binary's ELF symbol table (exactly as reli does when it attaches + * from the outside) and copies region bytes through /proc/self/mem. + * + * Caveats inherent to dumping yourself in PHP: + * - NTS only. ZTS keeps the globals in thread-local storage, out of a + * pure-PHP reader's reach. + * - Linux only (it is built from /proc/self/maps and /proc/self/mem). + * - 64-bit little-endian only (the format records native widths). + * - The dump is not a stop-the-world snapshot: this very code mutates the + * heap as it runs. The map is frozen up front and regions are streamed, + * so the window is small, but a dump of a live, mutating process can be + * slightly internally inconsistent — same caveat ext-rdump notes for + * busy ZTS processes. + */ +final class MemoryDumper +{ + /** + * @param string $path output file path + * @param bool $full also embed read-only file-backed segments, for a + * self-contained dump analysable without the original binaries + * (larger output) + * @return DumpResult counts for the written dump + */ + public function dump(string $path, bool $full = false): DumpResult + { + $this->assertSupportedPlatform(); + + // Freeze the VMA list before allocating any output buffer, so the + // snapshot reflects the heap as it was at entry rather than after + // our own dumping allocations have churned it. + $map = MemoryMap::readSelf(); + + $globals = (new GlobalsResolver($map))->resolve(); + $rss = $this->readRssBytes(); + $pid = getmypid(); + if ($pid === false) { + $pid = 0; + } + + $captured = []; + foreach ($map->entries as $e) { + if ($e->shouldCapture($full)) { + $captured[] = $e; + } + } + + $fp = @fopen($path, 'wb'); + if ($fp === false) { + throw new MemoryDumpException("failed to open file for writing: {$path}"); + } + + $reader = RegionReader::openSelf(); + try { + @chmod($path, 0600); + $writer = new RdumpWriter($fp); + $writer->writeHeader( + pid: $pid, + php_version: $this->phpVersionTag(), + eg_address: $globals['executor_globals'], + cg_address: $globals['compiler_globals'], + rss_bytes: $rss, + module_globals: ['basic_globals' => $globals['basic_globals']], + entries: $map->entries, + region_count: count($captured), + ); + + $total_bytes = 0; + foreach ($captured as $e) { + $size = $e->size(); + $writer->writeRegion($e->start(), $size, $reader); + $total_bytes += $size; + } + } finally { + $reader->close(); + fclose($fp); + } + + return new DumpResult( + output_path: $path, + memory_area_count: count($map->entries), + region_count: count($captured), + total_bytes: $total_bytes, + ); + } + + private function assertSupportedPlatform(): void + { + // PHP_OS (not PHP_OS_FAMILY, which is 7.2+) so the check itself runs + // on the 7.0 floor; on Linux PHP_OS is "Linux". + if (stripos(PHP_OS, 'Linux') !== 0) { + throw new MemoryDumpException( + 'pure-PHP memory dump is Linux-only (needs /proc/self/maps ' + . 'and /proc/self/mem)', + ); + } + if (PHP_INT_SIZE !== 8) { + throw new MemoryDumpException( + 'pure-PHP memory dump targets 64-bit builds only', + ); + } + if (unpack('S', "\x01\x00")[1] !== 1) { + throw new MemoryDumpException( + 'pure-PHP memory dump targets little-endian builds only', + ); + } + } + + private function phpVersionTag(): string + { + return 'v' . PHP_MAJOR_VERSION . PHP_MINOR_VERSION; + } + + /** Resident set size in bytes from /proc/self/statm, or null. */ + private function readRssBytes(): ?int + { + $raw = @file_get_contents('/proc/self/statm'); + if ($raw === false) { + return null; + } + $fields = preg_split('/\s+/', trim($raw)); + if ($fields === false || !isset($fields[1]) || !is_numeric($fields[1])) { + return null; + } + // Page size: 4096 on x86-64 and the common arm64 config. The RSS + // figure is informational in the dump, so fall back rather than + // hard-depend on ext-posix for the exact value. + $page = 4096; + if (function_exists('posix_sysconf') && defined('POSIX_SC_PAGESIZE')) { + $detected = @posix_sysconf(POSIX_SC_PAGESIZE); + if (is_int($detected) && $detected > 0) { + $page = $detected; + } + } + return (int)$fields[1] * $page; + } +} diff --git a/src/OomDumpHandler.php b/src/OomDumpHandler.php new file mode 100644 index 0000000..2e3082c --- /dev/null +++ b/src/OomDumpHandler.php @@ -0,0 +1,192 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Reliforp\PhpMemoryDump; + +/** + * Register a shutdown handler that takes a memory dump when the request + * dies of a `memory_limit` exhaustion, so you can analyse the moment of + * the OOM offline with reli. + * + * OomDumpHandler::register('/var/log/oom-%p-%t.rdump'); + * + * This mirrors reli's sidecar client `MemoryLimitHandler::register()`, but + * where the sidecar client only asks a *separate* daemon to do the work + * (so its dying process needs almost no memory), this handler runs the + * dump **in-process**. That changes what it takes to survive the OOM: + * + * - **Raising `memory_limit` is the reliable lever.** memory_limit is + * enforced on memory actually obtained from the OS (ZendMM's real + * usage). By default the handler lifts it (to unlimited) before + * dumping — the process is exiting anyway — which deterministically + * gives the dumper the ~one 2 MiB ZendMM chunk of working set it needs. + * - **The emergency reserve must be a *huge* block (>= 2 MiB).** Freeing a + * sub-2 MiB reserve returns it to a ZendMM chunk's free list but does + * NOT munmap the chunk, so real usage — and thus memory_limit headroom + * — is unchanged. A 1 MiB reserve frees *zero* headroom. The reserve + * here defaults to 4 MiB and is the fallback for when raising + * memory_limit is not possible (e.g. `ini_set` restricted). + * + * ## Best-effort, by nature + * + * A shutdown handler cannot catch *every* OOM. When the exhausting + * allocation is itself a VM-stack page push (or the stack sits on a page + * boundary), pushing the handler's own call frame needs another `emalloc` + * that also fails, so the handler body never runs — no reserve or + * `memory_limit` bump can help, because nothing executes. Catching those + * needs the C extension's `zend_error_cb` hook (ext-rdump). Treat this as + * a high-coverage best-effort, not a guarantee. + */ +final class OomDumpHandler +{ + /** + * Default emergency reserve. 4 MiB is a *huge* ZendMM allocation, so + * releasing it actually returns memory to the OS (unlike a sub-2 MiB + * block), and it comfortably covers the dumper's in-process working + * set even when `memory_limit` cannot be raised. + */ + public const DEFAULT_RESERVE_BYTES = 4 * 1024 * 1024; + + /** Pre-allocated block, released at the very start of the handler. */ + private static ?string $reserve = null; + + private static bool $registered = false; + + /** + * @param string $path output path; may contain `%p` (pid), `%t` (unix + * time at dump) and `%%` (a literal `%`), letting one registration + * give each worker/run a distinct file + * @param bool $full also embed read-only file-backed segments (larger, + * self-contained dump) + * @param (callable(DumpResult, string): void)|null $on_dump invoked with + * the result and the expanded path after a successful dump + * @param (callable(\Throwable): void)|null $on_error invoked when the + * dump throws; defaults to an `error_log()` line + * @param int $reserve_bytes emergency reserve, freed first thing in the + * handler. Must be >= 2 MiB to recover any `memory_limit` headroom; + * smaller values are accepted but free no real memory. + * @param int|string|false $memory_limit value passed to + * `ini_set('memory_limit', ...)` inside the handler before dumping + * (`-1` = unlimited, the default; a string like `'256M'` or a byte + * count also work). Pass `false` to leave `memory_limit` untouched + * and rely solely on the reserve. + */ + public static function register( + string $path, + bool $full = false, + ?callable $on_dump = null, + ?callable $on_error = null, + int $reserve_bytes = self::DEFAULT_RESERVE_BYTES, + int|string|false $memory_limit = -1, + ): void { + // Pre-load every class the handler touches, so autoload — file I/O + // plus compilation, both of which need memory — never has to run + // inside the already-exhausted shutdown handler. + class_exists(MemoryDumper::class); + class_exists(RegionReader::class); + class_exists(RdumpWriter::class); + class_exists(DumpResult::class); + class_exists(MemoryDumpException::class); + class_exists(Internal\MemoryMap::class); + class_exists(Internal\MapEntry::class); + class_exists(Internal\GlobalsResolver::class); + class_exists(Internal\ElfImage::class); + + self::$reserve = $reserve_bytes > 0 ? str_repeat("\0", $reserve_bytes) : null; + self::$registered = true; + + register_shutdown_function( + self::createHandler($path, $full, $on_dump, $on_error, $memory_limit), + ); + } + + /** + * @param (callable(DumpResult, string): void)|null $on_dump + * @param (callable(\Throwable): void)|null $on_error + * @param int|string|false $memory_limit + */ + private static function createHandler( + string $path, + bool $full, + ?callable $on_dump, + ?callable $on_error, + int|string|false $memory_limit, + ): \Closure { + return static function () use ($path, $full, $on_dump, $on_error, $memory_limit): void { + // Free the emergency reserve before anything else, so the dump + // has headroom. For a >= 2 MiB (huge) reserve this also returns + // memory to the OS, recovering memory_limit headroom. + self::$reserve = null; + + $error = error_get_last(); + if ($error === null || !self::isMemoryLimitError($error['message'] ?? '')) { + return; + } + + // Raising memory_limit is the deterministic way to give the + // in-process dumper room; the process is exiting regardless. + if ($memory_limit !== false) { + @ini_set('memory_limit', (string)$memory_limit); + } + + $out_path = self::expandPath($path); + try { + $result = (new MemoryDumper())->dump($out_path, $full); + if ($on_dump !== null) { + $on_dump($result, $out_path); + } else { + error_log(sprintf( + 'php-memory-dump: OOM dump saved to %s (%.1f MiB, %d regions)', + $result->output_path, + $result->total_bytes / 1048576, + $result->region_count, + )); + } + } catch (\Throwable $e) { + if ($on_error !== null) { + $on_error($e); + } else { + error_log('php-memory-dump: OOM dump failed: ' . $e->getMessage()); + } + } + }; + } + + /** Whether a fatal-error message is a `memory_limit` exhaustion. */ + public static function isMemoryLimitError(string $message): bool + { + return str_contains($message, 'Allowed memory size'); + } + + /** + * Expand `%p` (pid), `%t` (unix time) and `%%` (literal `%`) in a path + * template. Any other `%x` is left verbatim. Mirrors ext-rdump's + * oom_dump path templating (minus `%i`, since this is NTS-only and has + * no thread id to disambiguate). + */ + public static function expandPath(string $template): string + { + return preg_replace_callback( + '/%(.)/', + static function (array $m): string { + return match ($m[1]) { + 'p' => (string)getmypid(), + 't' => (string)time(), + '%' => '%', + default => $m[0], + }; + }, + $template, + ); + } +} diff --git a/src/RdumpWriter.php b/src/RdumpWriter.php new file mode 100644 index 0000000..0a81c6e --- /dev/null +++ b/src/RdumpWriter.php @@ -0,0 +1,133 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Reliforp\PhpMemoryDump; + +use Reliforp\PhpMemoryDump\Internal\MapEntry; + +/** + * Streaming writer for reli's RDUMP dump-file format (magic "RDUMP", + * format version 3). Byte-compatible with the file produced by + * reli's MemoryDumpWriter and ext-rdump. + * + * Layout: + * "RDUMP\0\0\0" magic (8 bytes) + * u32 format version (3) + * str php version tag, e.g. "v84" + * u64 pid + * u64 executor_globals address + * u64 compiler_globals address + * i64 RSS in bytes (-1 = unavailable) + * u32 + entries module-globals map (name str + u64 address)* + * u32 memory-map entry count + * u32 captured region count + * memory map: (begin str, end str, offset str, attrs[4], + * dev str, inode u64, name str) per VMA + * regions: (address u64, size u64, raw bytes) per captured VMA + * + * A "str" is a u32 length followed by that many raw bytes. + */ +final class RdumpWriter +{ + private const MAGIC = "RDUMP\0\0\0"; + private const FORMAT_VERSION = 3; + + /** @var resource */ + private $fp; + + /** + * @param resource $fp open for binary writing + */ + public function __construct($fp) + { + $this->fp = $fp; + } + + private static function packString(string $s): string + { + return pack('V', strlen($s)) . $s; + } + + private function write(string $bytes): void + { + $len = strlen($bytes); + $written = 0; + while ($written < $len) { + $n = fwrite($this->fp, $written === 0 ? $bytes : substr($bytes, $written)); + if ($n === false || $n === 0) { + throw new MemoryDumpException('failed while writing dump file (disk full?)'); + } + $written += $n; + } + } + + /** + * @param list $entries every VMA, captured or not + * @param array $module_globals e.g. ['basic_globals' => 0x...] + */ + public function writeHeader( + int $pid, + string $php_version, + int $eg_address, + int $cg_address, + ?int $rss_bytes, + array $module_globals, + array $entries, + int $region_count, + ): void { + $buf = self::MAGIC; + $buf .= pack('V', self::FORMAT_VERSION); + $buf .= self::packString($php_version); + $buf .= pack('P', $pid); + $buf .= pack('P', $eg_address); + $buf .= pack('P', $cg_address); + $buf .= pack('q', $rss_bytes ?? -1); + $buf .= pack('V', count($module_globals)); + foreach ($module_globals as $key => $address) { + $buf .= self::packString((string)$key); + $buf .= pack('P', $address); + } + $buf .= pack('V', count($entries)); + $buf .= pack('V', $region_count); + + foreach ($entries as $e) { + $buf .= self::packString($e->begin); + $buf .= self::packString($e->end); + $buf .= self::packString($e->file_offset); + $buf .= pack( + 'CCCC', + $e->isReadable() ? 1 : 0, + $e->isWritable() ? 1 : 0, + $e->isExecutable() ? 1 : 0, + $e->isPrivate() ? 1 : 0, + ); + $buf .= self::packString($e->device_id); + $buf .= pack('P', $e->inode); + $buf .= self::packString($e->name); + } + $this->write($buf); + } + + /** + * Write one region: address, size, then exactly $size bytes streamed + * from $reader so the whole region never has to sit in PHP memory at + * once. + */ + public function writeRegion(int $address, int $size, RegionReader $reader): void + { + $this->write(pack('P', $address) . pack('P', $size)); + $reader->copyInto($address, $size, function (string $chunk): void { + $this->write($chunk); + }); + } +} diff --git a/src/RegionReader.php b/src/RegionReader.php new file mode 100644 index 0000000..0e28a27 --- /dev/null +++ b/src/RegionReader.php @@ -0,0 +1,109 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Reliforp\PhpMemoryDump; + +/** + * Reads a process's own memory through /proc/self/mem. + * + * Why not just dereference the bytes? PHP has no way to read a raw + * pointer, but /proc/self/mem exposes the whole address space as a + * seekable file: fseek() to the virtual address, fread() the bytes. This + * also gives a crash-safety property — if a page in a declared VMA turns + * out to be unreadable (a guard page, or, under threads, a region another + * thread unmapped mid-dump), the read fails for that page instead of + * faulting the process, and we zero-fill it so the region keeps its + * declared length (the same trick ext-rdump's rdump.safe_read uses). + * + * PHP's fseek takes a 64-bit offset on a 64-bit build, so the full user + * address range (< 2^47 on x86-64 / arm64) is reachable directly. + */ +final class RegionReader +{ + private const CHUNK = 256 * 1024; + private const PAGE = 4096; + + /** @var resource */ + private $fp; + + private function __construct($fp) + { + $this->fp = $fp; + } + + public static function openSelf(): self + { + $fp = @fopen('/proc/self/mem', 'rb'); + if ($fp === false) { + throw new MemoryDumpException('failed to open /proc/self/mem'); + } + // Unbuffered: we always seek to an absolute address before each + // read, so a read-ahead buffer would only waste work. + stream_set_read_buffer($fp, 0); + return new self($fp); + } + + public function close(): void + { + if (is_resource($this->fp)) { + fclose($this->fp); + } + } + + /** + * Stream exactly $size bytes starting at virtual address $address, + * invoking $sink for each chunk. Unreadable pages are zero-filled so + * the emitted byte count always equals $size. + * + * @param callable(string): void $sink + */ + public function copyInto(int $address, int $size, callable $sink): void + { + $pos = 0; + while ($pos < $size) { + $want = min(self::CHUNK, $size - $pos); + $chunk = $this->readAt($address + $pos, $want); + $sink($chunk); + $pos += $want; + } + } + + /** Read $want bytes at $vaddr, zero-filling any unreadable page. */ + private function readAt(int $vaddr, int $want): string + { + if (@fseek($this->fp, $vaddr) !== 0) { + return str_repeat("\0", $want); + } + $out = ''; + $filled = 0; + while ($filled < $want) { + $data = @fread($this->fp, $want - $filled); + if ($data === false || $data === '') { + // EOF/EFAULT on this page: zero just one page and resume + // past it, so a single hole doesn't blank the rest of the + // chunk when later pages are still readable. + $step = min(self::PAGE, $want - $filled); + $out .= str_repeat("\0", $step); + $filled += $step; + if (@fseek($this->fp, $vaddr + $filled) !== 0) { + $out .= str_repeat("\0", $want - $filled); + break; + } + continue; + } + $out .= $data; + $filled += strlen($data); + } + return $out; + } +} diff --git a/tests/MemoryMapTest.php b/tests/MemoryMapTest.php new file mode 100644 index 0000000..de230a9 --- /dev/null +++ b/tests/MemoryMapTest.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Reliforp\PhpMemoryDump\Tests; + +use PHPUnit\Framework\TestCase; +use Reliforp\PhpMemoryDump\Internal\MemoryMap; + +final class MemoryMapTest extends TestCase +{ + private const SAMPLE = <<<'MAPS' + 5621774aa000-56217759a000 r--p 00000000 fe:00 120348 /usr/bin/php8.4 + 56217759a000-5621778ea000 r-xp 000f0000 fe:00 120348 /usr/bin/php8.4 + 562177a68000-562177a6a000 rw-p 005be000 fe:00 120348 /usr/bin/php8.4 + 562177a6a000-562177a89000 rw-p 00000000 00:00 0 + 5621b613a000-5621b65a2000 rw-p 00000000 00:00 0 [heap] + 7f7fc0000000-7f7fc0021000 rw-p 00000000 00:00 0 + 7fffffffe000-7ffffffff000 r-xp 00000000 00:00 0 [vdso] + ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 [vsyscall] + MAPS; + + public function testParsesAllEntries(): void + { + $map = MemoryMap::parse(self::SAMPLE); + $this->assertCount(8, $map->entries); + + $first = $map->entries[0]; + $this->assertSame('5621774aa000', $first->begin); + $this->assertSame('r--p', $first->perms); + $this->assertSame('fe:00', $first->device_id); + $this->assertSame(120348, $first->inode); + $this->assertSame('/usr/bin/php8.4', $first->name); + } + + public function testAnonymousAndNamedDetection(): void + { + $map = MemoryMap::parse(self::SAMPLE); + $this->assertTrue($map->entries[3]->isAnonymous()); + $this->assertFalse($map->entries[4]->isAnonymous()); // [heap] + $this->assertSame('[heap]', $map->entries[4]->name); + } + + public function testCaptureRule(): void + { + $map = MemoryMap::parse(self::SAMPLE); + // r--p file-backed: not captured unless full. + $this->assertFalse($map->entries[0]->shouldCapture(false)); + $this->assertTrue($map->entries[0]->shouldCapture(true)); + // r-xp file-backed: not captured unless full. + $this->assertFalse($map->entries[1]->shouldCapture(false)); + // rw-p file-backed: always captured. + $this->assertTrue($map->entries[2]->shouldCapture(false)); + // anonymous writable: captured. + $this->assertTrue($map->entries[3]->shouldCapture(false)); + // [heap]: captured. + $this->assertTrue($map->entries[4]->shouldCapture(false)); + // [vdso] kernel-special: never captured, even with full. + $this->assertFalse($map->entries[6]->shouldCapture(true)); + // [vsyscall] not readable + kernel-special: never captured. + $this->assertFalse($map->entries[7]->shouldCapture(true)); + } + + public function testModuleBaseIsLowestStart(): void + { + $map = MemoryMap::parse(self::SAMPLE); + // Three /usr/bin/php8.4 VMAs start at 0x5621774aa000, 0x56217759a000 + // and 0x562177a68000; the base is the lowest. + $this->assertSame((int)hexdec('5621774aa000'), $map->moduleBase('/usr/bin/php8.4')); + } + + public function testModulePathsAreDistinctFileBacked(): void + { + $map = MemoryMap::parse(self::SAMPLE); + $this->assertSame(['/usr/bin/php8.4'], $map->modulePaths()); + } +} diff --git a/tests/OomDumpHandlerTest.php b/tests/OomDumpHandlerTest.php new file mode 100644 index 0000000..b05a9fb --- /dev/null +++ b/tests/OomDumpHandlerTest.php @@ -0,0 +1,169 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Reliforp\PhpMemoryDump\Tests; + +use PHPUnit\Framework\TestCase; +use Reliforp\PhpMemoryDump\OomDumpHandler; + +final class OomDumpHandlerTest extends TestCase +{ + public function testIsMemoryLimitError(): void + { + $this->assertTrue(OomDumpHandler::isMemoryLimitError( + 'Allowed memory size of 33554432 bytes exhausted (tried to allocate 8192 bytes)', + )); + $this->assertFalse(OomDumpHandler::isMemoryLimitError('Uncaught TypeError')); + $this->assertFalse(OomDumpHandler::isMemoryLimitError('')); + } + + public function testExpandPathPidAndTime(): void + { + $expanded = OomDumpHandler::expandPath('/var/log/oom-%p.rdump'); + $this->assertSame('/var/log/oom-' . getmypid() . '.rdump', $expanded); + + $with_time = OomDumpHandler::expandPath('/d/%t.rdump'); + $this->assertMatchesRegularExpression('#^/d/\d+\.rdump$#', $with_time); + } + + public function testExpandPathLiteralPercentAndUnknown(): void + { + $this->assertSame('100%done', OomDumpHandler::expandPath('100%%done')); + // An unknown specifier is left verbatim. + $this->assertSame('/x/%z/y', OomDumpHandler::expandPath('/x/%z/y')); + } + + public function testExpandPathNoSpecifiers(): void + { + $this->assertSame('/plain/path.rdump', OomDumpHandler::expandPath('/plain/path.rdump')); + } + + /** + * End-to-end: a child process that exhausts memory_limit must leave a + * complete, non-truncated dump behind, written from the shutdown + * handler with the path template expanded. + */ + public function testHandlerWritesCompleteDumpOnOom(): void + { + if (PHP_OS_FAMILY !== 'Linux' || PHP_ZTS || PHP_INT_SIZE !== 8) { + $this->markTestSkipped('self-dump is Linux/NTS/64-bit only'); + } + if (!is_readable('/proc/self/mem')) { + $this->markTestSkipped('/proc/self/mem not readable in this environment'); + } + + $dir = sys_get_temp_dir() . '/pmd_oom_' . getmypid() . '_' . uniqid(); + mkdir($dir); + $template = $dir . '/oom-%p.rdump'; + + $fixture = __DIR__ . '/fixtures/oom_dump.php'; + $cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fixture) + . ' ' . escapeshellarg($template) . ' 2>/dev/null'; + $output = shell_exec($cmd) ?? ''; + + try { + $this->assertStringContainsString('DUMP_OK', $output, "fixture output: {$output}"); + + $files = glob($dir . '/oom-*.rdump') ?: []; + $this->assertCount(1, $files, 'exactly one dump file expected'); + $dump = $files[0]; + + // The %p in the template must have been replaced by a pid. + $this->assertMatchesRegularExpression('#/oom-\d+\.rdump$#', $dump); + + $this->assertDumpIsStructurallyComplete($dump); + } finally { + foreach (glob($dir . '/*') ?: [] as $f) { + @unlink($f); + } + @rmdir($dir); + } + } + + /** + * Walk the whole RDUMP file — header, every memory-map entry, and every + * region's address/size/data — and assert it ends exactly at EOF. A + * dump truncated by a second OOM mid-write (the failure mode of an + * undersized reserve) fails this walk. + */ + private function assertDumpIsStructurallyComplete(string $path): void + { + $fp = fopen($path, 'rb'); + $this->assertNotFalse($fp); + try { + $this->assertSame("RDUMP\0\0\0", fread($fp, 8)); + $this->assertSame(3, $this->u32($fp)); // format version + $this->readStr($fp); // php version + $this->u64($fp); // pid + $this->u64($fp); // eg + $this->u64($fp); // cg + fread($fp, 8); // rss + $mg = $this->u32($fp); + for ($i = 0; $i < $mg; $i++) { + $this->readStr($fp); + $this->u64($fp); + } + $map_count = $this->u32($fp); + $region_count = $this->u32($fp); + $this->assertGreaterThan(0, $region_count); + + for ($i = 0; $i < $map_count; $i++) { + $this->readStr($fp); // begin + $this->readStr($fp); // end + $this->readStr($fp); // offset + fread($fp, 4); // attrs + $this->readStr($fp); // dev + $this->u64($fp); // inode + $this->readStr($fp); // name + } + + $stat = fstat($fp); + $size = $stat['size']; + for ($i = 0; $i < $region_count; $i++) { + $this->u64($fp); // address + $region_size = $this->u64($fp); + $pos = ftell($fp); + $this->assertLessThanOrEqual( + $size, + $pos + $region_size, + "region {$i} data runs past EOF — dump is truncated", + ); + fseek($fp, $region_size, SEEK_CUR); + } + + // After the last region we must be exactly at EOF. + $this->assertSame($size, ftell($fp), 'trailing bytes or short final region'); + } finally { + fclose($fp); + } + } + + /** @param resource $fp */ + private function u32($fp): int + { + return unpack('V', fread($fp, 4))[1]; + } + + /** @param resource $fp */ + private function u64($fp): int + { + return unpack('P', fread($fp, 8))[1]; + } + + /** @param resource $fp */ + private function readStr($fp): string + { + $len = $this->u32($fp); + return $len === 0 ? '' : fread($fp, $len); + } +} diff --git a/tests/RdumpWriterTest.php b/tests/RdumpWriterTest.php new file mode 100644 index 0000000..d4a6672 --- /dev/null +++ b/tests/RdumpWriterTest.php @@ -0,0 +1,146 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Reliforp\PhpMemoryDump\Tests; + +use PHPUnit\Framework\TestCase; +use Reliforp\PhpMemoryDump\Internal\MemoryMap; +use Reliforp\PhpMemoryDump\RdumpWriter; + +/** + * Round-trips a header through the writer and a reader that mirrors + * reli's MemoryDumpReaderFactory::parse(), so the on-disk layout is + * pinned to the format reli expects. + */ +final class RdumpWriterTest extends TestCase +{ + public function testHeaderRoundTrip(): void + { + $map = MemoryMap::parse( + "562177aa4000-562177aa5000 r--p 00000000 fe:00 120348 /usr/bin/php8.4\n" + . "562177a6a000-562177a6b000 rw-p 00000000 00:00 0\n" + ); + + $fp = fopen('php://memory', 'w+b'); + $writer = new RdumpWriter($fp); + $writer->writeHeader( + pid: 4242, + php_version: 'v84', + eg_address: 0x562177a81c60, + cg_address: 0x562177a82420, + rss_bytes: 39198720, + module_globals: ['basic_globals' => 0x562177a6d580], + entries: $map->entries, + region_count: 1, + ); + + rewind($fp); + $parsed = self::parseHeader($fp); + fclose($fp); + + $this->assertSame("RDUMP\0\0\0", $parsed['magic']); + $this->assertSame(3, $parsed['format_version']); + $this->assertSame('v84', $parsed['php_version']); + $this->assertSame(4242, $parsed['pid']); + $this->assertSame(0x562177a81c60, $parsed['eg_address']); + $this->assertSame(0x562177a82420, $parsed['cg_address']); + $this->assertSame(39198720, $parsed['rss_bytes']); + $this->assertSame( + ['basic_globals' => 0x562177a6d580], + $parsed['module_globals'], + ); + $this->assertSame(2, $parsed['memory_map_count']); + $this->assertSame(1, $parsed['region_count']); + $this->assertCount(2, $parsed['memory_areas']); + $this->assertSame('/usr/bin/php8.4', $parsed['memory_areas'][0]['name']); + $this->assertSame([1, 0, 0, 1], $parsed['memory_areas'][0]['attrs']); + $this->assertSame([1, 1, 0, 1], $parsed['memory_areas'][1]['attrs']); + $this->assertSame('', $parsed['memory_areas'][1]['name']); + } + + public function testRssUnavailableEncodedAsMinusOne(): void + { + $fp = fopen('php://memory', 'w+b'); + (new RdumpWriter($fp))->writeHeader( + pid: 1, + php_version: 'v84', + eg_address: 0x1000, + cg_address: 0x2000, + rss_bytes: null, + module_globals: [], + entries: [], + region_count: 0, + ); + rewind($fp); + $parsed = self::parseHeader($fp); + fclose($fp); + $this->assertNull($parsed['rss_bytes']); + $this->assertSame([], $parsed['module_globals']); + } + + /** + * @param resource $fp + * @return array + */ + private static function parseHeader($fp): array + { + $magic = fread($fp, 8); + $format_version = unpack('V', fread($fp, 4))[1]; + $php_version = self::readString($fp); + $pid = unpack('P', fread($fp, 8))[1]; + $eg = unpack('P', fread($fp, 8))[1]; + $cg = unpack('P', fread($fp, 8))[1]; + $rss_raw = unpack('q', fread($fp, 8))[1]; + $module_globals = []; + $mg_count = unpack('V', fread($fp, 4))[1]; + for ($i = 0; $i < $mg_count; $i++) { + $key = self::readString($fp); + $module_globals[$key] = unpack('P', fread($fp, 8))[1]; + } + $map_count = unpack('V', fread($fp, 4))[1]; + $region_count = unpack('V', fread($fp, 4))[1]; + + $areas = []; + for ($i = 0; $i < $map_count; $i++) { + $begin = self::readString($fp); + $end = self::readString($fp); + $offset = self::readString($fp); + $attrs = array_values(unpack('C4', fread($fp, 4))); + $dev = self::readString($fp); + $inode = unpack('P', fread($fp, 8))[1]; + $name = self::readString($fp); + $areas[] = compact('begin', 'end', 'offset', 'attrs', 'dev', 'inode', 'name'); + } + + return [ + 'magic' => $magic, + 'format_version' => $format_version, + 'php_version' => $php_version, + 'pid' => $pid, + 'eg_address' => $eg, + 'cg_address' => $cg, + 'rss_bytes' => $rss_raw === -1 ? null : $rss_raw, + 'module_globals' => $module_globals, + 'memory_map_count' => $map_count, + 'region_count' => $region_count, + 'memory_areas' => $areas, + ]; + } + + /** @param resource $fp */ + private static function readString($fp): string + { + $len = unpack('V', fread($fp, 4))[1]; + return $len === 0 ? '' : fread($fp, $len); + } +} diff --git a/tests/SelfDumpIntegrationTest.php b/tests/SelfDumpIntegrationTest.php new file mode 100644 index 0000000..a678f3d --- /dev/null +++ b/tests/SelfDumpIntegrationTest.php @@ -0,0 +1,173 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Reliforp\PhpMemoryDump\Tests; + +use PHPUnit\Framework\TestCase; +use Reliforp\PhpMemoryDump\MemoryDumper; + +/** + * Drives a real self-dump on a supported host (Linux, NTS, 64-bit LE) + * and checks the produced file parses and is internally consistent. The + * authoritative cross-check — that reli can analyse the output — lives in + * the reli integration suite; here we keep to format/sanity assertions. + */ +final class SelfDumpIntegrationTest extends TestCase +{ + private string $path = ''; + + protected function setUp(): void + { + if (PHP_OS_FAMILY !== 'Linux') { + $this->markTestSkipped('self-dump is Linux-only'); + } + if (PHP_ZTS) { + $this->markTestSkipped('pure-PHP dumper is NTS-only'); + } + if (PHP_INT_SIZE !== 8) { + $this->markTestSkipped('64-bit only'); + } + if (!is_readable('/proc/self/mem')) { + $this->markTestSkipped('/proc/self/mem not readable in this environment'); + } + $this->path = tempnam(sys_get_temp_dir(), 'pmd_test_') . '.rdump'; + } + + protected function tearDown(): void + { + if ($this->path !== '' && file_exists($this->path)) { + @unlink($this->path); + } + } + + public function testProducesParsableDump(): void + { + // Allocate recognizable state so there is something to capture. + $retained = []; + for ($i = 0; $i < 200; $i++) { + $retained[] = new \ArrayObject(['i' => $i]); + } + + $result = (new MemoryDumper())->dump($this->path); + + $this->assertGreaterThan(0, $result->region_count); + $this->assertGreaterThan(0, $result->total_bytes); + $this->assertFileExists($this->path); + + $header = $this->parseHeaderAndAreas($this->path); + + $this->assertSame("RDUMP\0\0\0", $header['magic']); + $this->assertSame(3, $header['format_version']); + $this->assertSame('v' . PHP_MAJOR_VERSION . PHP_MINOR_VERSION, $header['php_version']); + $this->assertSame(getmypid(), $header['pid']); + $this->assertArrayHasKey('basic_globals', $header['module_globals']); + + // The engine globals must point inside a mapping that the dump + // actually captured, otherwise offline analysis can't read them. + $this->assertAddressIsCaptured($header['eg_address'], $header['areas']); + $this->assertAddressIsCaptured($header['cg_address'], $header['areas']); + $this->assertAddressIsCaptured($header['module_globals']['basic_globals'], $header['areas']); + + // keep $retained alive until after the dump + $this->assertCount(200, $retained); + } + + /** + * @param list $areas + */ + private function assertAddressIsCaptured(int $address, array $areas): void + { + foreach ($areas as $a) { + if ($address >= $a['start'] && $address < $a['end']) { + $this->assertTrue( + $a['capture'], + sprintf('address 0x%x falls in an un-captured region', $address), + ); + return; + } + } + $this->fail(sprintf('address 0x%x is not in any mapped region', $address)); + } + + /** + * @return array{ + * magic:string, format_version:int, php_version:string, pid:int, + * eg_address:int, cg_address:int, module_globals:array, + * areas: list + * } + */ + private function parseHeaderAndAreas(string $path): array + { + $fp = fopen($path, 'rb'); + $magic = fread($fp, 8); + $format_version = unpack('V', fread($fp, 4))[1]; + $php_version = $this->readString($fp); + $pid = unpack('P', fread($fp, 8))[1]; + $eg = unpack('P', fread($fp, 8))[1]; + $cg = unpack('P', fread($fp, 8))[1]; + fread($fp, 8); // rss + $mg = []; + $mgc = unpack('V', fread($fp, 4))[1]; + for ($i = 0; $i < $mgc; $i++) { + $k = $this->readString($fp); + $mg[$k] = unpack('P', fread($fp, 8))[1]; + } + $map_count = unpack('V', fread($fp, 4))[1]; + $region_count = unpack('V', fread($fp, 4))[1]; + + $areas = []; + for ($i = 0; $i < $map_count; $i++) { + $begin = $this->readString($fp); + $end = $this->readString($fp); + $this->readString($fp); // offset + $attrs = array_values(unpack('C4', fread($fp, 4))); + $this->readString($fp); // dev + fread($fp, 8); // inode + $name = $this->readString($fp); + $entry = new \Reliforp\PhpMemoryDump\Internal\MapEntry( + $begin, + $end, + ($attrs[0] ? 'r' : '-') . ($attrs[1] ? 'w' : '-') + . ($attrs[2] ? 'x' : '-') . ($attrs[3] ? 'p' : '-'), + '0', + '00:00', + 0, + $name, + ); + $areas[] = [ + 'start' => (int)hexdec($begin), + 'end' => (int)hexdec($end), + 'capture' => $entry->shouldCapture(false), + ]; + } + fclose($fp); + + return [ + 'magic' => $magic, + 'format_version' => $format_version, + 'php_version' => $php_version, + 'pid' => $pid, + 'eg_address' => $eg, + 'cg_address' => $cg, + 'module_globals' => $mg, + 'areas' => $areas, + ]; + } + + /** @param resource $fp */ + private function readString($fp): string + { + $len = unpack('V', fread($fp, 4))[1]; + return $len === 0 ? '' : fread($fp, $len); + } +} diff --git a/tests/fixtures/oom_dump.php b/tests/fixtures/oom_dump.php new file mode 100644 index 0000000..f487c68 --- /dev/null +++ b/tests/fixtures/oom_dump.php @@ -0,0 +1,39 @@ + + */ + +declare(strict_types=1); + +require dirname(__DIR__, 2) . '/vendor/autoload.php'; + +use Reliforp\PhpMemoryDump\OomDumpHandler; + +$path = $argv[1] ?? null; +if ($path === null) { + fwrite(STDERR, "usage: oom_dump.php \n"); + exit(2); +} + +ini_set('memory_limit', '48M'); + +OomDumpHandler::register( + path: $path, + on_dump: function ($result, $out_path): void { + // Signal success on stdout for the test harness to assert on. + fwrite(STDOUT, "DUMP_OK {$out_path} regions={$result->region_count} bytes={$result->total_bytes}\n"); + }, + on_error: function (\Throwable $e): void { + fwrite(STDOUT, 'DUMP_ERR ' . $e->getMessage() . "\n"); + }, +); + +// Burn through memory_limit. +$sink = []; +while (true) { + $sink[] = str_repeat('A', 8192); +} diff --git a/tools/rector/DowngradeConstVisibilityToPhp70Rector.php b/tools/rector/DowngradeConstVisibilityToPhp70Rector.php new file mode 100644 index 0000000..e626b12 --- /dev/null +++ b/tools/rector/DowngradeConstVisibilityToPhp70Rector.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Reliforp\PhpMemoryDump\Build\Rector; + +use PhpParser\Modifiers; +use PhpParser\Node; +use PhpParser\Node\Stmt\ClassConst; +use Rector\Rector\AbstractRector; +use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample; +use Symplify\RuleDocGenerator\ValueObject\RuleDefinition; + +/** + * Strip class constant visibility modifiers (PHP 7.1+). + * + * `private const X` / `protected const X` / `public const X` all become a bare + * `const X` for PHP 7.0, which historically only had implicitly-public class + * constants. Effective visibility loosens for private/protected constants, but + * that is a hidden access leak, not a runtime fault — and for an FFI-free + * embeddable library this trade-off is acceptable. + * + * Mirrors reli's Reli\Tools\Rector\DowngradeConstVisibilityToPhp70Rector. + */ +final class DowngradeConstVisibilityToPhp70Rector extends AbstractRector +{ + public function getRuleDefinition(): RuleDefinition + { + return new RuleDefinition( + 'Strip class constant visibility for PHP 7.0 compatibility', + [new CodeSample( + 'private const FOO = 1;', + 'const FOO = 1;', + )], + ); + } + + /** + * @return array> + */ + public function getNodeTypes(): array + { + return [ClassConst::class]; + } + + public function refactor(Node $node): ?Node + { + $visibilityFlags = Modifiers::PUBLIC | Modifiers::PROTECTED | Modifiers::PRIVATE; + if (($node->flags & $visibilityFlags) === 0) { + return null; + } + + $node->flags &= ~$visibilityFlags; + return $node; + } +} diff --git a/tools/rector/DowngradeNullableTypeToPhp70Rector.php b/tools/rector/DowngradeNullableTypeToPhp70Rector.php new file mode 100644 index 0000000..25269f6 --- /dev/null +++ b/tools/rector/DowngradeNullableTypeToPhp70Rector.php @@ -0,0 +1,117 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Reliforp\PhpMemoryDump\Build\Rector; + +use PhpParser\Node; +use PhpParser\Node\Expr\ConstFetch; +use PhpParser\Node\Expr\Closure; +use PhpParser\Node\Expr\ArrowFunction; +use PhpParser\Node\Name; +use PhpParser\Node\NullableType; +use PhpParser\Node\Param; +use PhpParser\Node\Stmt\ClassMethod; +use PhpParser\Node\Stmt\Function_; +use Rector\Rector\AbstractRector; +use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample; +use Symplify\RuleDocGenerator\ValueObject\RuleDefinition; + +/** + * Downgrade `?T` nullable type hints (PHP 7.1+) for PHP 7.0 compatibility. + * + * Parameters: drop the type entirely and ensure a `= null` default is set. + * + * ?T $x = null -> $x = null + * ?T $x -> $x = null + * ?T $x = 5 -> $x = 5 + * + * The naive translation `?T $x = null` -> `T $x = null` (implicit nullable) + * was deprecated in PHP 8.4 and is scheduled to become an error in PHP 9.0. + * Generating it would emit a fatal-future deprecation warning every time + * the class is autoloaded on PHP 8.4+, polluting end-user error logs. + * Stripping the type entirely keeps the code working on every supported + * PHP version (7.0 .. 8.5+) without runtime warnings; type information + * is preserved in the `@param` docblocks generated by Rector's existing + * downgrade rules. + * + * Return types: + * function foo(): ?T -> function foo() (PHP 7.0 has no nullable return form) + * + * The official Rector downgrade level set stops at PHP 7.1, so the 7.1 -> 7.0 + * gap is filled by this rule and its siblings. See rector.php. + * + * Mirrors reli's Reli\Tools\Rector\DowngradeNullableTypeToPhp70Rector so the + * two reliforp packages downgrade to the same 7.0 floor the same way. + */ +final class DowngradeNullableTypeToPhp70Rector extends AbstractRector +{ + public function getRuleDefinition(): RuleDefinition + { + return new RuleDefinition( + 'Strip ?T nullable types for PHP 7.0 compatibility (without triggering the PHP 8.4 implicit-nullable deprecation)', + [new CodeSample( + 'public function foo(?string $x): ?int { return null; }', + 'public function foo($x = null) { return null; }', + )], + ); + } + + /** + * @return array> + */ + public function getNodeTypes(): array + { + return [ + Param::class, + ClassMethod::class, + Function_::class, + Closure::class, + ArrowFunction::class, + ]; + } + + public function refactor(Node $node): ?Node + { + if ($node instanceof Param) { + return $this->refactorParam($node); + } + + return $this->refactorReturn($node); + } + + private function refactorParam(Param $param): ?Param + { + if (!$param->type instanceof NullableType) { + return null; + } + + $param->type = null; + if ($param->default === null) { + $param->default = new ConstFetch(new Name('null')); + } + return $param; + } + + /** + * @param ClassMethod|Function_|Closure|ArrowFunction $node + */ + private function refactorReturn(Node $node): ?Node + { + if (!$node->returnType instanceof NullableType) { + return null; + } + + $node->returnType = null; + return $node; + } +} diff --git a/tools/rector/DowngradeVoidReturnToPhp70Rector.php b/tools/rector/DowngradeVoidReturnToPhp70Rector.php new file mode 100644 index 0000000..fb1967d --- /dev/null +++ b/tools/rector/DowngradeVoidReturnToPhp70Rector.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Reliforp\PhpMemoryDump\Build\Rector; + +use PhpParser\Node; +use PhpParser\Node\Expr\ArrowFunction; +use PhpParser\Node\Expr\Closure; +use PhpParser\Node\Identifier; +use PhpParser\Node\Stmt\ClassMethod; +use PhpParser\Node\Stmt\Function_; +use Rector\Rector\AbstractRector; +use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample; +use Symplify\RuleDocGenerator\ValueObject\RuleDefinition; + +/** + * Drop `void` return type (PHP 7.1+). + * + * PHP 7.0 has no `void` keyword for return types. The body is unaffected; + * we just remove the declaration and rely on PHP's natural "no return" + * behavior (function returns null implicitly). + * + * Mirrors reli's Reli\Tools\Rector\DowngradeVoidReturnToPhp70Rector. + */ +final class DowngradeVoidReturnToPhp70Rector extends AbstractRector +{ + public function getRuleDefinition(): RuleDefinition + { + return new RuleDefinition( + 'Drop void return type for PHP 7.0 compatibility', + [new CodeSample( + 'public function foo(): void {}', + 'public function foo() {}', + )], + ); + } + + /** + * @return array> + */ + public function getNodeTypes(): array + { + return [ClassMethod::class, Function_::class, Closure::class, ArrowFunction::class]; + } + + public function refactor(Node $node): ?Node + { + $returnType = $node->returnType; + if (!$returnType instanceof Identifier) { + return null; + } + if ($returnType->toLowerString() !== 'void') { + return null; + } + + $node->returnType = null; + return $node; + } +} diff --git a/tools/smoke.php b/tools/smoke.php new file mode 100644 index 0000000..a4f5fb6 --- /dev/null +++ b/tools/smoke.php @@ -0,0 +1,66 @@ + $i)); +} +$GLOBALS['__smoke_retained'] = $retained; + +$path = tempnam(sys_get_temp_dir(), 'pmd_smoke_') . '.rdump'; +$result = (new Reliforp\PhpMemoryDump\MemoryDumper())->dump($path); + +$size = filesize($path); +@unlink($path); + +if ($result->region_count < 1 || $size < 64) { + fwrite(STDERR, sprintf( + "SMOKE FAIL: regions=%d size=%d\n", + $result->region_count, + $size + )); + exit(1); +} + +// Exercise the match->switch downgraded path too. +$expanded = Reliforp\PhpMemoryDump\OomDumpHandler::expandPath('/x-%p-%%'); +if (strpos($expanded, '%p') !== false || strpos($expanded, '%%') !== false) { + fwrite(STDERR, "SMOKE FAIL: expandPath did not expand: {$expanded}\n"); + exit(1); +} + +printf( + "SMOKE OK on PHP %s: regions=%d, bytes=%d, expandPath=%s\n", + PHP_VERSION, + $result->region_count, + $result->total_bytes, + $expanded +);