@@ -106,7 +106,12 @@ function pt_write_sleep($stream, $data, $delay = 10000): int|false {
106106function 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
258294function 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+ }
0 commit comments