Table of Contents

PHP RFC: Catchable MemoryError for predictably oversized allocations

Introduction

When a PHP program requests an allocation whose size the engine can compute before doing any work, the request ends in an uncatchable fatal error. Examples include: str_repeat('x', PHP_INT_MAX), array_fill(0, 2 ** 30, 0), new SplFixedArray(2 ** 30), and $str[2 ** 40] = 'x'. Today each of these results in a fatal error with either the memory manager’s “Allowed memory size exhausted” message or, when the size arithmetic overflows, “Possible integer overflow in memory allocation.” This is unnecessarily severe. The requested size is a simple arithmetic function of the arguments, and comparing it against the configured memory_limit requires no allocation. There is no reason a single bad length argument must terminate the whole request instead of throwing an error the program can catch, log, and recover from.

Proposal

This RFC proposes a new API and throwable error that allows the PHP engine and extensions to pre-calculate an allocation and throw a MemoryError when it determines the allocation will consume the remaining available memory within the configured memory_limit.

New throwable error class

MemoryError is thrown when PHP determines a requested allocation cannot fit within the configured memory_limit.

class MemoryError extends Error
{
}

New engine API

The following ZEND_API functions allow checking the size of an allocation ahead of time and throwing a MemoryError if the allocation will exceed the configured memory_limit:

/* Zend/zend_alloc.h */
ZEND_API size_t zend_memory_limit(void);
ZEND_API bool zend_memory_limit_is_unlimited(void);
ZEND_API bool zend_alloc_size_exceeds_memory(
    size_t nmemb, size_t size, size_t offset, const char *kind);
 
/* Zend/zend_string.h */
ZEND_API bool zend_string_alloc_size_exceeds_memory(
    size_t nmemb, size_t size, size_t offset);
 
/* Zend/zend_hash.h */
ZEND_API bool zend_array_alloc_size_exceeds_memory(size_t nelems, bool packed);

zend_alloc_size_exceeds_memory() provides the core functionality. It computes nmemb * size + offset with overflow detection. If the computation overflows or the result of the allocation would exceed the memory still available under memory_limit (the limit minus current usage), then it throws a MemoryError and returns true. Otherwise, it returns false without throwing. If memory_limit is -1 (unlimited), it also returns false without throwing.

The string helper is a thin wrapper with kind "string". The array helper converts an element count into a lower-bound byte estimate of the hash table’s element storage (sizeof(zval) per element for packed arrays, bucket + hash slot otherwise) with kind "array"; underestimating is safe because a miss simply leaves the allocator’s existing behavior (i.e., OOM fatal error) in place.

Where MemoryError is thrown

This RFC implements the guard in the engine, as well as a set of built-in functions whose result or buffer size is a direct function of an integer argument (or of a size the function can compute up front, before doing any work). Every site follows the same pattern: the guard sits after the function’s existing argument validation and immediately before the allocation.

String builders

Site Current behavior New behavior
$str[$offset] = $c (extending the string) uncatchable fatal MemoryError, string unchanged
str_repeat($s, $times) uncatchable fatal MemoryError
str_pad($s, $length) uncatchable fatal MemoryError
mb_str_pad($s, $length) uncatchable fatal (catchable Error: String size overflow on a rare byte-count overflow path)MemoryError
number_format($n, $decimals) uncatchable fatal MemoryError
chunk_split($s, $length, $end) uncatchable fatal MemoryError
pack($format, ...) (computed output size) uncatchable fatal MemoryError
sprintf()/printf() (field width, e.g., %2000000000d)uncatchable fatal MemoryError

Array builders

When the element count is known before any element is written, we can compute the exact size of the array’s storage and throw a MemoryError if the allocation would exceed the memory_limit.

Site Current behavior New behavior
array_fill($start, $count, $value) uncatchable fatalMemoryError
array_pad($array, $length, $value) uncatchable fatalMemoryError
range($start, $end, $step) uncatchable fatalMemoryError
str_split($string, $length) uncatchable fatalMemoryError
mb_str_split($string, $length) uncatchable fatalMemoryError
new SplFixedArray($size), SplFixedArray::setSize()uncatchable fatalMemoryError, array unchanged

Argument-sized read/output buffers

When the length argument is an upper bound the function allocates in full before reading or generating, we can compute the exact size of the buffer and throw a MemoryError if the allocation would exceed the memory_limit.

