Skip to content

Commit 3eaa338

Browse files
authored
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).
1 parent dbf6d1f commit 3eaa338

3 files changed

Lines changed: 124 additions & 10 deletions

File tree

ext/standard/tests/poll/poll.inc

Lines changed: 109 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,12 @@ function pt_write_sleep($stream, $data, $delay = 10000): int|false {
106106
function pt_event_array_to_string(array $events): string {
107107
$names = [];
108108
foreach ($events as $event) {
109-
$names[] = $event->name;
109+
if (is_array($event)) {
110+
// Optional slot, rendered in brackets.
111+
$names[] = '[' . pt_event_array_to_string($event) . ']';
112+
} else {
113+
$names[] = $event->name;
114+
}
110115
}
111116
return empty($names) ? 'NONE' : implode('|', $names);
112117
}
@@ -242,17 +247,48 @@ function pt_events_equal($actual, $expected): bool {
242247
return false;
243248
}
244249

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

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

255-
return $actual_names === $expected_names;
266+
// Every required event must be present.
267+
foreach ($required as $name) {
268+
$idx = array_search($name, $actual_names, true);
269+
if ($idx === false) {
270+
return false;
271+
}
272+
unset($actual_names[$idx]);
273+
}
274+
275+
// Each leftover actual event must be accounted for by a distinct optional
276+
// slot that lists it.
277+
foreach ($actual_names as $name) {
278+
$matched = false;
279+
foreach ($optional_groups as $gi => $group) {
280+
if (in_array($name, $group, true)) {
281+
unset($optional_groups[$gi]);
282+
$matched = true;
283+
break;
284+
}
285+
}
286+
if (!$matched) {
287+
return false;
288+
}
289+
}
290+
291+
return true;
256292
}
257293

258294
function pt_resolve_backend_specific_value($backend_map, $current_backend) {
@@ -319,3 +355,68 @@ function pt_print_mismatched_events($actual_watchers, $expected_watchers, $match
319355
echo $match_status . "\n";
320356
}
321357
}
358+
359+
/*
360+
* Self-test for the pt_* event-matching helpers.
361+
*
362+
* This is deliberately NOT a .phpt: the CI only runs .phpt files, never .inc,
363+
* so this never executes there. It only runs when poll.inc is executed as the
364+
* main script, which lets the (fiddly) optional-slot matching logic be verified
365+
* by hand:
366+
*
367+
* sapi/cli/php ext/standard/tests/poll/poll.inc
368+
*/
369+
function pt_run_self_test(): void {
370+
if (!enum_exists('Io\\Poll\\Event')) {
371+
echo "Io\\Poll\\Event not available; build with poll support to run the self-test\n";
372+
return;
373+
}
374+
375+
$R = Io\Poll\Event::Read;
376+
$W = Io\Poll\Event::Write;
377+
$E = Io\Poll\Event::Error;
378+
$H = Io\Poll\Event::HangUp;
379+
380+
$failures = 0;
381+
$check = function (string $label, bool $cond) use (&$failures) {
382+
if ($cond) {
383+
echo "ok - $label\n";
384+
} else {
385+
echo "FAIL - $label\n";
386+
++$failures;
387+
}
388+
};
389+
390+
// Plain required sets are order independent and exact.
391+
$check('exact set matches', pt_events_equal([$W, $H], [$H, $W]));
392+
$check('missing required fails', !pt_events_equal([$W], [$W, $H]));
393+
$check('extra unexpected fails', !pt_events_equal([$W, $H], [$W]));
394+
395+
// Optional slot: [$W, [$H]] means Write required, HangUp allowed but not
396+
// required.
397+
$check('optional present matches', pt_events_equal([$W, $H], [$W, [$H]]));
398+
$check('optional absent matches', pt_events_equal([$W], [$W, [$H]]));
399+
$check('optional cannot satisfy a required event', !pt_events_equal([$H], [$W, [$H]]));
400+
$check('unlisted extra with optional fails', !pt_events_equal([$W, $E], [$W, [$H]]));
401+
402+
// A single optional slot accepts at most one of its listed events; separate
403+
// slots can each accept one.
404+
$check('optional picks one of group', pt_events_equal([$W, $H], [$W, [$H, $E]]));
405+
$check('optional group may stay empty', pt_events_equal([$W], [$W, [$H, $E]]));
406+
$check('single slot rejects two', !pt_events_equal([$W, $H, $E], [$W, [$H, $E]]));
407+
$check('two slots accept two', pt_events_equal([$W, $H, $E], [$W, [$H], [$E]]));
408+
409+
// Rendering.
410+
$check('renders plain set', pt_event_array_to_string([$W, $H]) === 'Write|HangUp');
411+
$check('renders optional slot', pt_event_array_to_string([$W, [$H]]) === 'Write|[HangUp]');
412+
$check('renders empty as NONE', pt_event_array_to_string([]) === 'NONE');
413+
414+
echo $failures === 0
415+
? "\nAll self-tests passed\n"
416+
: "\n$failures self-test(s) FAILED\n";
417+
}
418+
419+
if (isset($_SERVER['SCRIPT_FILENAME'])
420+
&& realpath($_SERVER['SCRIPT_FILENAME']) === __FILE__) {
421+
pt_run_self_test();
422+
}

ext/standard/tests/poll/poll_stream_sock_rw_close.phpt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ pt_expect_events($poll_ctx->wait(0, 100000), [
1717
[
1818
'events' => [
1919
'default' => [Io\Poll\Event::Write, Io\Poll\Event::Error, Io\Poll\Event::HangUp],
20-
'Kqueue|EventPorts' => [Io\Poll\Event::Write, Io\Poll\Event::HangUp],
20+
'Kqueue' => [Io\Poll\Event::Write, Io\Poll\Event::HangUp],
21+
// On Solaris event ports the peer close may not be folded into a
22+
// POLLHUP in the same snapshot as the POLLOUT readiness, so HangUp
23+
// is optional here.
24+
'EventPorts' => [Io\Poll\Event::Write, [Io\Poll\Event::HangUp]],
2125
],
2226
'data' => 'socket2_data'
2327
]

ext/standard/tests/poll/poll_stream_sock_rw_multi_level.phpt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,16 @@ pt_expect_events($poll_ctx->wait(0, 100000), [
4343

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

4958
fclose($socket1w);

0 commit comments

Comments
 (0)