Skip to content

Add pure-PHP memory dumper for reli analysis#1

Merged
sj-i merged 5 commits into
mainfrom
claude/laughing-keller-gPyDX
Jul 10, 2026
Merged

Add pure-PHP memory dumper for reli analysis#1
sj-i merged 5 commits into
mainfrom
claude/laughing-keller-gPyDX

Conversation

@sj-i

@sj-i sj-i commented Jun 3, 2026

Copy link
Copy Markdown
Member

Summary

This PR introduces a complete pure-PHP memory dumping library that captures a PHP process's own memory and writes it in reli's RDUMP format for offline analysis. The implementation requires no C extensions or FFI, making it accessible to any PHP environment.

Key Changes

  • Core dumping functionality (MemoryDumper): Main entry point that orchestrates the dump process by freezing the memory map, resolving engine globals, and streaming regions to disk
  • ELF symbol resolution (ElfImage): Minimal 64-bit little-endian ELF reader that parses the PHP binary's symbol table to locate executor_globals, compiler_globals, and basic_globals without loading the entire binary into memory
  • Memory map parsing (MemoryMap, MapEntry): Reads and parses /proc/self/maps to understand the process's virtual address space layout
  • Region reading (RegionReader): Safely reads process memory through /proc/self/mem with page-level error handling and zero-filling for unreadable regions
  • RDUMP format writer (RdumpWriter): Streams output in reli's RDUMP format (magic "RDUMP", version 3) compatible with existing reli analysis tools
  • OOM handler (OomDumpHandler): Optional shutdown handler that captures a dump when memory_limit is exhausted, with configurable emergency reserves and path templating
  • Rector downgrade rules: Custom rules to downgrade modern PHP syntax (nullable types, void returns, class constant visibility) to PHP 7.0 for broad compatibility

Notable Implementation Details

  • Lazy ELF parsing: Only reads necessary ELF headers and symbol tables on-demand, avoiding the multi-megabyte overhead of loading the entire PHP binary
  • Unbuffered procfs I/O: Uses fseek/fread on /proc/self/mem with explicit buffering control to reliably read arbitrary virtual addresses
  • Minimal heap perturbation: Freezes the memory map before allocating output buffers and streams regions directly to disk to keep the snapshot window small
  • Stripped binary support: Falls back to .dynsym when .symtab is unavailable (as in Debian/Ubuntu distro builds) by walking PT_DYNAMIC and sizing via DT_GNU_HASH/DT_HASH
  • Cross-version compatibility: Modern source (PHP 8.1+) is automatically downgraded to PHP 7.0 syntax via Rector for installation on legacy PHP versions
  • Comprehensive testing: Integration tests verify dump validity, format compliance, and cross-version analysis with reli

Platform Support

  • Linux only (requires /proc/self/maps and /proc/self/mem)
  • NTS builds only (ZTS engine globals are in thread-local storage)
  • 64-bit little-endian only
  • PHP 7.0+ (after downgrade)

https://claude.ai/code/session_012vbYstASQ1Um7Xns7KqRMF

sj-i added 5 commits June 2, 2026 00:31
Implement php-memory-dump: capture the current PHP process's own memory
in reli's RDUMP v3 format from inside the process, in pure PHP with no
extension and no FFI.

The hard part ext-rdump gets for free from the linker — the runtime
addresses of executor_globals / compiler_globals / basic_globals — is
recovered here the same way reli does when attaching from outside: parse
the PHP binary's ELF symbol table (.symtab / .dynsym; stock distro builds
export the three engine globals in .dynsym even when stripped) and add the
load bias from /proc/self/maps. Region bytes are streamed out of
/proc/self/mem via fseek/fread, which reaches the full 64-bit user address
range and zero-fills any unreadable page instead of faulting.

The memory map is frozen before any output buffer is allocated and regions
are streamed rather than accumulated, keeping the (non-stop-the-world)
perturbation window small. Capture rule mirrors ext-rdump: every readable
volatile mapping, plus read-only file-backed segments under full mode.

Verified end to end: reli inspector:memory:analyze / :report on a PHP 8.4
NTS x86-64 self-dump reconstructs the heap, object inventory, and the call
stack at the moment of capture.