Site Current behavior New behavior
random_bytes($length) uncatchable fatalMemoryError
openssl_random_pseudo_bytes($length) uncatchable fatalMemoryError
sodium_crypto_stream($length, ...), sodium_crypto_stream_xchacha20($length, ...), sodium_crypto_pwhash($length, ...)uncatchable fatalMemoryError
fread($h, $length) uncatchable fatalMemoryError
fgets($h, $length) uncatchable fatalMemoryError
stream_socket_recvfrom($s, $length) uncatchable fatalMemoryError
socket_read($s, $length) uncatchable fatalMemoryError
socket_recv($s, &$buf, $length, $flags) uncatchable fatalMemoryError
socket_recvfrom($s, &$buf, $length, ...) uncatchable fatalMemoryError
msg_receive($q, ..., $maxSize, ...) uncatchable fatalMemoryError
Random\Randomizer::getBytes($length), getBytesFromString($s, $length) uncatchable fatalMemoryError
fgetcsv($h, $length, ...) uncatchable fatalMemoryError
shmop_read($id, $offset, $size) uncatchable fatalMemoryError
pg_lo_read($lo, $length) uncatchable fatalMemoryError
gmp_strval($num, $base), GMP (string) cast, gmp_export($num, ...) uncatchable fatalMemoryError

Notes

Note on shmop_read(): A shared-memory segment can legitimately be larger than memory_limit, so reading a whole large segment into a PHP string is a predictable failure; the guard makes it catchable and partial reads keep working.

Note on GMP: libgmp uses its own allocator, entirely outside memory_limit. For example, gmp_pow(2, 2000000000) succeeds under a 64 MB limit, producing a ~250 MB number. That pre-existing behavior is out of scope here. This RFC guards the rendering of such a number into a PHP string, whose exact size mpz_sizeinbase() provides up front. The arithmetic functions themselves have nothing to check against.

For the read buffers, any request the guard rejects is one that already fails with a fatal error today, so it either worked before and still works, or it was already fatal and now throws.

printf-family precision is deliberately not guarded. It is capped at 53 digits for floats and acts as a truncation limit for strings, so it never attempts a large allocation.

Error messages

Built-in functions (where kind is string, array, or SplFixedArray as appropriate):

The resulting <kind> is too large to fit in the configured memory limit

String offset assignment:

String offset assignment produces a string too large to fit in the configured memory limit

Example

ini_set('memory_limit', '64M');
 
try {
    $s = str_repeat('x', PHP_INT_MAX);
} catch (MemoryError $e) {
    // The resulting string is too large to fit in the configured memory limit
    echo $e->getMessage();
}
 
$s = 'abc';
try {
    $s[2 ** 40] = 'Z';
} catch (MemoryError $e) {
    // String offset assignment produces a string too large to fit in the configured memory limit
    echo $e->getMessage();
 
    // $s is still 'abc'
}

Semantics

The check is against available memory, i.e., memory_limit minus current usage.

“Current usage” means used bytes (zend_memory_usage(false)), rather than the allocator’s reserved chunk size. Reserved-but-free chunk space can satisfy an allocation, so counting it as part of current usage would reject requests that succeed today. For example, bug78902.phpt (a read loop under a 2 MB limit whose per-chunk strings are freed each iteration) demonstrates this condition and passes unchanged under this rule. The trade-off is that the check can miss an allocation that fails at the chunk level (i.e., fragmentation); such cases result in an OOM fatal error, so the behavior is unchanged.

When memory_limit = -1 (unlimited), the engine never throws. The engine proceeds with the allocation. If the operating system cannot satisfy it, the existing OOM fatal error behavior applies. The check is a comparison against the configured limit, not a guess about system memory.

Failed operations have no effect (i.e., state is unchanged). For example, the string offset assignment leaves the target string unchanged. The throw occurs before allocating or copying anything.

Genuine OOM errors out-of-scope

Genuine out-of-memory errors that occur when allocation fails during execution remain uncatchable fatal errors. Making these catchable would require the engine to allocate (e.g., an exception object, stack frames for finally blocks, destructor execution, etc.) at the precise moment no memory is available. This RFC only adds guards at sites where the size is computable before allocation.

Performance

The check is placed at the guarded call sites, not inside the allocator. emalloc() and zend_string_alloc() are untouched, so allocation elsewhere in the engine is unchanged. Each guard costs one overflow-checked multiply-add (zend_safe_address()) plus two or three reads of the heap globals and comparisons. The throw paths are marked UNEXPECTED. The string-offset guard is only on the extend-if-needed branch of zend_assign_to_string_offset(), so ordinary in-bounds $s[$i] = 'x' writes never execute it.

