Commit fc84d165950 for php.net

commit fc84d165950d48031d8985f4839ff75a55e87f52
Author: Ilia Alshanetsky <ilia@ilia.ws>
Date:   Sun Jun 14 19:31:12 2026 -0400

    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

diff --git a/ext/standard/io_poll.c b/ext/standard/io_poll.c
index c500247dc8b..693f72eaee7 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 00000000000..f31c4c2ecd7
--- /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--
+<?php
+require_once __DIR__ . '/poll.inc';
+
+list($r, $w) = pt_new_socket_pair();
+$ctx = pt_new_stream_poll();
+$handle = new StreamPollHandle($r);
+$watcher = $ctx->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 00000000000..1fa5b65db38
--- /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--
+<?php
+require_once __DIR__ . '/poll.inc';
+
+list($r, $w) = pt_new_socket_pair();
+
+$handle = new StreamPollHandle($r);
+try {
+    $handle->__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 00000000000..9a97fad8dd1
--- /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--
+<?php
+require_once __DIR__ . '/poll.inc';
+
+list($r, $w) = pt_new_socket_pair();
+$ctx = pt_new_stream_poll();
+$watcher = $ctx->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 00000000000..66d042c1713
--- /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--
+<?php
+if (!is_dir('/proc/self/fd')) {
+    die("skip requires /proc/self/fd (Linux)\n");
+}
+?>
+--FILE--
+<?php
+function open_fds(): int {
+    return count(scandir('/proc/self/fd'));
+}
+
+$before = open_fds();
+for ($i = 0; $i < 100; $i++) {
+    list($r, $w) = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, 0);
+    $h = new StreamPollHandle($r);
+    unset($h, $r, $w);
+}
+gc_collect_cycles();
+$delta = open_fds() - $before;
+
+// Without the fix each handle pins its stream, leaking ~100 fds.
+var_dump($delta < 10);
+?>
+--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 00000000000..590649acef8
--- /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--
+<?php
+require_once __DIR__ . '/poll.inc';
+
+class Canary {
+    public $ref;
+    public function __destruct() {
+        echo "Canary freed\n";
+    }
+}
+
+list($r, $w) = pt_new_socket_pair();
+$ctx = pt_new_stream_poll();
+$watcher = $ctx->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 00000000000..2c058d5b896
--- /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--
+<?php
+require_once __DIR__ . '/poll.inc';
+
+list($r, $w) = pt_new_socket_pair();
+$ctx = pt_new_stream_poll();
+$watcher = $ctx->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 8422e46c937..f985dbcdd3e 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;
 	}