Skip to content

Commit cdf2561

Browse files
authored
Merge pull request #1 from reliforp/claude/sharp-bardeen-j35RZ
ext-rdump: in-process RDUMP memory dumps for reli
2 parents d32b5c9 + 312447e commit cdf2561

17 files changed

Lines changed: 2371 additions & 0 deletions

.github/workflows/build.yml

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
name: build
2+
3+
on:
4+
push:
5+
pull_request:
6+
workflow_dispatch:
7+
8+
concurrency:
9+
group: build-${{ github.ref }}
10+
cancel-in-progress: true
11+
12+
jobs:
13+
build:
14+
name: PHP ${{ matrix.php }} ${{ matrix.ts == 'zts' && 'ZTS' || 'NTS' }}
15+
runs-on: ubuntu-latest
16+
strategy:
17+
fail-fast: false
18+
matrix:
19+
# reli supports target PHP 7.0 .. 8.5; cover the same range in
20+
# both thread-safety builds.
21+
php:
22+
- '7.0'
23+
- '7.1'
24+
- '7.2'
25+
- '7.3'
26+
- '7.4'
27+
- '8.0'
28+
- '8.1'
29+
- '8.2'
30+
- '8.3'
31+
- '8.4'
32+
- '8.5'
33+
ts:
34+
- 'cli' # NTS
35+
- 'zts' # ZTS
36+
steps:
37+
# Checkout runs on the host runner (modern glibc/node), then the
38+
# build happens inside the target container with the tree mounted.
39+
# This sidesteps node20-based actions failing on the old glibc of
40+
# the 7.0-7.4 images.
41+
- uses: actions/checkout@v5
42+
43+
- name: Build and test (php:${{ matrix.php }}-${{ matrix.ts }})
44+
run: |
45+
docker run --rm \
46+
-e CI=1 \
47+
-v "$PWD":/ext -w /ext \
48+
"php:${{ matrix.php }}-${{ matrix.ts }}" \
49+
sh ci/build-and-test.sh
50+
51+
reli-integration:
52+
name: reli reads an ext-rdump dump (PHP 8.4)
53+
runs-on: ubuntu-latest
54+
steps:
55+
- uses: actions/checkout@v5
56+
with:
57+
path: ext
58+
59+
- uses: actions/checkout@v5
60+
with:
61+
repository: reliforp/reli-prof
62+
path: reli
63+
64+
- name: Dump with ext-rdump, then read it with reli
65+
run: |
66+
docker run --rm \
67+
-e CI=1 \
68+
-v "$PWD/ext":/ext -v "$PWD/reli":/reli -w /ext \
69+
php:8.4-cli \
70+
sh ci/reli-integration.sh
71+
72+
leak-check:
73+
name: Leak check PHP 8.4 ${{ matrix.ts == 'zts' && 'ZTS' || 'NTS' }}
74+
runs-on: ubuntu-latest
75+
strategy:
76+
fail-fast: false
77+
matrix:
78+
ts:
79+
- 'cli' # NTS
80+
- 'zts' # ZTS
81+
steps:
82+
- uses: actions/checkout@v5
83+
84+
- name: Valgrind leak check (php:8.4-${{ matrix.ts }})
85+
run: |
86+
docker run --rm \
87+
-v "$PWD":/ext -w /ext \
88+
"php:8.4-${{ matrix.ts }}" \
89+
sh ci/leak-check.sh