The following benchmarks measure the impact of executing the guards on release builds (i.e., -O2 and no --enable-debug). “Without guards” is a build of php/php-src at 9073875eb9, the merge-base where the memory-error branch diverges from master; “with guards” is the branch tip. Every guarded function is measured on its success path with small inputs, so the numbers show the per-call cost of the guard.

Case Without guardsWith guardsDelta
String builders
str_repeat() (10 B result) 30 ns 32 ns +8.2%
str_repeat() (64 KB result) 677 ns 698 ns +3.0%
str_pad() (16 B) 36 ns 37 ns +1.3%
mb_str_pad() (16 B) 72 ns 71 ns −1.8%
number_format($x, 2) 213 ns 214 ns +0.4%
chunk_split() (16 B / 4) 43 ns 45 ns +5.7%
pack('N4', …) 44 ns 46 ns +4.3%
sprintf('%10d', …) 81 ns 82 ns +2.1%
String-offset write, extending (×4) 60 ns 68 ns +12.2%
String-offset write, in-bounds (×4) 50 ns 50 ns +0.1%
Array / data-structure builders
array_fill() (10 elements) 45 ns 45 ns −0.3%
array_fill() (1000 elements) 732 ns 730 ns −0.3%
array_pad() (2 → 8) 47 ns 47 ns −1.1%
range(1, 10) 48 ns 49 ns +1.3%
str_split() (16 B / 4) 57 ns 57 ns +0.5%
mb_str_split() (16 B / 4) 88 ns 89 ns +0.4%
new SplFixedArray(10) 80 ns 79 ns −0.8%
SplFixedArray::setSize() grow (4 → 16) 122 ns 122 ns −0.4%
Argument-sized read/output buffers
random_bytes(16) 313 ns 315 ns +0.5%
Randomizer::getBytes(16) 610 ns 611 ns +0.2%
Randomizer::getBytesFromString() (16 B)972 ns 972 ns −0.1%
openssl_random_pseudo_bytes(16) 492 ns 488 ns −0.8%
sodium_crypto_stream(32) 253 ns 253 ns +0.1%
sodium_crypto_stream_xchacha20(32) 249 ns 251 ns +0.8%
fread($h, 16) (php://memory) 58 ns 57 ns −1.1%
fgets($h, 16) (php://memory) 76 ns 75 ns −0.9%
fgetcsv($h, 64) 354 ns 356 ns +0.8%
stream_socket_recvfrom(16) 579 ns 585 ns +1.2%
socket_read(16) 571 ns 575 ns +0.7%
socket_recv(16) 579 ns 583 ns +0.7%
socket_recvfrom(16) 616 ns 623 ns +1.1%
msg_receive() (empty, NOWAIT) 272 ns 277 ns +1.6%
shmop_read(16) 47 ns 47 ns +0.7%
pg_lo_read(16) (live server) 90.6 µs 90.8 µs +0.1%
GMP rendering
gmp_strval() (5 digits) 50 ns 51 ns +0.4%
(string) GMP cast (5 digits) 52 ns 53 ns +0.6%
gmp_export() (5 digits) 40 ns 40 ns +0.3%

Methodology. The suite is a PHPBench project; each subject runs 200,000–3,000,000 revolutions per iteration for 10 iterations, with a 5% retry threshold, and the table reports the mode. Delta percentages are PHPBench’s mode comparison against the stored baseline run. The two builds are compiled in Docker images from the same base image and identical configure flags, and ran in aarch64 Linux containers on Apple silicon (M1 Max), on an otherwise idle machine, with a PostgreSQL 17 container backing pg_lo_read(). The benchmark project is available at https://github.com/ramsey/memory-error-bench.

Backward Incompatible Changes

Name collision survey

Surveyed 2026-07-10

A collision requires a global namespace symbol of the name MemoryError. Because PHP classes, interfaces, traits, and enums share one symbol table and class names are case-insensitive, the survey covered all four declaration kinds plus class_alias(). Several search engines were used, since each covers code the other misses:

  1. Sourcegraph:
  2. Grep.app:
  3. GitHub code search:

Findings:

Proposed PHP Version(s)

PHP 8.6.

RFC Impact

Future Scope

The functions included in this RFC are those where the size of the allocation is known beforehand and where the guard is a simple insertion. Other functions where guards may be included are reserved for future scope. Converting an uncatchable fatal error to a MemoryError at additional sites after this RFC should be treated as a bug-fix improvement not requiring an RFC.

Vote

Simple yes/no vote requiring a 2/3 majority:

Add MemoryError and throw it for predictably oversized allocations as described in this proposal?
Real name Yes No Abstain
Final result: 0 0 0
This poll has been closed.

Reference Implementation

TBD

References