Skip to content

Commit 63e5213

Browse files
committed
Add a total-size budget for OOM auto-dumps across the dump directory
The per-worker count cap doesn't bound the fleet's combined disk use: N workers writing one large dump each can still fill the disk. Add rdump.oom_dump_max_total (bytes, K/M/G suffixes; 0 = off). Before each auto dump the extension sums the *.rdump files already in the target directory (excluding the file an overwrite would replace) and skips if they meet the budget. It is a soft, best-effort limit: concurrent workers can each pass the check and overshoot, and counts every *.rdump in the directory. Tested in CI via a single CLI OOM with a pre-placed filler above/below the budget. https://claude.ai/code/session_017skw8BsHkF8wur7Pf4G9W5
1 parent c583a66 commit 63e5213

4 files changed

Lines changed: 164 additions & 10 deletions

File tree

README.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,25 @@ rdump.oom_dump_max=5 ; up to 5 dumps over the worker's lifetime (0 =
129129
rdump.oom_dump_min_interval=60 ; ...and at least 60s apart
130130
```
131131

132+
The per-worker count still leaves the fleet total unbounded — 200 workers writing
133+
one 130 MB dump each is 26 GB. To cap the *combined* footprint, set a byte budget
134+
(accepts `K`/`M`/`G` suffixes); before each auto-dump the extension sums the
135+
`*.rdump` files already in the dump's directory and skips if they meet the budget:
136+
137+
```ini
138+
rdump.oom_dump_max_total=2G ; skip the auto-dump once *.rdump there totals >= 2G (0 = off)
139+
```
140+
141+
Two caveats on the budget: it is a *soft* limit — concurrent workers can each pass
142+
the check and overshoot, and the dump that crosses the line is still written (so
143+
the real ceiling is roughly budget + one round of dumps). And it counts **every**
144+
`*.rdump` in that directory, so give OOM dumps a directory of their own if other
145+
`.rdump` files live alongside.
146+
132147
A dump attempt counts toward the cap whether it succeeds or not, so a bad path or
133-
a full disk can't retry forever either. This guard applies only to the automatic
134-
OOM dump — explicit `rdump_dump()` calls are never throttled. (Combine the cap
135-
with a `%p` path so each worker still writes a distinct file.)
148+
a full disk can't retry forever either. These guards apply only to the automatic
149+
OOM dump — explicit `rdump_dump()` calls are never throttled. (Combine them with a
150+
`%p` path so each worker still writes a distinct file.)
136151

137152
You can also toggle it at runtime — handy for a per-request/per-pid filename,
138153
or when you cannot edit php.ini:

ci/build-and-test.sh

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,37 @@ if [ ! -s "$CAP_DUMP" ]; then
189189
fi
190190
echo "oom-cap (unlimited) OK"
191191
rm -f "$CAP_DUMP"
192+
193+
# --- OOM auto-dump total-size budget --------------------------------
194+
# Unlike the per-worker count cap, the byte budget bounds the *combined*
195+
# footprint of every *.rdump in the dump directory, so a fleet of workers
196+
# cannot fill the disk one dump each. The directory is read at dump time, so
197+
# a single CLI OOM is enough to test it: pre-fill above the budget and the
198+
# dump must be skipped; drop the filler and it must be written.
199+
BUDGET_DIR=/tmp/rdump-budget
200+
rm -rf "$BUDGET_DIR"; mkdir -p "$BUDGET_DIR"
201+
dd if=/dev/zero of="$BUDGET_DIR/filler.rdump" bs=1024 count=2048 >/dev/null 2>&1 # 2 MiB
202+
203+
# Over budget (2 MiB sibling, 1M cap): the OOM dump must be skipped.
204+
php -d extension="$SO" -d memory_limit=8M \
205+
-d rdump.oom_dump="$BUDGET_DIR/oom-%p.rdump" \
206+
-d rdump.oom_dump_max_total=1M \
207+
-r 'function r(){ r(); } r();' >/dev/null 2>&1 || true
208+
if ls "$BUDGET_DIR"/oom-*.rdump >/dev/null 2>&1; then
209+
echo "::error::rdump.oom_dump_max_total did not cap when over budget"
210+
exit 1
211+
fi
212+
echo "oom-budget (over) OK"
213+
214+
# Under budget (filler removed): the dump must now be written.
215+
rm -f "$BUDGET_DIR/filler.rdump"
216+
php -d extension="$SO" -d memory_limit=8M \
217+
-d rdump.oom_dump="$BUDGET_DIR/oom-%p.rdump" \
218+
-d rdump.oom_dump_max_total=1M \
219+
-r 'function r(){ r(); } r();' >/dev/null 2>&1 || true
220+
if ! ls "$BUDGET_DIR"/oom-*.rdump >/dev/null 2>&1; then
221+
echo "::error::rdump.oom_dump_max_total suppressed a dump while under budget"
222+
exit 1
223+
fi
224+
echo "oom-budget (under) OK"
225+
rm -rf "$BUDGET_DIR"

php_rdump.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ ZEND_BEGIN_MODULE_GLOBALS(rdump)
3232
* count/last_ts are process- (per-thread-) scoped and survive RSHUTDOWN. */
3333
zend_long oom_dump_max; /* rdump.oom_dump_max (INI): 0 = unlimited */
3434
zend_long oom_dump_min_interval; /* rdump.oom_dump_min_interval (INI), seconds; 0 = off */
35+
zend_long oom_dump_max_total; /* rdump.oom_dump_max_total (INI), bytes; 0 = off.
36+
* Budget across all *.rdump in the dump dir,
37+
* so many workers can't fill the disk together. */
3538
zend_long oom_dump_count; /* OOM dumps attempted so far this process */
3639
zend_long oom_dump_last_ts; /* time() of the last OOM dump attempt, 0 = none */
3740
/* Runtime override set via rdump_set_oom_dump(). Owned by us (libc

rdump.c

Lines changed: 109 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
#include <errno.h>
3636
#include <time.h>
3737
#include <sys/stat.h>
38+
#include <dirent.h>
3839

3940
ZEND_DECLARE_MODULE_GLOBALS(rdump)
4041

@@ -310,6 +311,68 @@ static int64_t rdump_rss_bytes(void)
310311
return (int64_t)(resident * (unsigned long long)page);
311312
}
312313

314+
/* Parse a byte quantity like "500", "16K", "256M", "2G" (binary multiples,
315+
* case-insensitive suffix) into bytes. Negatives / garbage -> 0. */
316+
static zend_long rdump_parse_bytes(const char *s)
317+
{
318+
if (s == NULL) {
319+
return 0;
320+
}
321+
while (*s == ' ' || *s == '\t') {
322+
s++;
323+
}
324+
char *end = NULL;
325+
long long v = strtoll(s, &end, 10);
326+
if (end == s || v < 0) {
327+
return 0;
328+
}
329+
while (*end == ' ' || *end == '\t') {
330+
end++;
331+
}
332+
switch (*end) {
333+
case 'k': case 'K': v *= 1024LL; break;
334+
case 'm': case 'M': v *= 1024LL * 1024; break;
335+
case 'g': case 'G': v *= 1024LL * 1024 * 1024; break;
336+
case 't': case 'T': v *= 1024LL * 1024 * 1024 * 1024; break;
337+
default: break;
338+
}
339+
return v < 0 ? 0 : (zend_long)v;
340+
}
341+
342+
/* Sum the sizes of every *.rdump file in dir, skipping except_base (the file
343+
* an overwrite would replace, so it must not count toward the new total).
344+
* Best-effort: unreadable entries are ignored. Used to enforce a disk budget
345+
* shared across all workers writing OOM dumps into the same directory. */
346+
static uint64_t rdump_dir_rdump_total(const char *dir, const char *except_base)
347+
{
348+
DIR *d = opendir(dir);
349+
if (!d) {
350+
return 0;
351+
}
352+
uint64_t total = 0;
353+
struct dirent *ent;
354+
while ((ent = readdir(d)) != NULL) {
355+
const char *n = ent->d_name;
356+
size_t ln = strlen(n);
357+
if (ln < 6 || strcmp(n + ln - 6, ".rdump") != 0) {
358+
continue;
359+
}
360+
if (except_base != NULL && strcmp(n, except_base) == 0) {
361+
continue;
362+
}
363+
char full[4096];
364+
if ((size_t)snprintf(full, sizeof(full), "%s/%s", dir, n) >= sizeof(full)) {
365+
continue;
366+
}
367+
struct stat st;
368+
if (stat(full, &st) == 0 && S_ISREG(st.st_mode)) {
369+
total += (uint64_t)st.st_size;
370+
}
371+
}
372+
closedir(d);
373+
return total;
374+
}
375+
313376
/* ------------------------------------------------------------------ */
314377
/* Dump writer. */
315378
/* ------------------------------------------------------------------ */
@@ -588,22 +651,48 @@ static void rdump_zend_error_cb(RDUMP_ERROR_CB_PARAMS)
588651
* every request, so cap the auto-dump over the worker's lifetime
589652
* by count and/or by minimum spacing. Both gates apply together;
590653
* the default max of 1 keeps it to a single dump per worker. */
654+
char expanded[4096];
655+
const char *out_path = path;
656+
if (strchr(path, '%') != NULL
657+
&& rdump_expand_path(path, expanded, sizeof(expanded)) == 0
658+
) {
659+
out_path = expanded;
660+
}
661+
591662
zend_long max = RDUMP_G(oom_dump_max);
592663
zend_long interval = RDUMP_G(oom_dump_min_interval);
664+
zend_long budget = RDUMP_G(oom_dump_max_total);
593665
zend_long now = (zend_long)time(NULL);
594666
int capped = (max > 0 && RDUMP_G(oom_dump_count) >= max);
595667
int too_soon = (interval > 0
596668
&& RDUMP_G(oom_dump_last_ts) != 0
597669
&& now - RDUMP_G(oom_dump_last_ts) < interval);
598670

599-
if (!capped && !too_soon) {
600-
char expanded[4096];
601-
const char *out_path = path;
602-
if (strchr(path, '%') != NULL
603-
&& rdump_expand_path(path, expanded, sizeof(expanded)) == 0
604-
) {
605-
out_path = expanded;
671+
/* Disk budget: if the *.rdump files already in the target's
672+
* directory (excluding the one we'd overwrite) total at or above
673+
* the budget, skip -- this is the only gate that bounds the
674+
* combined footprint of many workers, not just one worker's rate.
675+
* Best-effort: concurrent workers can each pass and overshoot. */
676+
int over_budget = 0;
677+
if (!capped && !too_soon && budget > 0) {
678+
char pathcopy[4096];
679+
const char *dir = ".";
680+
const char *base = out_path;
681+
if (snprintf(pathcopy, sizeof(pathcopy), "%s", out_path)
682+
< (int)sizeof(pathcopy)) {
683+
char *slash = strrchr(pathcopy, '/');
684+
if (slash != NULL) {
685+
*slash = '\0';
686+
dir = pathcopy[0] != '\0' ? pathcopy : "/";
687+
base = slash + 1;
688+
}
689+
}
690+
if (rdump_dir_rdump_total(dir, base) >= (uint64_t)budget) {
691+
over_budget = 1;
606692
}
693+
}
694+
695+
if (!capped && !too_soon && !over_budget) {
607696
/* Count the attempt (success or failure) and stamp the time
608697
* before writing, so a broken path or full disk cannot retry
609698
* without bound either. */
@@ -677,6 +766,14 @@ PHP_FUNCTION(rdump_set_oom_dump)
677766
/* Module plumbing. */
678767
/* ------------------------------------------------------------------ */
679768

769+
/* Accept memory_limit-style sizes ("256M", "2G", ...) for the dump budget. */
770+
static ZEND_INI_MH(OnUpdateRdumpBytes)
771+
{
772+
zend_long *p = (zend_long *)ZEND_INI_GET_ADDR();
773+
*p = rdump_parse_bytes(ZSTR_VAL(new_value));
774+
return SUCCESS;
775+
}
776+
680777
PHP_INI_BEGIN()
681778
STD_PHP_INI_ENTRY(
682779
"rdump.oom_dump", "", PHP_INI_SYSTEM, OnUpdateString,
@@ -694,6 +791,10 @@ PHP_INI_BEGIN()
694791
"rdump.oom_dump_min_interval", "0", PHP_INI_SYSTEM, OnUpdateLong,
695792
oom_dump_min_interval, zend_rdump_globals, rdump_globals
696793
)
794+
STD_PHP_INI_ENTRY(
795+
"rdump.oom_dump_max_total", "0", PHP_INI_SYSTEM, OnUpdateRdumpBytes,
796+
oom_dump_max_total, zend_rdump_globals, rdump_globals
797+
)
697798
PHP_INI_END()
698799

699800
static const zend_function_entry rdump_functions[] = {
@@ -711,6 +812,7 @@ static PHP_GINIT_FUNCTION(rdump)
711812
rdump_globals->oom_dump_full = 0;
712813
rdump_globals->oom_dump_max = 1;
713814
rdump_globals->oom_dump_min_interval = 0;
815+
rdump_globals->oom_dump_max_total = 0;
714816
rdump_globals->oom_dump_count = 0;
715817
rdump_globals->oom_dump_last_ts = 0;
716818
rdump_globals->oom_dump_runtime = NULL;

0 commit comments

Comments
 (0)