Commit 3eaa3387c25 for php.net

commit 3eaa3387c25d442b7dfc87a25fa8592ea99c6b26
Author: Jakub Zelenka <bukka@php.net>
Date:   Sun Jul 12 22:16:06 2026 +0200

    Fix flaky poll socket close tests on Solaris event ports (#22708)

    After the peer closes, Solaris event ports fire a one-shot snapshot as soon
    as the socket is writable and may not yet have folded the peer close into a
    POLLHUP, so the wait() sometimes reports Write without HangUp.

    Make HangUp optional for the EventPorts backend in the affected tests while
    keeping Write (and all other backends) strict. The pt_events_equal() helper
    now treats a nested array in the expected events as an optional slot, and
    pt_event_array_to_string() renders it in brackets. A manual self-test for
    this matching logic is included in poll.inc (runs only when the file is
    executed directly, never in CI).

diff --git a/ext/standard/tests/poll/poll.inc b/ext/standard/tests/poll/poll.inc
index a1ca01fc7d4..ce001220bba 100644
--- a/ext/standard/tests/poll/poll.inc
+++ b/ext/standard/tests/poll/poll.inc
@@ -106,7 +106,12 @@ function pt_write_sleep($stream, $data, $delay = 10000): int|false {
 function pt_event_array_to_string(array $events): string {
     $names = [];
     foreach ($events as $event) {
-        $names[] = $event->name;
+        if (is_array($event)) {
+            // Optional slot, rendered in brackets.
+            $names[] = '[' . pt_event_array_to_string($event) . ']';
+        } else {
+            $names[] = $event->name;
+        }
     }
     return empty($names) ? 'NONE' : implode('|', $names);
 }
@@ -242,17 +247,48 @@ function pt_events_equal($actual, $expected): bool {
         return false;
     }

-    if (count($actual) !== count($expected)) {
-        return false;
+    // An expected item that is itself an array marks an optional slot: it may be
+    // absent, or filled by exactly one of the events it lists. Plain enums are
+    // required. This lets a backend accept e.g. Write with an optional HangUp
+    // that may not have propagated yet, without weakening the required events.
+    $required = [];
+    $optional_groups = [];
+    foreach ($expected as $item) {
+        if (is_array($item)) {
+            $optional_groups[] = array_map(fn($e) => $e->name, $item);
+        } else {
+            $required[] = $item->name;
+        }
     }

-    // Sort both arrays by event name for comparison
     $actual_names = array_map(fn($e) => $e->name, $actual);
-    $expected_names = array_map(fn($e) => $e->name, $expected);
-    sort($actual_names);
-    sort($expected_names);

-    return $actual_names === $expected_names;
+    // Every required event must be present.
+    foreach ($required as $name) {
+        $idx = array_search($name, $actual_names, true);
+        if ($idx === false) {
+            return false;
+        }
+        unset($actual_names[$idx]);
+    }
+
+    // Each leftover actual event must be accounted for by a distinct optional
+    // slot that lists it.
+    foreach ($actual_names as $name) {
+        $matched = false;
+        foreach ($optional_groups as $gi => $group) {
+            if (in_array($name, $group, true)) {
+                unset($optional_groups[$gi]);
+                $matched = true;
+                break;
+            }
+        }
+        if (!$matched) {
+            return false;
+        }
+    }
+
+    return true;
 }

 function pt_resolve_backend_specific_value($backend_map, $current_backend) {
@@ -319,3 +355,68 @@ function pt_print_mismatched_events($actual_watchers, $expected_watchers, $match
         echo $match_status . "\n";
     }
 }
+
+/*
+ * Self-test for the pt_* event-matching helpers.
+ *
+ * This is deliberately NOT a .phpt: the CI only runs .phpt files, never .inc,
+ * so this never executes there. It only runs when poll.inc is executed as the
+ * main script, which lets the (fiddly) optional-slot matching logic be verified
+ * by hand:
+ *
+ *     sapi/cli/php ext/standard/tests/poll/poll.inc
+ */
+function pt_run_self_test(): void {
+    if (!enum_exists('Io\\Poll\\Event')) {
+        echo "Io\\Poll\\Event not available; build with poll support to run the self-test\n";
+        return;
+    }
+
+    $R = Io\Poll\Event::Read;
+    $W = Io\Poll\Event::Write;
+    $E = Io\Poll\Event::Error;
+    $H = Io\Poll\Event::HangUp;
+
+    $failures = 0;
+    $check = function (string $label, bool $cond) use (&$failures) {
+        if ($cond) {
+            echo "ok   - $label\n";
+        } else {
+            echo "FAIL - $label\n";
+            ++$failures;
+        }
+    };
+
+    // Plain required sets are order independent and exact.
+    $check('exact set matches', pt_events_equal([$W, $H], [$H, $W]));
+    $check('missing required fails', !pt_events_equal([$W], [$W, $H]));
+    $check('extra unexpected fails', !pt_events_equal([$W, $H], [$W]));
+
+    // Optional slot: [$W, [$H]] means Write required, HangUp allowed but not
+    // required.
+    $check('optional present matches', pt_events_equal([$W, $H], [$W, [$H]]));
+    $check('optional absent matches', pt_events_equal([$W], [$W, [$H]]));
+    $check('optional cannot satisfy a required event', !pt_events_equal([$H], [$W, [$H]]));
+    $check('unlisted extra with optional fails', !pt_events_equal([$W, $E], [$W, [$H]]));
+
+    // A single optional slot accepts at most one of its listed events; separate
+    // slots can each accept one.
+    $check('optional picks one of group', pt_events_equal([$W, $H], [$W, [$H, $E]]));
+    $check('optional group may stay empty', pt_events_equal([$W], [$W, [$H, $E]]));
+    $check('single slot rejects two', !pt_events_equal([$W, $H, $E], [$W, [$H, $E]]));
+    $check('two slots accept two', pt_events_equal([$W, $H, $E], [$W, [$H], [$E]]));
+
+    // Rendering.
+    $check('renders plain set', pt_event_array_to_string([$W, $H]) === 'Write|HangUp');
+    $check('renders optional slot', pt_event_array_to_string([$W, [$H]]) === 'Write|[HangUp]');
+    $check('renders empty as NONE', pt_event_array_to_string([]) === 'NONE');
+
+    echo $failures === 0
+        ? "\nAll self-tests passed\n"
+        : "\n$failures self-test(s) FAILED\n";
+}
+
+if (isset($_SERVER['SCRIPT_FILENAME'])
+        && realpath($_SERVER['SCRIPT_FILENAME']) === __FILE__) {
+    pt_run_self_test();
+}
diff --git a/ext/standard/tests/poll/poll_stream_sock_rw_close.phpt b/ext/standard/tests/poll/poll_stream_sock_rw_close.phpt
index 4edd1619d30..3909dea5863 100644
--- a/ext/standard/tests/poll/poll_stream_sock_rw_close.phpt
+++ b/ext/standard/tests/poll/poll_stream_sock_rw_close.phpt
@@ -17,7 +17,11 @@
     [
         'events' => [
             'default' => [Io\Poll\Event::Write, Io\Poll\Event::Error, Io\Poll\Event::HangUp],
-            'Kqueue|EventPorts' => [Io\Poll\Event::Write, Io\Poll\Event::HangUp],
+            'Kqueue' => [Io\Poll\Event::Write, Io\Poll\Event::HangUp],
+            // On Solaris event ports the peer close may not be folded into a
+            // POLLHUP in the same snapshot as the POLLOUT readiness, so HangUp
+            // is optional here.
+            'EventPorts' => [Io\Poll\Event::Write, [Io\Poll\Event::HangUp]],
         ],
         'data' => 'socket2_data'
     ]
diff --git a/ext/standard/tests/poll/poll_stream_sock_rw_multi_level.phpt b/ext/standard/tests/poll/poll_stream_sock_rw_multi_level.phpt
index 57fc335c26c..8bc8ea7fcc3 100644
--- a/ext/standard/tests/poll/poll_stream_sock_rw_multi_level.phpt
+++ b/ext/standard/tests/poll/poll_stream_sock_rw_multi_level.phpt
@@ -43,7 +43,16 @@

 fclose($socket1r);
 pt_expect_events($poll_ctx->wait(0, 100000), [
-    ['events' => [Io\Poll\Event::Write, Io\Poll\Event::HangUp], 'data' => 'socket2_data']
+    [
+        'events' => [
+            'default' => [Io\Poll\Event::Write, Io\Poll\Event::HangUp],
+            // On Solaris event ports the peer close may not be folded into a
+            // POLLHUP in the same snapshot as the POLLOUT readiness, so HangUp
+            // is optional here.
+            'EventPorts' => [Io\Poll\Event::Write, [Io\Poll\Event::HangUp]],
+        ],
+        'data' => 'socket2_data'
+    ]
 ], $poll_ctx);

 fclose($socket1w);