From c4c4948aef2cd82748d87a35a7b7e90f3c322bcc Mon Sep 17 00:00:00 2001 From: Weilin Du Date: Thu, 18 Jun 2026 22:44:37 +0800 Subject: [PATCH 1/7] ext/standard: Reject NUL bytes in `dl()` (#22358) Similar to #21942 and #21871. The dl function in std extension now silently truncates from NUL bytes. Now we reject any parameter containing NUL byte(s) by throwing a ValueErrpr --- NEWS | 2 ++ UPGRADING | 2 ++ ext/standard/dl.c | 2 +- .../tests/general_functions/dl_null_bytes.phpt | 14 ++++++++++++++ 4 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 ext/standard/tests/general_functions/dl_null_bytes.phpt diff --git a/NEWS b/NEWS index 2bbe43032ece..652c415d526f 100644 --- a/NEWS +++ b/NEWS @@ -254,6 +254,8 @@ PHP NEWS (Weilin Du) . getenv() and putenv() now raises a ValueError when the first argument contains null bytes. (Weilin Du) + . dl() now raises a ValueError when the $extension_filename argument + contains null bytes. (Weilin Du) . parse_str() now raises a ValueError when the $string argument contains null bytes. (Weilin Du) . proc_open() now raises a ValueError when the $cwd argument contains diff --git a/UPGRADING b/UPGRADING index bdadc6efbefc..f840340fb7fb 100644 --- a/UPGRADING +++ b/UPGRADING @@ -151,6 +151,8 @@ PHP 8.6 UPGRADE NOTES argument value is passed. . getenv() and putenv() now raises a ValueError when the first argument contains null bytes. + . dl() now raises a ValueError when the $extension_filename argument + contains null bytes. . parse_str() now raises a ValueError when the $string argument contains null bytes. . linkinfo() now raises a ValueError when the $path argument is empty. diff --git a/ext/standard/dl.c b/ext/standard/dl.c index a6d0ced6fa86..ca8ba57a16e9 100644 --- a/ext/standard/dl.c +++ b/ext/standard/dl.c @@ -43,7 +43,7 @@ PHPAPI PHP_FUNCTION(dl) size_t filename_len; ZEND_PARSE_PARAMETERS_START(1, 1) - Z_PARAM_STRING(filename, filename_len) + Z_PARAM_PATH(filename, filename_len) ZEND_PARSE_PARAMETERS_END(); if (!PG(enable_dl)) { diff --git a/ext/standard/tests/general_functions/dl_null_bytes.phpt b/ext/standard/tests/general_functions/dl_null_bytes.phpt new file mode 100644 index 000000000000..7f251393ba3b --- /dev/null +++ b/ext/standard/tests/general_functions/dl_null_bytes.phpt @@ -0,0 +1,14 @@ +--TEST-- +dl() rejects null bytes in extension filename +--FILE-- +getMessage(), "\n"; +} + +?> +--EXPECT-- +dl(): Argument #1 ($extension_filename) must not contain any null bytes From 657f0d6b21d1af9f1c0d80f890b42b09b04c9880 Mon Sep 17 00:00:00 2001 From: Weilin Du Date: Thu, 18 Jun 2026 22:56:11 +0800 Subject: [PATCH 2/7] ext/phar: Fix ZIP extra field length underflow (#22330) Validate each ZIP extra field header before consuming its payload. The old parser kept the remaining extra field length in a uint16_t and subtracted the declared payload size plus the header size without first checking that the field fit inside the remaining extra data. A malformed ZIP central directory entry could therefore underflow the counter and make the parser continue into following bytes, such as the file comment. That allowed comment bytes to be interpreted as another extra field and update metadata like the entry mtime. Reject truncated extra headers and oversized payloads, keep the remaining length in size_t while parsing, and check seeks that skip unknown or unused field data. Add a regression test that builds a malformed ZIP and expects PharData to reject it. Closes #22330 --- NEWS | 1 + ext/phar/tests/zip/zip_extra_underflow.phpt | 91 +++++++++++++++++++++ ext/phar/zip.c | 52 ++++++++---- 3 files changed, 129 insertions(+), 15 deletions(-) create mode 100644 ext/phar/tests/zip/zip_extra_underflow.phpt diff --git a/NEWS b/NEWS index 98f5bf7e718d..32bb0b49f344 100644 --- a/NEWS +++ b/NEWS @@ -65,6 +65,7 @@ PHP NEWS . Fixed a bypass of the magic ".phar" directory protection in Phar::addEmptyDir() for paths starting with "/.phar", while allowing non-magic directory names that merely share the ".phar" prefix. (Weilin Du) + . Fixed an integer underflow when parsing ZIP extra fields. (Weilin Du) - Reflection: . Preserve class-name case in ReflectionClass::getProperty() error messages diff --git a/ext/phar/tests/zip/zip_extra_underflow.phpt b/ext/phar/tests/zip/zip_extra_underflow.phpt new file mode 100644 index 000000000000..e37a3493b663 --- /dev/null +++ b/ext/phar/tests/zip/zip_extra_underflow.phpt @@ -0,0 +1,91 @@ +--TEST-- +Phar: ZIP extra field length must not underflow +--EXTENSIONS-- +phar +--FILE-- +getMTime(), "\n"; +} catch (Exception $e) { + echo $e->getMessage(), "\n"; +} +?> +--CLEAN-- + +--EXPECTF-- +phar error: Unable to process extra field header for file in central directory in zip-based phar "%szip_extra_underflow.zip" diff --git a/ext/phar/zip.c b/ext/phar/zip.c index 339e45e73088..f051c35a09a7 100644 --- a/ext/phar/zip.c +++ b/ext/phar/zip.c @@ -41,19 +41,30 @@ static inline void phar_write_16(char buffer[2], uint32_t value) # define PHAR_SET_32(var, value) phar_write_32(var, (uint32_t) (value)); # define PHAR_SET_16(var, value) phar_write_16(var, (uint16_t) (value)); -static int phar_zip_process_extra(php_stream *fp, phar_entry_info *entry, uint16_t len) /* {{{ */ +static int phar_zip_process_extra(php_stream *fp, phar_entry_info *entry, uint16_t extra_len) /* {{{ */ { union { phar_zip_extra_field_header header; phar_zip_unix3 unix3; phar_zip_unix_time time; } h; + size_t len = extra_len; size_t read; - do { + while (len) { + size_t header_size; + + if (len < sizeof(h.header)) { + return FAILURE; + } if (sizeof(h.header) != php_stream_read(fp, (char *) &h.header, sizeof(h.header))) { return FAILURE; } + len -= sizeof(h.header); + header_size = PHAR_GET_16(h.header.size); + if (header_size > len) { + return FAILURE; + } if (h.header.tag[0] == 'U' && h.header.tag[1] == 'T') { /* Unix timestamp header found. @@ -62,7 +73,6 @@ static int phar_zip_process_extra(php_stream *fp, phar_entry_info *entry, uint16 * We only store the modification time in the entry, so only read that. */ const size_t min_size = 5; - uint16_t header_size = PHAR_GET_16(h.header.size); if (header_size >= min_size) { read = php_stream_read(fp, &h.time.flags, min_size); if (read != min_size) { @@ -73,12 +83,11 @@ static int phar_zip_process_extra(php_stream *fp, phar_entry_info *entry, uint16 entry->timestamp = PHAR_GET_32(h.time.time); } - len -= header_size + 4; - /* Consume remaining bytes */ - if (header_size != read) { - php_stream_seek(fp, header_size - read, SEEK_CUR); + if (header_size != read && -1 == php_stream_seek(fp, header_size - read, SEEK_CUR)) { + return FAILURE; } + len -= header_size; continue; } /* Fallthrough to next if to skip header */ @@ -86,23 +95,36 @@ static int phar_zip_process_extra(php_stream *fp, phar_entry_info *entry, uint16 if (h.header.tag[0] != 'n' || h.header.tag[1] != 'u') { /* skip to next header */ - php_stream_seek(fp, PHAR_GET_16(h.header.size), SEEK_CUR); - len -= PHAR_GET_16(h.header.size) + 4; + if (header_size && -1 == php_stream_seek(fp, header_size, SEEK_CUR)) { + return FAILURE; + } + len -= header_size; continue; } /* unix3 header found */ - read = php_stream_read(fp, (char *) &(h.unix3.crc32), sizeof(h.unix3) - sizeof(h.header)); - len -= read + 4; + size_t unix3_size = sizeof(h.unix3) - sizeof(h.header); + size_t field_size = header_size; + if (field_size == unix3_size - sizeof(h.unix3.crc32)) { + /* Some archives omit the CRC32 from the unix3 size field. */ + field_size = unix3_size; + } + if (field_size < unix3_size || field_size > len) { + return FAILURE; + } - if (sizeof(h.unix3) - sizeof(h.header) != read) { + read = php_stream_read(fp, (char *) &(h.unix3.crc32), unix3_size); + if (unix3_size != read) { return FAILURE; } - if (PHAR_GET_16(h.unix3.size) > sizeof(h.unix3) - 4) { + if (field_size > unix3_size) { /* skip symlink filename - we may add this support in later */ - php_stream_seek(fp, PHAR_GET_16(h.unix3.size) - sizeof(h.unix3.size), SEEK_CUR); + if (-1 == php_stream_seek(fp, field_size - unix3_size, SEEK_CUR)) { + return FAILURE; + } } + len -= field_size; /* set permissions */ entry->flags &= PHAR_ENT_COMPRESSION_MASK; @@ -113,7 +135,7 @@ static int phar_zip_process_extra(php_stream *fp, phar_entry_info *entry, uint16 entry->flags |= PHAR_GET_16(h.unix3.perms) & PHAR_ENT_PERM_MASK; } - } while (len); + } return SUCCESS; } From ce8786106ff5e659f97c32c1ce4144ffe6330d92 Mon Sep 17 00:00:00 2001 From: Gina Peter Banyard Date: Thu, 18 Jun 2026 15:58:13 +0100 Subject: [PATCH 3/7] stream filters: only accept array params for write_seek_mode param (#22357) This is in order to remove usage of HASH_OF() and interpreting objects as arrays within PHP As this is a new parameter, there is no BC break. --- main/streams/filter.c | 11 +++++------ main/streams/php_stream_filter_api.h | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/main/streams/filter.c b/main/streams/filter.c index 35dbef455a30..e53c4fa14ba0 100644 --- a/main/streams/filter.c +++ b/main/streams/filter.c @@ -277,7 +277,7 @@ PHPAPI php_stream_filter *_php_stream_filter_alloc(const php_stream_filter_ops * } PHPAPI zend_result php_stream_filter_parse_write_seek_mode( - zval *filterparams, + const zval *filterparams, php_stream_filter_seekable_t *write_seekable) { *write_seekable = PSFS_SEEKABLE_ALWAYS; @@ -285,18 +285,17 @@ PHPAPI zend_result php_stream_filter_parse_write_seek_mode( if (filterparams == NULL) { return SUCCESS; } - if (Z_TYPE_P(filterparams) != IS_ARRAY && Z_TYPE_P(filterparams) != IS_OBJECT) { + if (Z_TYPE_P(filterparams) != IS_ARRAY) { return SUCCESS; } - zval *tmp = zend_hash_str_find_ind(HASH_OF(filterparams), - "write_seek_mode", sizeof("write_seek_mode") - 1); - if (tmp == NULL) { + const zval *write_seek_mode = zend_hash_str_find(Z_ARR_P(filterparams), ZEND_STRL("write_seek_mode")); + if (write_seek_mode == NULL) { return SUCCESS; } zend_string *tmp_str; - zend_string *str = zval_get_tmp_string(tmp, &tmp_str); + const zend_string *str = zval_get_tmp_string(write_seek_mode, &tmp_str); zend_result result = SUCCESS; if (zend_string_equals_literal(str, "preserve")) { diff --git a/main/streams/php_stream_filter_api.h b/main/streams/php_stream_filter_api.h index 111127a8ad1a..20df33897799 100644 --- a/main/streams/php_stream_filter_api.h +++ b/main/streams/php_stream_filter_api.h @@ -144,7 +144,7 @@ PHPAPI void php_stream_filter_free(php_stream_filter *filter); PHPAPI php_stream_filter *_php_stream_filter_alloc(const php_stream_filter_ops *fops, void *abstract, bool persistent, php_stream_filter_seekable_t read_seekable, php_stream_filter_seekable_t write_seekable STREAMS_DC); -PHPAPI zend_result php_stream_filter_parse_write_seek_mode(zval *filterparams, +PHPAPI zend_result php_stream_filter_parse_write_seek_mode(const zval *filterparams, php_stream_filter_seekable_t *write_seekable); PHPAPI int php_stream_filter_get_chain_type(php_stream *stream, php_stream_filter *filter); From e744feb4fe7bdcbd4bce084fc898d941406b9cc5 Mon Sep 17 00:00:00 2001 From: Gina Peter Banyard Date: Thu, 18 Jun 2026 16:01:48 +0100 Subject: [PATCH 4/7] Add an UPGRADING entry for the new write_seek_mode filter param --- UPGRADING | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/UPGRADING b/UPGRADING index f840340fb7fb..a9b1f34eae5a 100644 --- a/UPGRADING +++ b/UPGRADING @@ -235,6 +235,10 @@ PHP 8.6 UPGRADE NOTES tcp_keepintvl and tcp_keepcnt that allow setting socket keepalive options. . Allowed casting casting filtered streams as file descriptor for select. + . Added the "write_seek_mode stream" filter parameter for the bz2, iconv, + zlib, and string stream filters. This parameter must be set via an + associative array where the key is "write_seek_mode stream" and the + value is one of the following strings "preserve", "reset", or "strict". - URI: . Added Uri\Rfc3986\Uri:getUriType() and Uri\WhatWg\Url:isSpecialScheme(). From fc84d165950d48031d8985f4839ff75a55e87f52 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Sun, 14 Jun 2026 19:31:12 -0400 Subject: [PATCH 5/7] Fix Io\Poll memory-safety issues Several memory-safety issues in the new Io\Poll API, found by review and confirmed under valgrind: - Watcher kept a raw pointer to its Context's php_poll_ctx with no reference, so dropping the Context while holding a Watcher left remove()/modify() dereferencing freed memory (use-after-free). The Context now neutralizes its watchers (active=false, poll_ctx=NULL) before it is destroyed, so those calls throw InactiveWatcherException. - StreamPollHandle took a reference on the stream resource in the constructor but never released it, leaking the descriptor for the rest of the request. Store the zend_resource and release it in the handle cleanup; the php_stream may already be freed by then (e.g. the user closed it), so the cleanup must not dereference it. - Watcher and Context had no get_gc handler, so reference cycles through Watcher::$data were uncollectable. Add get_gc for both. - Context, Watcher and StreamPollHandle were cloneable through the default handler, which shallow-copied the backing php_poll_ctx and the watcher map by pointer and double-freed them on destruction. Mark all three uncloneable. - Calling __construct() a second time on a Context or StreamPollHandle replaced the backing context or handle data without releasing the first, leaking it. Throw if the object is already constructed. - The add(), modify(), remove() and wait() entry points accepted a NULL ctx and forwarded it to php_poll_set_error(), which dereferenced it. The userland layer already gates on an active context before reaching the C API, so assert a non-NULL ctx in those entry points instead. Closes GH-22316 --- ext/standard/io_poll.c | 62 ++++++++++++++++++- .../tests/poll/poll_clone_not_allowed.phpt | 26 ++++++++ .../tests/poll/poll_double_construct.phpt | 28 +++++++++ .../poll_stream_handle_close_then_free.phpt | 20 ++++++ .../poll/poll_stream_handle_fd_release.phpt | 28 +++++++++ .../tests/poll/poll_watcher_gc_cycle.phpt | 31 ++++++++++ .../poll/poll_watcher_outlives_context.phpt | 35 +++++++++++ main/poll/poll_core.c | 15 +++-- 8 files changed, 238 insertions(+), 7 deletions(-) create mode 100644 ext/standard/tests/poll/poll_clone_not_allowed.phpt create mode 100644 ext/standard/tests/poll/poll_double_construct.phpt create mode 100644 ext/standard/tests/poll/poll_stream_handle_close_then_free.phpt create mode 100644 ext/standard/tests/poll/poll_stream_handle_fd_release.phpt create mode 100644 ext/standard/tests/poll/poll_watcher_gc_cycle.phpt create mode 100644 ext/standard/tests/poll/poll_watcher_outlives_context.phpt diff --git a/ext/standard/io_poll.c b/ext/standard/io_poll.c index c500247dc8be..693f72eaee7a 100644 --- a/ext/standard/io_poll.c +++ b/ext/standard/io_poll.c @@ -64,6 +64,7 @@ typedef struct { /* Stream poll handle specific data */ typedef struct { php_stream *stream; + zend_resource *res; } php_stream_poll_handle_data; /* Accessor macros */ @@ -250,7 +251,9 @@ static void php_stream_poll_handle_cleanup(php_poll_handle_object *handle) { php_stream_poll_handle_data *data = (php_stream_poll_handle_data *) handle->handle_data; if (data) { - /* Don't close the stream - user still owns it */ + if (data->res) { + zend_list_delete(data->res); + } efree(data); handle->handle_data = NULL; } @@ -331,6 +334,15 @@ static void php_io_poll_context_free_object(zend_object *obj) { php_io_poll_context_object *intern = PHP_POLL_CONTEXT_OBJ_FROM_ZOBJ(obj); + if (intern->watchers) { + zval *zv; + ZEND_HASH_FOREACH_VAL(intern->watchers, zv) { + php_io_poll_watcher_object *watcher = PHP_POLL_WATCHER_OBJ_FROM_ZOBJ(Z_OBJ_P(zv)); + watcher->active = false; + watcher->poll_ctx = NULL; + } ZEND_HASH_FOREACH_END(); + } + if (intern->ctx) { php_poll_destroy(intern->ctx); } @@ -343,6 +355,36 @@ static void php_io_poll_context_free_object(zend_object *obj) zend_object_std_dtor(&intern->std); } +static HashTable *php_io_poll_watcher_get_gc(zend_object *obj, zval **table, int *n) +{ + php_io_poll_watcher_object *intern = PHP_POLL_WATCHER_OBJ_FROM_ZOBJ(obj); + zend_get_gc_buffer *gc_buffer = zend_get_gc_buffer_create(); + + zend_get_gc_buffer_add_zval(gc_buffer, &intern->data); + if (intern->handle) { + zend_get_gc_buffer_add_obj(gc_buffer, &intern->handle->std); + } + + zend_get_gc_buffer_use(gc_buffer, table, n); + return NULL; +} + +static HashTable *php_io_poll_context_get_gc(zend_object *obj, zval **table, int *n) +{ + php_io_poll_context_object *intern = PHP_POLL_CONTEXT_OBJ_FROM_ZOBJ(obj); + zend_get_gc_buffer *gc_buffer = zend_get_gc_buffer_create(); + + if (intern->watchers) { + zval *zv; + ZEND_HASH_FOREACH_VAL(intern->watchers, zv) { + zend_get_gc_buffer_add_zval(gc_buffer, zv); + } ZEND_HASH_FOREACH_END(); + } + + zend_get_gc_buffer_use(gc_buffer, table, n); + return NULL; +} + /* Utility functions */ static zend_always_inline zend_ulong php_io_poll_compute_ptr_key(void *ptr) @@ -448,13 +490,19 @@ PHP_METHOD(StreamPollHandle, __construct) php_poll_handle_object *intern = PHP_POLL_HANDLE_OBJ_FROM_ZV(getThis()); + if (intern->handle_data) { + zend_throw_error(NULL, "StreamPollHandle object is already constructed"); + RETURN_THROWS(); + } + /* Set up stream-specific data */ php_stream_poll_handle_data *data = emalloc(sizeof(php_stream_poll_handle_data)); data->stream = stream; + data->res = stream->res; intern->handle_data = data; /* Add reference to stream */ - GC_ADDREF(stream->res); + GC_ADDREF(data->res); } PHP_METHOD(StreamPollHandle, getStream) @@ -657,6 +705,11 @@ PHP_METHOD(Io_Poll_Context, __construct) php_io_poll_context_object *intern = PHP_POLL_CONTEXT_OBJ_FROM_ZV(getThis()); + if (intern->ctx) { + zend_throw_error(NULL, "Io\\Poll\\Context object is already constructed"); + RETURN_THROWS(); + } + php_poll_backend_type backend_type = PHP_POLL_BACKEND_AUTO; if (backend_obj != NULL) { backend_type = php_io_poll_backend_enum_to_type(Z_OBJ_P(backend_obj)); @@ -861,6 +914,7 @@ PHP_MINIT_FUNCTION(poll) memcpy(&php_io_poll_handle_object_handlers, &std_object_handlers, sizeof(zend_object_handlers)); php_io_poll_handle_object_handlers.offset = offsetof(php_poll_handle_object, std); php_io_poll_handle_object_handlers.free_obj = php_poll_handle_object_free; + php_io_poll_handle_object_handlers.clone_obj = NULL; php_stream_poll_handle_class_entry->default_object_handlers = &php_io_poll_handle_object_handlers; /* Register Watcher class */ @@ -871,6 +925,8 @@ PHP_MINIT_FUNCTION(poll) sizeof(zend_object_handlers)); php_io_poll_watcher_object_handlers.offset = offsetof(php_io_poll_watcher_object, std); php_io_poll_watcher_object_handlers.free_obj = php_io_poll_watcher_free_object; + php_io_poll_watcher_object_handlers.get_gc = php_io_poll_watcher_get_gc; + php_io_poll_watcher_object_handlers.clone_obj = NULL; php_io_poll_watcher_class_entry->default_object_handlers = &php_io_poll_watcher_object_handlers; /* Register Context class */ @@ -881,6 +937,8 @@ PHP_MINIT_FUNCTION(poll) sizeof(zend_object_handlers)); php_io_poll_context_object_handlers.offset = offsetof(php_io_poll_context_object, std); php_io_poll_context_object_handlers.free_obj = php_io_poll_context_free_object; + php_io_poll_context_object_handlers.get_gc = php_io_poll_context_get_gc; + php_io_poll_context_object_handlers.clone_obj = NULL; php_io_poll_context_class_entry->default_object_handlers = &php_io_poll_context_object_handlers; /* Register exception hierarchy */ diff --git a/ext/standard/tests/poll/poll_clone_not_allowed.phpt b/ext/standard/tests/poll/poll_clone_not_allowed.phpt new file mode 100644 index 000000000000..f31c4c2ecd7f --- /dev/null +++ b/ext/standard/tests/poll/poll_clone_not_allowed.phpt @@ -0,0 +1,26 @@ +--TEST-- +Io\Poll: Context, Watcher and StreamPollHandle are not cloneable +--FILE-- +add($handle, [Io\Poll\Event::Read]); + +foreach ([$ctx, $handle, $watcher] as $obj) { + try { + clone $obj; + } catch (Error $e) { + echo $e->getMessage(), "\n"; + } +} + +echo "done\n"; +?> +--EXPECT-- +Trying to clone an uncloneable object of class Io\Poll\Context +Trying to clone an uncloneable object of class StreamPollHandle +Trying to clone an uncloneable object of class Io\Poll\Watcher +done diff --git a/ext/standard/tests/poll/poll_double_construct.phpt b/ext/standard/tests/poll/poll_double_construct.phpt new file mode 100644 index 000000000000..1fa5b65db38d --- /dev/null +++ b/ext/standard/tests/poll/poll_double_construct.phpt @@ -0,0 +1,28 @@ +--TEST-- +Io\Poll: calling __construct() twice throws instead of leaking +--FILE-- +__construct($r); +} catch (Error $e) { + echo $e->getMessage(), "\n"; +} + +$ctx = pt_new_stream_poll(); +try { + $ctx->__construct(); +} catch (Error $e) { + echo $e->getMessage(), "\n"; +} + +echo "done\n"; +?> +--EXPECT-- +StreamPollHandle object is already constructed +Io\Poll\Context object is already constructed +done diff --git a/ext/standard/tests/poll/poll_stream_handle_close_then_free.phpt b/ext/standard/tests/poll/poll_stream_handle_close_then_free.phpt new file mode 100644 index 000000000000..9a97fad8dd1c --- /dev/null +++ b/ext/standard/tests/poll/poll_stream_handle_close_then_free.phpt @@ -0,0 +1,20 @@ +--TEST-- +Io\Poll: StreamPollHandle cleanup is safe when the stream is closed first +--FILE-- +add(new StreamPollHandle($r), [Io\Poll\Event::Read]); + +// Close the underlying streams before the watcher and handle are freed. +fclose($r); +fclose($w); + +unset($watcher, $ctx); +gc_collect_cycles(); +echo "ok\n"; +?> +--EXPECT-- +ok diff --git a/ext/standard/tests/poll/poll_stream_handle_fd_release.phpt b/ext/standard/tests/poll/poll_stream_handle_fd_release.phpt new file mode 100644 index 000000000000..66d042c17138 --- /dev/null +++ b/ext/standard/tests/poll/poll_stream_handle_fd_release.phpt @@ -0,0 +1,28 @@ +--TEST-- +Io\Poll: StreamPollHandle releases its stream resource (no fd leak) +--SKIPIF-- + +--FILE-- + +--EXPECT-- +bool(true) diff --git a/ext/standard/tests/poll/poll_watcher_gc_cycle.phpt b/ext/standard/tests/poll/poll_watcher_gc_cycle.phpt new file mode 100644 index 000000000000..590649acef8f --- /dev/null +++ b/ext/standard/tests/poll/poll_watcher_gc_cycle.phpt @@ -0,0 +1,31 @@ +--TEST-- +Io\Poll: cycle collector reclaims a Watcher referenced through its own data +--FILE-- +add(new StreamPollHandle($r), [Io\Poll\Event::Read]); + +$c = new Canary(); +$c->ref = $watcher; // Canary -> Watcher +$watcher->modifyData($c); // Watcher->data -> Canary (cycle) + +unset($ctx, $watcher, $c, $r, $w); + +echo "before gc\n"; +gc_collect_cycles(); +echo "after gc\n"; +?> +--EXPECT-- +before gc +Canary freed +after gc diff --git a/ext/standard/tests/poll/poll_watcher_outlives_context.phpt b/ext/standard/tests/poll/poll_watcher_outlives_context.phpt new file mode 100644 index 000000000000..2c058d5b8966 --- /dev/null +++ b/ext/standard/tests/poll/poll_watcher_outlives_context.phpt @@ -0,0 +1,35 @@ +--TEST-- +Io\Poll: Watcher operations are safe after its Context is destroyed +--FILE-- +add(new StreamPollHandle($r), [Io\Poll\Event::Read], "data"); + +// Drop the Context while still holding the Watcher it returned. +unset($ctx); +gc_collect_cycles(); + +var_dump($watcher->isActive()); + +try { + $watcher->remove(); +} catch (Io\Poll\InactiveWatcherException $e) { + echo $e->getMessage(), "\n"; +} + +try { + $watcher->modifyEvents([Io\Poll\Event::Write]); +} catch (Io\Poll\InactiveWatcherException $e) { + echo $e->getMessage(), "\n"; +} + +echo "done\n"; +?> +--EXPECT-- +bool(false) +Cannot remove inactive watcher +Cannot modify inactive watcher +done diff --git a/main/poll/poll_core.c b/main/poll/poll_core.c index 8422e46c9379..f985dbcdd3eb 100644 --- a/main/poll/poll_core.c +++ b/main/poll/poll_core.c @@ -191,7 +191,8 @@ PHPAPI php_poll_ctx *php_poll_create_by_name(const char *preferred_backend, uint /* Set event capacity hint (optional optimization) */ PHPAPI zend_result php_poll_set_max_events_hint(php_poll_ctx *ctx, int max_events) { - if (UNEXPECTED(!ctx || max_events <= 0)) { + ZEND_ASSERT(ctx); + if (UNEXPECTED(max_events <= 0)) { php_poll_set_error(ctx, PHP_POLL_ERR_INVALID); return FAILURE; } @@ -243,7 +244,8 @@ PHPAPI void php_poll_destroy(php_poll_ctx *ctx) /* Add file descriptor */ PHPAPI zend_result php_poll_add(php_poll_ctx *ctx, int fd, uint32_t events, void *data) { - if (UNEXPECTED(!ctx || !ctx->initialized || fd < 0)) { + ZEND_ASSERT(ctx); + if (UNEXPECTED(!ctx->initialized || fd < 0)) { php_poll_set_error(ctx, PHP_POLL_ERR_INVALID); return FAILURE; } @@ -259,7 +261,8 @@ PHPAPI zend_result php_poll_add(php_poll_ctx *ctx, int fd, uint32_t events, void /* Modify file descriptor */ PHPAPI zend_result php_poll_modify(php_poll_ctx *ctx, int fd, uint32_t events, void *data) { - if (UNEXPECTED(!ctx || !ctx->initialized || fd < 0)) { + ZEND_ASSERT(ctx); + if (UNEXPECTED(!ctx->initialized || fd < 0)) { php_poll_set_error(ctx, PHP_POLL_ERR_INVALID); return FAILURE; } @@ -275,7 +278,8 @@ PHPAPI zend_result php_poll_modify(php_poll_ctx *ctx, int fd, uint32_t events, v /* Remove file descriptor */ PHPAPI zend_result php_poll_remove(php_poll_ctx *ctx, int fd) { - if (UNEXPECTED(!ctx || !ctx->initialized || fd < 0)) { + ZEND_ASSERT(ctx); + if (UNEXPECTED(!ctx->initialized || fd < 0)) { php_poll_set_error(ctx, PHP_POLL_ERR_INVALID); return FAILURE; } @@ -292,7 +296,8 @@ PHPAPI zend_result php_poll_remove(php_poll_ctx *ctx, int fd) PHPAPI int php_poll_wait(php_poll_ctx *ctx, php_poll_event *events, int max_events, const struct timespec *timeout) { - if (UNEXPECTED(!ctx || !ctx->initialized || !events || max_events <= 0)) { + ZEND_ASSERT(ctx); + if (UNEXPECTED(!ctx->initialized || !events || max_events <= 0)) { php_poll_set_error(ctx, PHP_POLL_ERR_INVALID); return -1; } From d07cc59650865b0d6c87287a7c484bde94cddcff Mon Sep 17 00:00:00 2001 From: Ilija Tovilo Date: Thu, 18 Jun 2026 19:46:18 +0200 Subject: [PATCH 6/7] Fix doc blocks in io_poll.stub.php /* */ comments are ignored by the stub parser. --- ext/standard/io_poll.stub.php | 6 +++--- ext/standard/io_poll_arginfo.h | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/ext/standard/io_poll.stub.php b/ext/standard/io_poll.stub.php index 689099a0b398..82bc00e0aaca 100644 --- a/ext/standard/io_poll.stub.php +++ b/ext/standard/io_poll.stub.php @@ -39,7 +39,7 @@ interface Handle { } - /* + /** * @strict-properties * @not-serializable */ @@ -70,7 +70,7 @@ public function modifyData(mixed $data): void {} public function remove(): void {} } - /* + /** * @strict-properties * @not-serializable */ @@ -145,7 +145,7 @@ class InvalidHandleException extends PollException {} } namespace { - /* + /** * @strict-properties * @not-serializable */ diff --git a/ext/standard/io_poll_arginfo.h b/ext/standard/io_poll_arginfo.h index 5272a2b2739b..ced48021eaad 100644 --- a/ext/standard/io_poll_arginfo.h +++ b/ext/standard/io_poll_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit io_poll.stub.php instead. - * Stub hash: 3791f4b8eefbab06da9386cff3c82974a3c080a2 */ + * Stub hash: 4383509df1f1ebcbf6188feb346cbe4805bb0cc5 */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Io_Poll_Backend_getAvailableBackends, 0, 0, IS_ARRAY, 0) ZEND_END_ARG_INFO() @@ -195,7 +195,7 @@ static zend_class_entry *register_class_Io_Poll_Watcher(void) zend_class_entry ce, *class_entry; INIT_NS_CLASS_ENTRY(ce, "Io\\Poll", "Watcher", class_Io_Poll_Watcher_methods); - class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL); + class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL|ZEND_ACC_NO_DYNAMIC_PROPERTIES|ZEND_ACC_NOT_SERIALIZABLE); return class_entry; } @@ -205,7 +205,7 @@ static zend_class_entry *register_class_Io_Poll_Context(void) zend_class_entry ce, *class_entry; INIT_NS_CLASS_ENTRY(ce, "Io\\Poll", "Context", class_Io_Poll_Context_methods); - class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL); + class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL|ZEND_ACC_NO_DYNAMIC_PROPERTIES|ZEND_ACC_NOT_SERIALIZABLE); return class_entry; } @@ -387,7 +387,7 @@ static zend_class_entry *register_class_StreamPollHandle(zend_class_entry *class zend_class_entry ce, *class_entry; INIT_CLASS_ENTRY(ce, "StreamPollHandle", class_StreamPollHandle_methods); - class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL); + class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL|ZEND_ACC_NO_DYNAMIC_PROPERTIES|ZEND_ACC_NOT_SERIALIZABLE); zend_class_implements(class_entry, 1, class_entry_Io_Poll_Handle); return class_entry; From 6e989d23d5e46d7abb85064f3bf660cf87b50e3b Mon Sep 17 00:00:00 2001 From: Ilija Tovilo Date: Thu, 18 Jun 2026 20:05:49 +0200 Subject: [PATCH 7/7] Name all unnamed structs in main/poll --- ext/standard/io_poll.c | 6 +++--- main/poll/poll_backend_epoll.c | 2 +- main/poll/poll_backend_eventport.c | 4 ++-- main/poll/poll_backend_kqueue.c | 2 +- main/poll/poll_backend_poll.c | 4 ++-- main/poll/poll_backend_wsapoll.c | 4 ++-- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/ext/standard/io_poll.c b/ext/standard/io_poll.c index 693f72eaee7a..4bb7f6a80688 100644 --- a/ext/standard/io_poll.c +++ b/ext/standard/io_poll.c @@ -44,7 +44,7 @@ static zend_object_handlers php_io_poll_watcher_object_handlers; static zend_object_handlers php_io_poll_handle_object_handlers; /* Watcher object structure */ -typedef struct { +typedef struct php_io_poll_watcher_object { php_poll_handle_object *handle; uint32_t watched_events; uint32_t triggered_events; @@ -55,14 +55,14 @@ typedef struct { } php_io_poll_watcher_object; /* Context object structure */ -typedef struct { +typedef struct php_io_poll_context_object { php_poll_ctx *ctx; HashTable *watchers; /* Maps handle pointer -> watcher object */ zend_object std; } php_io_poll_context_object; /* Stream poll handle specific data */ -typedef struct { +typedef struct php_stream_poll_handle_data { php_stream *stream; zend_resource *res; } php_stream_poll_handle_data; diff --git a/main/poll/poll_backend_epoll.c b/main/poll/poll_backend_epoll.c index b0dbc4c7dbcf..685339da77d2 100644 --- a/main/poll/poll_backend_epoll.c +++ b/main/poll/poll_backend_epoll.c @@ -18,7 +18,7 @@ #include -typedef struct { +typedef struct epoll_backend_data { int epoll_fd; struct epoll_event *events; int events_capacity; diff --git a/main/poll/poll_backend_eventport.c b/main/poll/poll_backend_eventport.c index f3bb3fa66e34..35e7c61326f1 100644 --- a/main/poll/poll_backend_eventport.c +++ b/main/poll/poll_backend_eventport.c @@ -22,7 +22,7 @@ #include #include -typedef struct { +typedef struct eventport_backend_data { int port_fd; port_event_t *events; int events_capacity; @@ -212,7 +212,7 @@ static zend_result eventport_backend_remove(php_poll_ctx *ctx, int fd) } /* Callback context for associating fds */ -typedef struct { +typedef struct eventport_associate_ctx { eventport_backend_data_t *backend_data; php_poll_ctx *ctx; bool has_error; diff --git a/main/poll/poll_backend_kqueue.c b/main/poll/poll_backend_kqueue.c index 9a654c716d56..3d8e1feb4734 100644 --- a/main/poll/poll_backend_kqueue.c +++ b/main/poll/poll_backend_kqueue.c @@ -27,7 +27,7 @@ #define KQUEUE_FD_GARBAGE_WRITE (1 << 3) /* Write filter fired, needs read cleanup */ #define KQUEUE_FD_HAS_GARBAGE (KQUEUE_FD_GARBAGE_READ | KQUEUE_FD_GARBAGE_WRITE) -typedef struct { +typedef struct kqueue_backend_data { int kqueue_fd; struct kevent *events; int events_capacity; diff --git a/main/poll/poll_backend_poll.c b/main/poll/poll_backend_poll.c index 311c48529bc7..cca81b6fc4fb 100644 --- a/main/poll/poll_backend_poll.c +++ b/main/poll/poll_backend_poll.c @@ -16,7 +16,7 @@ #ifndef PHP_WIN32 -typedef struct { +typedef struct poll_backend_data { php_poll_fd_table *fd_table; struct pollfd *temp_fds; int temp_fds_capacity; @@ -162,7 +162,7 @@ static zend_result poll_backend_remove(php_poll_ctx *ctx, int fd) } /* Context for building struct pollfd array */ -typedef struct { +typedef struct poll_build_context { struct pollfd *fds; int index; } poll_build_context; diff --git a/main/poll/poll_backend_wsapoll.c b/main/poll/poll_backend_wsapoll.c index d8135d7f32a8..101a7cfe0e4b 100644 --- a/main/poll/poll_backend_wsapoll.c +++ b/main/poll/poll_backend_wsapoll.c @@ -19,7 +19,7 @@ #include #include -typedef struct { +typedef struct wsapoll_backend_data { php_poll_fd_table *fd_table; WSAPOLLFD *temp_fds; int temp_fds_capacity; @@ -167,7 +167,7 @@ static zend_result wsapoll_backend_remove(php_poll_ctx *ctx, int fd) } /* Context for building WSAPOLLFD array */ -typedef struct { +typedef struct wsapoll_build_context { WSAPOLLFD *fds; int index; } wsapoll_build_context;