====== PHP RFC: Catchable MemoryError for predictably oversized allocations ====== * Version: 0.1 * Date: 2026-07-05 * Author: Ben Ramsey * Status: Draft * Target Version: PHP 8.6 * Implementation: https://github.com/php/php-src/pull/22692 ===== 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 fatal|''%%MemoryError%%'' | |''%%array_pad($array, $length, $value)%%'' |uncatchable fatal|''%%MemoryError%%'' | |''%%range($start, $end, $step)%%'' |uncatchable fatal|''%%MemoryError%%'' | |''%%str_split($string, $length)%%'' |uncatchable fatal|''%%MemoryError%%'' | |''%%mb_str_split($string, $length)%%'' |uncatchable fatal|''%%MemoryError%%'' | |''%%new SplFixedArray($size)%%'', ''%%SplFixedArray::setSize()%%''|uncatchable fatal|''%%MemoryError%%'', 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 fatal|''%%MemoryError%%''| |''%%openssl_random_pseudo_bytes($length)%%'' |uncatchable fatal|''%%MemoryError%%''| |''%%sodium_crypto_stream($length, ...)%%'', ''%%sodium_crypto_stream_xchacha20($length, ...)%%'', ''%%sodium_crypto_pwhash($length, ...)%%''|uncatchable fatal|''%%MemoryError%%''| |''%%fread($h, $length)%%'' |uncatchable fatal|''%%MemoryError%%''| |''%%fgets($h, $length)%%'' |uncatchable fatal|''%%MemoryError%%''| |''%%stream_socket_recvfrom($s, $length)%%'' |uncatchable fatal|''%%MemoryError%%''| |''%%socket_read($s, $length)%%'' |uncatchable fatal|''%%MemoryError%%''| |''%%socket_recv($s, &$buf, $length, $flags)%%'' |uncatchable fatal|''%%MemoryError%%''| |''%%socket_recvfrom($s, &$buf, $length, ...)%%'' |uncatchable fatal|''%%MemoryError%%''| |''%%msg_receive($q, ..., $maxSize, ...)%%'' |uncatchable fatal|''%%MemoryError%%''| |''%%Random\Randomizer::getBytes($length)%%'', ''%%getBytesFromString($s, $length)%%'' |uncatchable fatal|''%%MemoryError%%''| |''%%fgetcsv($h, $length, ...)%%'' |uncatchable fatal|''%%MemoryError%%''| |''%%shmop_read($id, $offset, $size)%%'' |uncatchable fatal|''%%MemoryError%%''| |''%%pg_lo_read($lo, $length)%%'' |uncatchable fatal|''%%MemoryError%%''| |''%%gmp_strval($num, $base)%%'', GMP ''%%(string)%%'' cast, ''%%gmp_export($num, ...)%%'' |uncatchable fatal|''%%MemoryError%%''| === 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 //// 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, [[https://github.com/php/php-src/blob/master/ext/standard/tests/streams/bug78902.phpt|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 [[https://github.com/php/php-src/commit/9073875eb98416595f1666df0f6a728bab2bc5c7|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 guards^With guards^Delta ^ |**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 [[https://github.com/phpbench/phpbench|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 ===== * A new class named ''%%MemoryError%%'' is declared in the global namespace. A userland class with that exact name will now cause a fatal “Cannot redeclare class MemoryError” error. A survey of public code (below) found no global-namespace declaration of the name. * ''%%mb_str_pad()%%'' currently throws ''%%Error%%'' with the message “String size overflow” when its internal byte-count arithmetic overflows (most oversized requests instead die with the memory-exhaustion fatal before reaching that check). That throw site now uses ''%%MemoryError%%'' with the standard message above. Code catching ''%%Error%%'' (or ''%%Throwable%%'') is unaffected; only code matching the message changes behavior. * Cases that previously terminated with a fatal error now throw. * Because the check is against //available// memory, a script that has nearly exhausted its ''%%memory_limit%%'' and then calls a guarded function gets a ''%%MemoryError%%'' even for a small amount of data; the request could not have succeeded, but previously it reached the allocator and died with an uncatchable fatal error. (Since the check counts used bytes, memory that was allocated and freed again does not trigger this; only genuinely live data does.). * The size-arithmetic overflow case, previously the uncatchable “Possible integer overflow in memory allocation” fatal error, also becomes a ''%%MemoryError%%''. Two core tests asserting that fatal error (''%%bug67247.phpt%%'' for ''%%SplFixedArray::setSize()%%'' and ''%%array_fill_error2.phpt%%'') were re-written to expect the exception; the SplFixedArray test now additionally verifies the array is left unchanged. ==== 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: - Sourcegraph: * **0 results:** ''[[https://sourcegraph.com/search?q=context:global+%28class%7Cinterface%7Ctrait%7Cenum%29%5Cs%2BMemoryError%5Cb+lang:PHP+count:all+fork:yes+archived:yes&patternType=regexp&sm=0|context:global (class|interface|trait|enum)\s+MemoryError\b lang:PHP count:all fork:yes archived:yes]]'' * **0 results:** ''[[https://sourcegraph.com/search?q=context:global+class_alias%5Cs*%5C%28.*MemoryError+lang:PHP+count:all+fork:yes+archived:yes&patternType=regexp&sm=0|context:global class_alias\s*\(.*MemoryError lang:PHP count:all fork:yes archived:yes]]'' - Grep.app: * **0 results:** ''[[https://grep.app/search?f.lang=PHP®exp=true&q=%28class%7Cinterface%7Ctrait%7Cenum%29%5Cs%2BMemoryError%5Cb|(class|interface|trait|enum)\s+MemoryError\b]]'' * **0 results:** ''[[https://grep.app/search?f.lang=PHP®exp=true&q=class_alias%5Cs*%5C%28.*MemoryError|class_alias\s*\(.*MemoryError]]'' - GitHub code search: * **1 result:** ''%%Liquid\Exceptions\MemoryError%%'', not in the global namespace * ''[[https://github.com/search?type=code&q=%2F(class%7Cinterface%7Ctrait%7Cenum)%5Cs%2BMemoryError%5Cb%2F+language%3APHP|/(class|interface|trait|enum)\s+MemoryError\b/ language:PHP]]'' * **2 results:** neither are in the global namespace * ''[[https://github.com/search?type=code&q=%2Fclass_alias%5Cs*%5C%28.*MemoryError%2F+language%3APHP|/class_alias\s*\(.*MemoryError/ language:PHP]]'' Findings: * Zero global-namespace declarations of ''%%MemoryError%%''. * One genuine declaration exists, and it is namespaced: ''%%Liquid\Exceptions\MemoryError%%''. ===== Proposed PHP Version(s) ===== PHP 8.6. ===== RFC Impact ===== * **To SAPIs:** none beyond the new behavior described above. * **To existing extensions:** none required. Extensions may adopt ''%%zend_string_alloc_size_exceeds_memory()%%'' where they can predict allocation sizes. * **To opcache/JIT:** the string-offset guard lives in ''%%zend_assign_to_string_offset()%%'', a slow-path helper shared by the interpreter and JIT; no specialized handlers change. * **New constants/INI:** none. ===== 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: * Yes * No * Abstain ===== Reference Implementation ===== TBD ===== References ===== * Python’s built-in ''%%MemoryError%%'': https://docs.python.org/3/library/exceptions.html#MemoryError * PHP 7 Throwable hierarchy RFC (precedent for ''%%Error%%'' subclasses): https://wiki.php.net/rfc/throwable-interface