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);