.gitignore

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
*.o
2+
*.lo
3+
*.la
4+
*.dep
5+
*~
6+
.libs/
7+
.deps/
8+
modules/
9+
build/
10+
autom4te.cache/
11+
config.h
12+
config.h.in
13+
config.log
14+
config.status
15+
config.nice
16+
configure
17+
configure.ac
18+
configure.in
19+
libtool
20+
ltmain.sh
21+
Makefile
22+
Makefile.fragments
23+
Makefile.global
24+
Makefile.objects
25+
install-sh
26+
missing
27+
mkinstalldirs
28+
run-tests.php
29+
*.tar.gz
30+
acinclude.m4
31+
aclocal.m4
32+
tmp-php.ini
33+
tests/*.diff
34+
tests/*.out
35+
tests/*.exp
36+
tests/*.log
37+
tests/*.php
38+
tests/*.sh

README.md

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
# ext-rdump
2+
3+
[![build](https://github.com/reliforp/ext-rdump/actions/workflows/build.yml/badge.svg)](https://github.com/reliforp/ext-rdump/actions/workflows/build.yml)
4+
5+
Take a memory dump of the **current PHP process** at any point in time, for later
6+
analysis with [reli](https://github.com/reliforp/reli-prof).
7+
8+
One call writes a snapshot of your process's memory to a file:
9+
10+
```php
11+
rdump_dump('/tmp/app.rdump');
12+
```
13+
14+
[Feed that file to reli](https://github.com/reliforp/reli-prof/blob/HEAD/docs/memory/memory-dump.md)
15+
and you can:
16+
17+
- see the **call stack** at the moment of the dump,
18+
- find out **what objects/strings/arrays exist, how much memory each uses, and
19+
how they reference each other**,
20+
- help investigate **memory leaks, resource leaks, circular references, and
21+
memory bottlenecks**.
22+
23+
`reli` can do all this by attaching from outside, but that needs `ptrace`.
24+
`ext-rdump` dumps from *inside* the process instead, without needing ptrace.
25+
26+
## Requirements
27+
28+
- 64-bit little-endian Linux (x86-64 / arm64), PHP **7.0 – 8.5** (NTS and ZTS)
29+
- `reli` (only on the machine where you *analyse* the dump)
30+
31+
## Install
32+
33+
From source:
34+
35+
```bash
36+
phpize && ./configure --enable-rdump && make && sudo make install
37+
# then add to php.ini:
38+
# extension=rdump.so
39+
```
40+
41+
Or via [PIE](https://github.com/php/pie) / PECL:
42+
43+
```bash
44+
pie install reliforp/ext-rdump
45+
# or
46+
pecl install package.xml # from a checkout
47+
```
48+
49+
## Usage
50+
51+
```php
52+
bool rdump_dump(string $path, bool $full = false)
53+
```
54+
55+
Writes the dump to `$path`; returns `false` (with an `E_WARNING`) on failure.
56+
Pass `$full = true` for a self-contained dump you can analyse on a host without
57+
the original binaries. It embeds **every readable mapping** (read-only
58+
file-backed segments too, not just code), so the dump is noticeably larger.
59+
60+
### On demand from a signal handler
61+
62+
```php
63+
// kill -USR2 <pid>
64+
pcntl_async_signals(true);
65+
pcntl_signal(SIGUSR2, function () { rdump_dump('/tmp/sig.rdump'); });
66+
```
67+
68+
### Capturing the moment of `memory_limit` death
69+
70+
To capture the moment of `memory_limit` exhaustion, enable the auto-dump in
71+
php.ini:
72+
73+
```ini
74+
extension=rdump.so
75+
rdump.oom_dump=/var/log/php-oom-%p.rdump ; empty = disabled
76+
;rdump.oom_dump_full=1 ; also embed read-only segments
77+
;rdump.oom_dump_max=1 ; max auto-dumps per worker (0 = unlimited)
78+
;rdump.oom_dump_min_interval=0 ; min seconds between auto-dumps (0 = off)
79+
;rdump.oom_dump_max_total=2G ; skip once *.rdump in the dir would exceed this (0 = off)
80+
;rdump.oom_dump_marker=1 ; also write "<path>.done" when a dump completes
81+
;rdump.safe_read=1 ; read via /proc/self/mem (crash-safe; default on under ZTS)
82+
```
83+
84+
The path may contain `%p` (PID), `%i` (thread id), and `%t` (Unix time),
85+
expanded when the dump fires; `%%` is a literal `%`. Under PHP-FPM give the
86+
template a `%p` so workers write distinct files instead of racing to overwrite
87+
one path; under ZTS (FrankenPHP) add `%i` so threads in one process don't
88+
collide either. (This expansion applies only to the OOM auto-dump path:
89+
both the `rdump.oom_dump` INI value and a `rdump_set_oom_dump()` override. It
90+
does not apply to the literal `$path` you pass to `rdump_dump()`.)
91+
92+
#### Limiting the auto-dump
93+
94+
When application code or data keeps exhausting `memory_limit`, a worker can OOM
95+
request after request, so an unguarded auto-dump could write a fresh (often
96+
100 MB+) file every time and fill the disk. Three INI guards, all applied together:
97+
98+
```ini
99+
rdump.oom_dump_max=1 ; max dumps per worker process (default 1; 0 = unlimited)
100+
rdump.oom_dump_min_interval=0 ; min seconds between dumps (default 0 = off)
101+
rdump.oom_dump_max_total=0 ; skip once *.rdump in the dump dir would exceed this (K/M/G/T, e.g. 1.5G; 0 = off)
102+
```
103+
104+
`oom_dump_max` is a per-worker count (default 1 = one dump per worker).
105+
`oom_dump_max_total` bounds the *combined* footprint across workers; it counts
106+
every `*.rdump` in the directory plus the incoming dump's minimum size. Both are
107+
best-effort: concurrent workers can still overshoot together.
108+
109+
These guards apply only to the automatic OOM dump; explicit `rdump_dump()` calls
110+
are never throttled. Give the path a `%p` so each worker writes a distinct file.
111+
112+
#### Shipping dumps with a watcher
113+
114+
Set `rdump.oom_dump_marker=1` and the extension drops an empty `<path>.done`
115+
once a dump is fully written. A watcher waits for `oom-1234.rdump.done` before
116+
shipping `oom-1234.rdump`, so it won't start on a half-written file. Give each
117+
dump a unique path (`%p`, plus `%t` for repeats) so a later dump can't truncate
118+
one while it is being shipped.
119+
120+
#### Setting the path at runtime
121+
122+
For a per-request or per-PID filename, or when you cannot edit php.ini, set the
123+
auto-dump from code instead:
124+
125+
```php
126+
rdump_set_oom_dump("/var/log/oom-" . getmypid() . ".rdump");
127+
// rdump_set_oom_dump("/var/log/oom.rdump", true); // full dump
128+
// rdump_set_oom_dump(""); // force off for this request, even if the INI set a path
129+
// rdump_set_oom_dump(null); // clear the override, fall back to rdump.oom_dump
130+
```
131+
132+
The runtime setting applies to the current request and takes precedence over
133+
the `rdump.oom_dump` default. It returns `true` on success, or `false` (with an
134+
`E_WARNING`) if the path could not be stored, leaving the previous default in
135+
effect rather than silently disabling the dump.
136+
137+
## Analyse with reli
138+
139+
Reading the dump is reli's job; its
140+
[memory-dump docs](https://github.com/reliforp/reli-prof/blob/HEAD/docs/memory/memory-dump.md)
141+
have the full story. The easiest way to run it is reli's Docker image, with no
142+
local PHP 8.4 / FFI setup; the one-liner below installs a `reli` shell function
143+
backed by it (see reli's
144+
[getting-started guide](https://github.com/reliforp/reli-prof/blob/HEAD/docs/getting-started.md)
145+
to persist it across shells):
146+
147+
```bash
148+
# one-time: install a `reli` command backed by the Docker image
149+
eval "$(docker run --rm --pull=always reliforp/reli-prof docker:print-wrapper)"
150+
151+
# analyse into reli's .rmem graph (feeds rmem:viz / rmem:explore)
152+
reli inspector:memory:analyze app.rdump -f rmem -o app.rmem
153+
154+
# report from the .rmem: type breakdown, retention, leaks, cycles, findings
155+
reli inspector:memory:report app.rmem
156+
```
157+
158+
The Docker wrapper only bind-mounts your current directory, so run it from where
159+
the dump lives. A path outside it, including a `--dependency-root`, needs a bind
160+
mount via `RELI_DOCKER_EXTRA_ARGS='-v /path:/path'` (see
161+
[docker-wrapper.md](https://github.com/reliforp/reli-prof/blob/HEAD/docs/docker-wrapper.md)).
162+
163+
Analysing on a different host? Either use `$full = true` at capture time, or
164+
point reli at a copy of the target's filesystem with `--dependency-root=/path`.
165+
166+
## Security
167+
168+
**A dump is a verbatim copy of your process's memory, so treat the file as highly
169+
sensitive.** It can contain anything that was resident at the moment of capture:
170+
environment variables, database and API credentials, session tokens, decrypted
171+
request and response bodies, private keys, other users' data on a shared
172+
process, and so on.
173+
174+
- The dump is created `0600` (owner-only) with `O_EXCL` / `O_NOFOLLOW` /
175+
`O_CLOEXEC`. An existing regular file is unlinked and replaced by a fresh
176+
`0600` inode (anything that isn't a regular file is refused), so a stale read
177+
fd can't see the new dump. Write it somewhere only the intended user can read,
178+
not a world-readable temp dir or a webroot.
179+
- Delete it once you have finished analysing, and scrub it from any backups or
180+
log shipping. Treat it like a credential dump, because it can be one.
181+
- Move dumps to the analysis host over a secure channel; do not email them or
182+
drop them in shared buckets unencrypted.
183+
184+
## Notes
185+
186+
- Linux only (the dump is built from `/proc/self/maps`).
187+
- The snapshot is taken synchronously in the calling thread, not stop-the-world.
188+
On a busy ZTS process (e.g. FrankenPHP worker threads) other threads may change
189+
memory while it is written, so the dump can be internally inconsistent. In the
190+
worst case a region is `munmap`/`mprotect`-ed by another thread before its bytes
191+
are copied, which can **crash** the process mid-dump. To avoid that,
192+
`rdump.safe_read` reads regions through `/proc/self/mem`, so a concurrent unmap
193+
yields zero-filled bytes instead of a crash (it can't fix the inconsistency,
194+
only the crash). It defaults on under ZTS (where the race exists) and off under
195+
NTS (no other PHP thread runs during the dump, and the direct copy is faster);
196+
override it either way. Also give concurrent dumps distinct paths with
197+
`%i` (thread id), e.g. `rdump.oom_dump=/var/log/oom-%p-%i.rdump`, so threads in
198+
one process don't collide on a file.
199+
- The output is byte-compatible with reli's RDUMP format (the same file
200+
`reli inspector:memory:dump` produces).
201+
- **No steady-state overhead.** The extension installs no executor or allocator
202+
hooks, so normal execution runs at full speed. The only always-on hook is on
203+
`zend_error_cb` (the error path), where it adds a couple of comparisons and
204+
one chained call, only when an error is actually emitted. Cost is incurred
205+
only when you call `rdump_dump()` or an OOM dump fires; safe to keep resident.
206+
- Plays nice with other `zend_error_cb` users (e.g. php-memory-profiler): the
207+
hook always chains to the previous handler, so every extension still fires and
208+
writes its own dump on OOM.
209+
210+
## License
211+
212+
MIT. See [LICENSE](LICENSE).

0 commit comments

Comments
 (0)