The PHP master branch recorded 95 commits in this lookback, across 299 files, with 5543 insertions and 994 deletions. A large share of that diff is NEWS churn. The commits that change worker behavior are zip and phar lifetime bugs, a tracing JIT write into shared opcodes, and a WHATWG URL change that flips validation error order relative to PHP 8.5.
Zip and phar close paths free the wrong memory ¶
Zip archive destruction could free an archive that another caller still held. The close flag already stopped a progress or cancel callback from nesting zip_close() during an explicit close. The release path in php_zip.c did not use that flag, so destruction without an explicit close could nest zip_close() and free the archive while it was still in use. The destructor fix guards release with the same flag, keeps the flag set until warnings and native cleanup finish, and rejects new stream creation during a close.
Phar has a narrower hole, and it is new in PHP 8.6. In the CGI and FastCGI branch of webPhar(), a request with SCRIPT_NAME set and PATH_INFO absent aliased path_info to an internal testit buffer and marked that buffer for freeing. A later unconditional efree() of testit left path_info dangling: a use after free on read, and a double free at cleanup. The dedicated copy in phar_object.c gives path_info its own allocation. Earlier branches do not free testit there, so PHP 8.5 and PHP 8.4 do not have this regression.
soap.c crashes on a different bailout. An out of memory failure while copying the trace request, or while allocating the __doRequest name, left func uninitialized, and cleanup destroyed it. The initialization closes that hole and does not change SOAP parsing. bzopen() handed bzlib the inner descriptor, so both layers closed the same number. The duplicate hands bzlib a copy and skips an inner stream that is already closed.
Tracing JIT restores the VM handler on the wrong opline ¶
A root trace that has already hit opcache.jit_max_root_traces takes a bad exit. zend_jit_trace_exit() blacklists that root and writes the original VM handler back. The blacklist fix stored that handler on the exit opline, the INIT_* opline whose callee guard failed. The root trace starts on a different opline. Opcodes live in shared memory. The write is in zend_jit_trace.c. Every worker keeps executing the foreign handler until the process restarts.
When the root starts at a function with typed parameters, the copied handler is ZEND_RECV. It then runs on an INIT_FCALL or INIT_STATIC_METHOD_CALL opline and reports too few arguments from a frame that already has every argument. Other root oplines crash.
Function JIT increment and decrement updated the wrong zval. zend_jit_inc_typed_prop() and zend_jit_dec_typed_prop() copied the inner value with ZVAL_COPY_DEREF(), then incremented the pointer that was still a reference. The helper change dereferences first, runs the increment or decrement, and copies after that.
Huge page setup raced only at startup. create_segments() unmapped its reservation before a MAP_FIXED remap, which opens a window where another thread can claim the address. The remap change keeps the reservation, maps huge pages into it, and releases only the leftover head and tail. On failure the whole reservation is released.
WHATWG URL errors now follow detection order ¶
Uri\WhatWg\Url construction, parse(), the with*() modifiers, and Uri\WhatWg\UrlBuilder::build() collected lexbor errors by popping the log. Pop order is the reverse of detection order. The parser walk in uri_parser_whatwg.c reads the log from front to back, keeps the first reason, and then clears the log. UPGRADING records this under PHP 8.6 and says multiple errors come out reversed compared with PHP 8.5. Callers that branch on the first soft error, or that snapshot the full list, see a different sequence. Which strings count as invalid does not change in this commit.
Temporary UrlBuilder helpers in the same file were replaced once lexbor grew the real functions. The replacement does not change validation. A lexbor merge pulls upstream WHATWG URL and IDNA fixes into the bundled copy, and leaves out HTML tree construction plus a percent encoder API this branch does not ship.
Packed array chunks, grapheme offsets, and XML identity ¶
array_chunk() takes a cheaper path when keys are not preserved. Every chunk is then a list of at most size elements. The packed fill in array.c walks the input by element pointer and fills each chunk with ZEND_HASH_FILL_PACKED instead of one zend_hash_next_index_insert() per element. The preserve_keys path still inserts by key.
grapheme_extract() reported the wrong next offset when the start index sat inside a multibyte UTF-8 character. The cursor moved. The returned offset still used the original start. The offset fix in grapheme_string.c derives the next offset from the adjusted cursor on both the ASCII fast path and the break iterator path. A paging loop would skip or repeat bytes until the start landed on a character boundary.
SCCP folded isset() and empty() on an object dimension to a constant. Using an object as an array either calls ArrayAccess::offsetExists() or throws Error. The fold is removed in sccp.c. SimpleXML sibling proxies compared equal because the comparator used the parent wrapper. The node resolve runs before the compare. Two empty results from the same parent still compare equal. DOMDocument::adoptNode() only retargeted the leftmost descendant chain and skipped attribute value nodes, so retained nodes could point at a freed document. The subtree walk covers the whole tree.
What to watch ¶
curl_multi_exec() is the behavior change to schedule, and the upgrade note is wider than the test. UPGRADING says the function throws TypeError when still_running is not an int, under PHP 8.7. The try conversion still accepts the string "1", the float 1.5 (with the implicit conversion deprecation), true, false, and null. The string "abc", an array, and an object throw. The check replaced a bare zval_get_long():
still_running = zval_try_get_long(z_still_running, &failed);
if (UNEXPECTED(failed)) {
zend_argument_type_error(2, "must be of type int, %s given",
zend_zval_value_name(z_still_running));
RETURN_THROWS();
}
php_date_time_duration_create() is an internal export for PHP 8.7. The header split documents it in UPGRADING.INTERNALS. Extensions can build a Time\Duration without the userland factories.
Branch labels moved, and that is not a release. The 8.4 line now reads 8.4.28-dev, the 8.5 line reads 8.5.13-dev, and NEWS was updated for 8.6.0RC4. Take the phar fix on the 8.6 line only. Restart workers after the JIT handler fix, because the bad handler sits in shared opcodes until restart. ZTS builds should take the getpwnam_r() retry on ERANGE. chown() and lchown() failed when a passwd entry did not fit the first buffer, and debug builds started from a 1 byte buffer. If expand_filepath() fails after an ini file is already open, the null path fix closes the file instead of dereferencing null.