You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Io\Poll: data buffered by the stream layer is not reported, and a stream closed while watched is never reported #23948
@bukka — I've been writing a poller for phasync (a coroutine library) on top of Io\Poll, next to our stream_select() one, and ran our test suite against master. Two behaviours make a correct event loop hard to build on it, and both differ from stream_select():
1. Data already in the stream's read buffer isn't reported. After a read that buffered more than it returned (typical with fgets()), the socket is empty but the stream has unread data. stream_select() counts that stream as readable; Io\Poll only looks at the fd, so a loop that waits for readability before its next read never wakes.
useIo\Poll\{Context, Event};
[$a, $b] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
fwrite($b, "first\nsecond\n");
fgets($a); // "first\n"; "second\n" is now in $a's read buffer, the socket is empty$r = [$a]; $w = $e = null;
var_dump(stream_select($r, $w, $e, 0)); // int(1)$poll = newContext();
$poll->add(newStreamPollHandle($a), [Event::Read]);
var_dump(count($poll->wait(Time\Duration::fromMilliseconds(100)))); // int(0)
We work around it by checking stream_get_meta_data($s)['unread_bytes'] before every wait, but every user of the API will hit this. Perhaps wait() could return the watchers of streams with buffered read data at once (as stream_select() does through stream_array_emulate_read_fd_set()), with Event::Read triggered.
2. A stream closed while watched is never reported. Since #23822, fclose() retires the watcher (which fixed the use-after-free), but wait() never tells the loop. A coroutine or callback waiting on that stream waits forever, unless the loop scans every watcher's isActive() periodically and caps its waits to do so:
useIo\Poll\{Context, Event};
[$a, $b] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
$poll = newContext();
$watcher = $poll->add(newStreamPollHandle($a), [Event::Read]);
fclose($a); // elsewhere in the program, while something waits on $avar_dump($watcher->isActive()); // bool(false)var_dump(count($poll->wait(Time\Duration::fromMilliseconds(100)))); // int(0): nobody is told
With stream_select() the next call throws on the closed stream (TypeError, or ValueError when it was the only one), so the loop can find and wake its waiter. Perhaps the next wait() could return a watcher retired by a close once, inactive, (e.g. with Event::HangUp triggered), so the loop wakes its waiter without scanning.
Let me know if you'd like me to write a PR for either.
So I thought about this one quite a lot. For 1. it's a bit of pain because we now need to scan the watcher so initially I didn't want to do that but from the user point of view, it should be taken into the account and behave more like select so I got it implemented in #24017 . This is really needed just for read and level so we can still skip it in some cases.
I have been thinking even more about 2 and HangUp is just not right and it's not how the backends behave. The epoll normally silently just removes it and that's what is done here. My main issue with adding artifically HangUp is that it's not how HangUp behaves and it would also return inactive watchers which just does not seem right. We had this discussion before with Arnaud where he initially suggested special event for this which would be probably better than HangUp but it would still mix life cycle and state changes which I don't like. So at the end we came up with a solution for master and it's being proposed here: https://wiki.php.net/rfc/poll_api_additions#contextonwatcherremoved . That should resolve it but it won't be in PHP 8.6. In the meantime users can still check it but they need to keep track of watchers and then check isActive which is not great but I don't see another option how to handle it in 8.6.
On 2, I see it differently. A readiness API answers one question: which watched streams can I act on now without blocking? A stream that was closed while watched fits that. A read or write on it fails at once, but it never blocks. So from the loop's side the stream is ready. The waiter should be woken, try its operation, and get the error. Not reporting it is the one outcome where a waiter blocks forever.
The backends aren't uniform here either. poll() reports an fd that is no longer open as POLLNVAL, and select() fails with EBADF. epoll is the odd one out, because it tracks the open file description rather than the fd and drops it silently. So reporting it wouldn't be artificial. It's what poll() does.
I agree HangUp is the wrong name. A separate event, like Arnaud's earlier suggestion, returned once by the wait() after the close, keeps it within wait()'s contract: "this watcher can proceed without blocking". It's the same as POLLNVAL, just one level up.
onWatcherRemoved() would work for master. It does give the loop a second channel it has to merge with wait()'s results, though. And for 8.6 the gap stays: every loop has to track its watchers and scan isActive() on a capped wait. Could 8.6 at least report the retired watcher once from wait()?
Following up on 2: #24017 already added the path this needs. wait() now reports watchers the backend doesn't know about first, then asks the backend without waiting, and uses wait_gen to avoid duplicates. A watcher retired by a close could go through the same path.
php_io_poll_stream_notify_close() already visits exactly the watchers of the closing stream. If it put them on a small pending list on their Io\Poll\Context, the next wait() would report each one once, with its own event (Event::Invalid, the POLLNVAL equivalent, rather than HangUp), and then drop it. Nothing is scanned: the cost is proportional to the number of closes. No change to php_stream or the handle ops is needed, so it stays within the 8.6 ABI.
Loops would then get the close from the same wait() result as every other event, without tracking watchers or capping their waits. onWatcherRemoved() could still come in master for the lifecycle side.
Activity
Report Io\Poll level watchers with buffered stream data
bukka commentedon Sep 30, 2026
So I thought about this one quite a lot. For 1. it's a bit of pain because we now need to scan the watcher so initially I didn't want to do that but from the user point of view, it should be taken into the account and behave more like select so I got it implemented in #24017 . This is really needed just for read and level so we can still skip it in some cases.
I have been thinking even more about 2 and HangUp is just not right and it's not how the backends behave. The epoll normally silently just removes it and that's what is done here. My main issue with adding artifically HangUp is that it's not how HangUp behaves and it would also return inactive watchers which just does not seem right. We had this discussion before with Arnaud where he initially suggested special event for this which would be probably better than HangUp but it would still mix life cycle and state changes which I don't like. So at the end we came up with a solution for master and it's being proposed here: https://wiki.php.net/rfc/poll_api_additions#contextonwatcherremoved . That should resolve it but it won't be in PHP 8.6. In the meantime users can still check it but they need to keep track of watchers and then check isActive which is not great but I don't see another option how to handle it in 8.6.
frodeborli commentedon Sep 30, 2026
Thanks for #24017, that fixes 1 nicely.
On 2, I see it differently. A readiness API answers one question: which watched streams can I act on now without blocking? A stream that was closed while watched fits that. A read or write on it fails at once, but it never blocks. So from the loop's side the stream is ready. The waiter should be woken, try its operation, and get the error. Not reporting it is the one outcome where a waiter blocks forever.
The backends aren't uniform here either.
poll()reports an fd that is no longer open asPOLLNVAL, andselect()fails withEBADF. epoll is the odd one out, because it tracks the open file description rather than the fd and drops it silently. So reporting it wouldn't be artificial. It's whatpoll()does.I agree
HangUpis the wrong name. A separate event, like Arnaud's earlier suggestion, returned once by thewait()after the close, keeps it withinwait()'s contract: "this watcher can proceed without blocking". It's the same asPOLLNVAL, just one level up.onWatcherRemoved()would work for master. It does give the loop a second channel it has to merge withwait()'s results, though. And for 8.6 the gap stays: every loop has to track its watchers and scanisActive()on a capped wait. Could 8.6 at least report the retired watcher once fromwait()?frodeborli commentedon Sep 30, 2026
Following up on 2: #24017 already added the path this needs.
wait()now reports watchers the backend doesn't know about first, then asks the backend without waiting, and useswait_gento avoid duplicates. A watcher retired by a close could go through the same path.php_io_poll_stream_notify_close()already visits exactly the watchers of the closing stream. If it put them on a small pending list on theirIo\Poll\Context, the nextwait()would report each one once, with its own event (Event::Invalid, thePOLLNVALequivalent, rather thanHangUp), and then drop it. Nothing is scanned: the cost is proportional to the number of closes. No change tophp_streamor the handle ops is needed, so it stays within the 8.6 ABI.Loops would then get the close from the same
wait()result as every other event, without tracking watchers or capping their waits.onWatcherRemoved()could still come in master for the lifecycle side.