NTS only (ZTS globals live in TLS, out of a pure-PHP reader's reach);
64-bit little-endian Linux only.

https://claude.ai/code/session_012vbYstASQ1Um7Xns7KqRMF
ElfImage slurped the whole PHP binary (~6 MB) via file_get_contents just to
read its symbol table, which dominated the dumper's footprint and needlessly
churned the heap being snapshotted: the measured peak added by dump() was
~6.3 MiB (emalloc) / 5.9 MiB real, essentially the binary size.

Read lazily through an open handle instead — only the ELF/program/section
headers and the chosen .dynsym/.dynstr (or PT_DYNAMIC) slices, a few hundred
KiB total — and stream regions through a 256 KiB buffer (down from 1 MiB).
Peak added by dump() drops to ~0.6-1 MiB emalloc with no new 2 MiB ZendMM
chunk forced, independent of heap/output size.

reli analyze/report on the resulting dump is unchanged (call stack and object
inventory still reconstructed); unit suite still green.

https://claude.ai/code/session_012vbYstASQ1Um7Xns7KqRMF
Public API mirroring reli's sidecar client MemoryLimitHandler::register(),
but doing the dump in-process rather than over a socket to a daemon. One
call installs a shutdown handler that snapshots the process the moment it
dies of memory_limit exhaustion:

    OomDumpHandler::register('/var/log/php-oom-%p-%t.rdump');

Surviving the OOM in-process is the hard part, so two levers are on by
default: it raises memory_limit before dumping (the deterministic lever —
memory_limit is enforced on ZendMM real usage, and the process is exiting
anyway), and it frees an emergency reserve first thing in the handler. The
reserve defaults to 4 MiB because freeing a sub-2 MiB block returns it to a
chunk free-list without unmapping it, recovering zero memory_limit headroom
(a 1 MiB reserve yields a truncated, unusable dump). Path templating (%p,
%t, %%) lets one registration give each worker a distinct file; classes are
pre-loaded so autoload never runs inside the exhausted handler.

Best-effort by nature: an OOM on a VM-stack page push never lets the handler
body run at all (that case needs ext-rdump's C zend_error_cb hook).

Verified end to end: a child that exhausts a 48M limit leaves a complete,
non-truncated dump that reli analyzes, with the report flagging the very
array that overflowed the limit as the dominant retained branch. Unit suite
covers path expansion, the memory-limit predicate, and a subprocess OOM
that asserts the written dump walks cleanly to EOF.

https://claude.ai/code/session_012vbYstASQ1Um7Xns7KqRMF
Lower the floor to PHP 7.0 (matching ext-rdump's 7.0-8.5 reach) the same way
reli's sidecar client does: develop in modern PHP (8.1+), ship a downgraded
artifact.

- composer "php": ">=7.0"; "composer downgrade" copies src/ to build/php70/src
  and rewrites it with Rector.
- Rector's level set only reaches 7.1, so three custom rules under tools/rector
  fill the 7.1->7.0 gap (lifted from reli's Downgrade*ToPhp70Rector): strip
  nullable type hints, void return types, and class-const visibility. A sed
  pass handles 0o octal literals.
- MemoryDumper no longer uses PHP_OS_FAMILY (7.2+); it checks PHP_OS so the
  platform guard itself runs on the 7.0 floor.
- tools/smoke.php loads an arbitrary src tree and takes a dump, used by CI to
  exercise the downgraded build on PHP 7.0/7.1/7.2/7.4/8.0 (where Rector and
  PHPUnit can't run).
- CI: modern unit tests on 8.1-8.4; build the downgrade on 8.4 and assert no
  7.1+ syntax survives + lint; legacy smoke matrix on the artifact.

Verified: the downgraded build is 7.0-syntax-clean (no nullable/void/const-
visibility/0o), lints clean, and runs end to end — produces a valid dump reli
analyzes, with match->switch and str_contains->strpos paths exercised.

https://claude.ai/code/session_012vbYstASQ1Um7Xns7KqRMF
Add reli-cross-version.yml, the pure-PHP analogue of ext-rdump's same-named
workflow, proving the full producer->analyzer contract across PHP versions:

- build: downgrade the modern source to PHP 7.0 once on 8.4.
- make-dumps: load that build on every php:7.0-cli .. php:8.5-cli image and
  write a self-contained (full=true) dump via ci/reli-make-dump.sh — no
  compile step, just autoload the downgraded tree and call dump().
- analyze: set reli up once on PHP 8.4 and analyse every dump via
  ci/reli-analyze.sh, asserting RDUMP magic, format version 3, the recorded
  origin version tag, and that inspector:memory:analyze produces a report.
  The script is ext-rdump's reli-analyze.sh almost verbatim — the RDUMP format
  is identical regardless of producer, so the analyzer side is shared.
- zts-rejection: assert the NTS-only contract fails fast on php:*-zts (no
  bogus dump written).

Verified locally (Docker Hub is unreachable in this sandbox, so the old-PHP
runtimes run in GH Actions): ci/reli-make-dump.sh against the downgraded build
writes a 130 MB self-contained dump, and reli-analyze.sh's exact assertions all
pass on it via host reli (magic, format 3, version tag, "Memory Analysis
Report").

https://claude.ai/code/session_012vbYstASQ1Um7Xns7KqRMF
@sj-i
sj-i merged commit b0cb6e1 into main Jul 10, 2026
64 of 65 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant