Commit c6c8db5a006 for php.net

commit c6c8db5a0061c352a9b208d0c00124978afb96b5
Author: Jakub Zelenka <bukka@php.net>
Date:   Sun Jul 12 20:20:53 2026 +0200

    main/poll: Fix kqueue event buffer overflow in grouped mode (#22606)

    Cap each kevent() request at the number of entries that can still be
    delivered instead of requesting twice the buffer size. Events that are
    not retrieved (including oneshot and edge-triggered ones) stay pending
    in the kqueue and are delivered by the next wait, matching the epoll
    behavior. Merged read+write events can under-fill the buffer, so top up
    with zero timeout rounds, and base the suitable max events on the
    filter count so a default sized wait retrieves everything in one call.

diff --git a/ext/standard/tests/poll/poll_stream_sock_rw_max_events.phpt b/ext/standard/tests/poll/poll_stream_sock_rw_max_events.phpt
new file mode 100644
index 00000000000..d78d3f4fde3
--- /dev/null
+++ b/ext/standard/tests/poll/poll_stream_sock_rw_max_events.phpt
@@ -0,0 +1,38 @@
+--TEST--
+Poll stream - socket read/write events with maxEvents equal to the FD count
+--FILE--
+<?php
+require_once __DIR__ . '/poll.inc';
+
+$poll_ctx = pt_new_stream_poll();
+
+// Each FD is ready for both read and write. Backends that report read and
+// write separately (kqueue) see twice as many events as FDs and have to merge
+// them without dropping any FD, filling only half of the maxEvents entries.
+$writers = [];
+$expected = [];
+for ($i = 0; $i < 4; $i++) {
+    list($reader, $writer) = pt_new_socket_pair();
+    $writers[$i] = $writer;
+    pt_stream_poll_add($poll_ctx, $reader, [Io\Poll\Event::Read, Io\Poll\Event::Write], "sock$i");
+    pt_write_sleep($writer, "test $i data");
+    $expected[] = [
+        'events' => [Io\Poll\Event::Read, Io\Poll\Event::Write],
+        'data' => "sock$i",
+        'read' => "test $i data",
+    ];
+}
+
+pt_expect_events($poll_ctx->wait(0, 100000, 8), $expected);
+
+// All read data was drained above, so only write events remain
+$expected = [];
+for ($i = 0; $i < 4; $i++) {
+    $expected[] = ['events' => [Io\Poll\Event::Write], 'data' => "sock$i"];
+}
+pt_expect_events($poll_ctx->wait(0, 100000, 8), $expected);
+
+?>
+--EXPECT--
+Events matched - count: 4
+Events matched - count: 4
diff --git a/ext/standard/tests/poll/poll_stream_tcp_read_max_events.phpt b/ext/standard/tests/poll/poll_stream_tcp_read_max_events.phpt
new file mode 100644
index 00000000000..f927e73ed4d
--- /dev/null
+++ b/ext/standard/tests/poll/poll_stream_tcp_read_max_events.phpt
@@ -0,0 +1,45 @@
+--TEST--
+Poll stream - TCP read with maxEvents smaller than the number of ready FDs
+--FILE--
+<?php
+require_once __DIR__ . '/poll.inc';
+
+list($clients, $servers) = pt_new_tcp_socket_connections(8);
+$poll_ctx = pt_new_stream_poll();
+
+for ($i = 0; $i < count($servers); $i++) {
+    pt_stream_poll_add($poll_ctx, $servers[$i], [Io\Poll\Event::Read], "server$i");
+}
+
+for ($i = 0; $i < count($clients); $i++) {
+    pt_write_sleep($clients[$i], "test $i data");
+}
+
+// 8 FDs are ready but only 4 events fit into the buffer, so the remaining
+// events must be delivered by the following wait() without any duplicates.
+$seen = [];
+for ($round = 1; $round <= 2; $round++) {
+    $watchers = $poll_ctx->wait(0, 100000, 4);
+    echo "Round $round count: " . count($watchers) . "\n";
+    foreach ($watchers as $watcher) {
+        $data = $watcher->getData();
+        if (isset($seen[$data])) {
+            echo "Duplicate event for $data\n";
+        }
+        $seen[$data] = true;
+        // Drain so level triggering does not re-report the FD
+        fread($watcher->getHandle()->getStream(), 1024);
+    }
+}
+
+ksort($seen);
+echo "Seen: " . implode(',', array_keys($seen)) . "\n";
+
+pt_expect_events($poll_ctx->wait(0), []);
+
+?>
+--EXPECT--
+Round 1 count: 4
+Round 2 count: 4
+Seen: server0,server1,server2,server3,server4,server5,server6,server7
+Events matched - count: 0
diff --git a/ext/standard/tests/poll/poll_stream_tcp_read_one_shot_max_events.phpt b/ext/standard/tests/poll/poll_stream_tcp_read_one_shot_max_events.phpt
new file mode 100644
index 00000000000..d7a2e48be6f
--- /dev/null
+++ b/ext/standard/tests/poll/poll_stream_tcp_read_one_shot_max_events.phpt
@@ -0,0 +1,46 @@
+--TEST--
+Poll stream - TCP read oneshot with maxEvents smaller than the number of ready FDs
+--FILE--
+<?php
+require_once __DIR__ . '/poll.inc';
+
+list($clients, $servers) = pt_new_tcp_socket_connections(8);
+$poll_ctx = pt_new_stream_poll();
+
+for ($i = 0; $i < count($servers); $i++) {
+    pt_stream_poll_add($poll_ctx, $servers[$i], [Io\Poll\Event::Read, Io\Poll\Event::OneShot], "server$i");
+}
+
+for ($i = 0; $i < count($clients); $i++) {
+    pt_write_sleep($clients[$i], "test $i data");
+}
+
+// 8 oneshot FDs are ready but only 4 events fit into the buffer. The events
+// that did not fit must not be consumed - the following wait() has to deliver
+// them, and each oneshot event must be delivered exactly once.
+$seen = [];
+for ($round = 1; $round <= 2; $round++) {
+    $watchers = $poll_ctx->wait(0, 100000, 4);
+    echo "Round $round count: " . count($watchers) . "\n";
+    foreach ($watchers as $watcher) {
+        $data = $watcher->getData();
+        if (isset($seen[$data])) {
+            echo "Duplicate event for $data\n";
+        }
+        $seen[$data] = true;
+    }
+}
+
+ksort($seen);
+echo "Seen: " . implode(',', array_keys($seen)) . "\n";
+
+// Every oneshot watcher fired once, so new data must not be reported
+pt_write_sleep($clients[0], "more data");
+pt_expect_events($poll_ctx->wait(0, 100000), []);
+
+?>
+--EXPECT--
+Round 1 count: 4
+Round 2 count: 4
+Seen: server0,server1,server2,server3,server4,server5,server6,server7
+Events matched - count: 0
diff --git a/main/poll/poll_backend_kqueue.c b/main/poll/poll_backend_kqueue.c
index 00ba7abc9ee..19ff0ad6d22 100644
--- a/main/poll/poll_backend_kqueue.c
+++ b/main/poll/poll_backend_kqueue.c
@@ -303,149 +303,179 @@ static int kqueue_backend_wait(
 {
 	kqueue_backend_data_t *backend_data = (kqueue_backend_data_t *) ctx->backend_data;

-	/* For raw events, we need capacity for max_events.
-	 * For grouped events, kqueue can return up to 2 events per FD, so we need 2x capacity. */
-	int required_capacity = ctx->raw_events ? max_events : (max_events * 2);
-	if (required_capacity > backend_data->events_capacity) {
+	/* Cap every kevent() request at the entries we can still deliver, like epoll's maxevents.
+	 * Unretrieved events stay pending in the kqueue (EV_ONESHOT deletes and EV_CLEAR resets only
+	 * at retrieval), so nothing is lost and the caller's buffer cannot overflow. */
+	if (max_events > backend_data->events_capacity) {
 		struct kevent *new_events = php_poll_realloc(
-				backend_data->events, required_capacity * sizeof(struct kevent), ctx->persistent);
+				backend_data->events, max_events * sizeof(struct kevent), ctx->persistent);
 		if (!new_events) {
 			php_poll_set_error(ctx, PHP_POLL_ERR_NOMEM);
 			return -1;
 		}
 		backend_data->events = new_events;
-		backend_data->events_capacity = required_capacity;
+		backend_data->events_capacity = max_events;
 	}

-	int nfds = kevent(
-			backend_data->kqueue_fd, NULL, 0, backend_data->events, required_capacity, timeout);
+	if (ctx->raw_events) {
+		/* Raw events mode - direct 1:1 mapping, no grouping */
+		int nfds = kevent(
+				backend_data->kqueue_fd, NULL, 0, backend_data->events, max_events, timeout);

-	if (nfds < 0) {
-		php_poll_set_current_errno_error(ctx);
-		return -1;
+		if (nfds < 0) {
+			php_poll_set_current_errno_error(ctx);
+			return -1;
+		}
+
+		for (int i = 0; i < nfds; i++) {
+			events[i].fd = (int) backend_data->events[i].ident;
+			events[i].events = 0;
+			events[i].revents = 0;
+			events[i].data = backend_data->events[i].udata;
+
+			/* Convert kqueue filter to poll event */
+			if (backend_data->events[i].filter == EVFILT_READ) {
+				events[i].revents |= PHP_POLL_READ;
+			} else if (backend_data->events[i].filter == EVFILT_WRITE) {
+				events[i].revents |= PHP_POLL_WRITE;
+			}
+
+			/* Convert kqueue flags to poll events */
+			if (backend_data->events[i].flags & EV_EOF) {
+				events[i].revents |= PHP_POLL_HUP;
+			}
+			if (backend_data->events[i].flags & EV_ERROR) {
+				events[i].revents |= PHP_POLL_ERROR;
+			}
+		}
+
+		return nfds;
 	}

-	if (nfds > 0) {
-		if (ctx->raw_events) {
-			/* Raw events mode - direct 1:1 mapping, no grouping */
-			for (int i = 0; i < nfds && i < max_events; i++) {
-				events[i].fd = (int) backend_data->events[i].ident;
-				events[i].events = 0;
-				events[i].revents = 0;
-				events[i].data = backend_data->events[i].udata;
-
-				/* Convert kqueue filter to poll event */
-				if (backend_data->events[i].filter == EVFILT_READ) {
-					events[i].revents |= PHP_POLL_READ;
-				} else if (backend_data->events[i].filter == EVFILT_WRITE) {
-					events[i].revents |= PHP_POLL_WRITE;
-				}
+	/* Grouped events mode with improved oneshot tracking.
+	 *
+	 * kqueue reports one event per (FD, filter) pair, so a READ+WRITE pair merges into a single
+	 * entry and one round can fill fewer than max_events entries while more events are pending.
+	 * Top up with zero-timeout rounds, each requesting only the remaining free entries. Both
+	 * FreeBSD and XNU re-enqueue delivered level-triggered events at the tail of the active queue,
+	 * so top-up rounds see not-yet-delivered events first, matching epoll's round-robin fill. */
+	int unique_events = 0, garbage_events = 0;
+	const struct timespec zero_timeout = {0, 0};
+	const struct timespec *round_timeout = timeout;
+
+	while (true) {
+		int slots = max_events - unique_events;
+		int nfds = kevent(
+				backend_data->kqueue_fd, NULL, 0, backend_data->events, slots, round_timeout);
+
+		if (nfds < 0) {
+			if (unique_events > 0) {
+				/* Deliver what was already gathered */
+				break;
+			}
+			php_poll_set_current_errno_error(ctx);
+			return -1;
+		}

-				/* Convert kqueue flags to poll events */
-				if (backend_data->events[i].flags & EV_EOF) {
-					events[i].revents |= PHP_POLL_HUP;
-				}
-				if (backend_data->events[i].flags & EV_ERROR) {
-					events[i].revents |= PHP_POLL_ERROR;
-				}
+		int new_unique_events = 0;
+
+		for (int i = 0; i < nfds; i++) {
+			int fd = (int) backend_data->events[i].ident;
+			uint32_t revents = 0;
+			void *data = backend_data->events[i].udata;
+			bool is_oneshot = (backend_data->events[i].flags & EV_ONESHOT) != 0;
+
+			/* Convert this event */
+			if (backend_data->events[i].filter == EVFILT_READ) {
+				revents |= PHP_POLL_READ;
+			} else if (backend_data->events[i].filter == EVFILT_WRITE) {
+				revents |= PHP_POLL_WRITE;
 			}
-			return nfds > max_events ? max_events : nfds;
-		} else {
-			/* Grouped events mode with improved oneshot tracking */
-			int unique_events = 0, garbage_events = 0, fd;
-
-			for (int i = 0; i < nfds; i++) {
-				fd = (int) backend_data->events[i].ident;
-				uint32_t revents = 0;
-				void *data = backend_data->events[i].udata;
-				bool is_oneshot = (backend_data->events[i].flags & EV_ONESHOT) != 0;
-
-				/* Convert this event */
-				if (backend_data->events[i].filter == EVFILT_READ) {
-					revents |= PHP_POLL_READ;
-				} else if (backend_data->events[i].filter == EVFILT_WRITE) {
-					revents |= PHP_POLL_WRITE;
-				}

-				if (backend_data->events[i].flags & EV_EOF) {
-					revents |= PHP_POLL_HUP;
-				}
-				if (backend_data->events[i].flags & EV_ERROR) {
-					revents |= PHP_POLL_ERROR;
-				}
+			if (backend_data->events[i].flags & EV_EOF) {
+				revents |= PHP_POLL_HUP;
+			}
+			if (backend_data->events[i].flags & EV_ERROR) {
+				revents |= PHP_POLL_ERROR;
+			}

-				/* Look for existing event for this FD */
-				bool found = false;
-				for (int j = 0; j < unique_events; j++) {
-					if (events[j].fd == fd) {
-						/* Combine with existing event */
-						events[j].revents |= revents;
-						found = true;
-						break;
-					}
+			/* Look for existing event for this FD */
+			bool found = false;
+			for (int j = 0; j < unique_events; j++) {
+				if (events[j].fd == fd) {
+					/* Combine with existing event */
+					events[j].revents |= revents;
+					found = true;
+					break;
 				}
+			}

-				if (!found) {
-					/* New FD, create new event */
-					ZEND_ASSERT(unique_events < max_events);
-					events[unique_events].fd = fd;
-					events[unique_events].events = 0;
-					events[unique_events].revents = revents;
-					events[unique_events].data = data;
-					unique_events++;
-
-					/* Handle oneshot tracking */
-					if (is_oneshot) {
-						zval *tracking = zend_hash_index_find(backend_data->fd_tracking, fd);
-						if (tracking && (Z_LVAL_P(tracking) & KQUEUE_FD_ONESHOT_COMPLETE)) {
-							/* Mark which filter fired for garbage collection */
-							zend_long flags = Z_LVAL_P(tracking);
-							flags &= ~KQUEUE_FD_ONESHOT_COMPLETE; /* Clear complete flag */
-							if (revents & PHP_POLL_READ) {
-								flags |= KQUEUE_FD_GARBAGE_READ; /* Need to clean write */
-							}
-							if (revents & PHP_POLL_WRITE) {
-								flags |= KQUEUE_FD_GARBAGE_WRITE; /* Need to clean read */
-							}
-							ZVAL_LONG(tracking, flags);
-							backend_data->fd_count--;
-							garbage_events++;
-						}
-					}
-				} else if (is_oneshot) {
-					/* Second filter for same FD fired - clear garbage flags */
+			if (!found) {
+				/* New FD, create new event */
+				ZEND_ASSERT(unique_events < max_events);
+				events[unique_events].fd = fd;
+				events[unique_events].events = 0;
+				events[unique_events].revents = revents;
+				events[unique_events].data = data;
+				unique_events++;
+				new_unique_events++;
+
+				/* Handle oneshot tracking */
+				if (is_oneshot) {
 					zval *tracking = zend_hash_index_find(backend_data->fd_tracking, fd);
-					if (tracking) {
-						/* Remove FD from tracking as it gets deleted from kqueue as well */
-						zend_hash_index_del(backend_data->fd_tracking, fd);
-						garbage_events--;
+					if (tracking && (Z_LVAL_P(tracking) & KQUEUE_FD_ONESHOT_COMPLETE)) {
+						/* Mark which filter fired for garbage collection */
+						zend_long flags = Z_LVAL_P(tracking);
+						flags &= ~KQUEUE_FD_ONESHOT_COMPLETE; /* Clear complete flag */
+						if (revents & PHP_POLL_READ) {
+							flags |= KQUEUE_FD_GARBAGE_READ; /* Need to clean write */
+						}
+						if (revents & PHP_POLL_WRITE) {
+							flags |= KQUEUE_FD_GARBAGE_WRITE; /* Need to clean read */
+						}
+						ZVAL_LONG(tracking, flags);
+						backend_data->fd_count--;
+						garbage_events++;
 					}
 				}
-			}
-
-			if (garbage_events > 0) {
-				/* Clean up orphaned filters from complete oneshot FDs */
-				struct kevent cleanup_change;
-				ZEND_HASH_FOREACH_NUM_KEY_VAL(backend_data->fd_tracking, zend_ulong fd_key, zval *tracking)
-				{
-					zend_long flags = Z_LVAL_P(tracking);
-					if (flags & KQUEUE_FD_HAS_GARBAGE) {
-						int filter = (flags & KQUEUE_FD_GARBAGE_READ) ? EVFILT_WRITE : EVFILT_READ;
-						EV_SET(&cleanup_change, fd_key, filter, EV_DELETE, 0, 0, NULL);
-						kevent(backend_data->kqueue_fd, &cleanup_change, 1, NULL, 0, NULL);
-
-						/* Remove FD from tracking after cleanup */
-						zend_hash_index_del(backend_data->fd_tracking, fd_key);
-					}
+			} else if (is_oneshot) {
+				/* Second filter for same FD fired - clear garbage flags */
+				zval *tracking = zend_hash_index_find(backend_data->fd_tracking, fd);
+				if (tracking) {
+					/* Remove FD from tracking as it gets deleted from kqueue as well */
+					zend_hash_index_del(backend_data->fd_tracking, fd);
+					garbage_events--;
 				}
-				ZEND_HASH_FOREACH_END();
 			}
+		}

-			return unique_events;
+		/* Stop on a partial round (drained or timed out), a full buffer, or a full round with no
+		 * new FDs (kernel re-reported level-triggered events already merged above - don't spin) */
+		if (nfds < slots || unique_events == max_events || new_unique_events == 0) {
+			break;
 		}
+		round_timeout = &zero_timeout;
 	}

-	return nfds;
+	if (garbage_events > 0) {
+		/* Clean up orphaned filters from complete oneshot FDs */
+		struct kevent cleanup_change;
+		ZEND_HASH_FOREACH_NUM_KEY_VAL(backend_data->fd_tracking, zend_ulong fd_key, zval *tracking)
+		{
+			zend_long flags = Z_LVAL_P(tracking);
+			if (flags & KQUEUE_FD_HAS_GARBAGE) {
+				int filter = (flags & KQUEUE_FD_GARBAGE_READ) ? EVFILT_WRITE : EVFILT_READ;
+				EV_SET(&cleanup_change, fd_key, filter, EV_DELETE, 0, 0, NULL);
+				kevent(backend_data->kqueue_fd, &cleanup_change, 1, NULL, 0, NULL);
+
+				/* Remove FD from tracking after cleanup */
+				zend_hash_index_del(backend_data->fd_tracking, fd_key);
+			}
+		}
+		ZEND_HASH_FOREACH_END();
+	}
+
+	return unique_events;
 }

 static bool kqueue_backend_is_available(void)
@@ -466,15 +496,11 @@ static int kqueue_backend_get_suitable_max_events(php_poll_ctx *ctx)
 		return -1;
 	}

-	if (ctx->raw_events) {
-		/* For raw events, return the total number of filters */
-		int active_filters = backend_data->filter_count;
-		return active_filters == 0 ? 1 : active_filters;
-	} else {
-		/* For grouped events, return the number of unique FDs */
-		int active_fds = backend_data->fd_count;
-		return active_fds == 0 ? 1 : active_fds;
-	}
+	/* kqueue reports one event per filter even in grouped mode (events are merged into
+	 * per-FD entries afterwards) and the wait request is capped at max_events, so size
+	 * for the total filter count to allow retrieving all pending events in one call. */
+	int active_filters = backend_data->filter_count;
+	return active_filters == 0 ? 1 : active_filters;
 }

 const php_poll_backend_ops php_poll_backend_kqueue_ops = {