Skip to content

Io\Poll: data buffered by the stream layer is not reported, and a stream closed while watched is never reported #23948

Open

Description

@frodeborli

Description

@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.

use Io\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 = new Context();
$poll->add(new StreamPollHandle($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:

use Io\Poll\{Context, Event};

[$a, $b] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
$poll    = new Context();
$watcher = $poll->add(new StreamPollHandle($a), [Event::Read]);

fclose($a); // elsewhere in the program, while something waits on $a
var_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.

PHP Version

PHP 8.7.0-dev (master)

Operating System

Linux (epoll backend)

Activity

  1. bukka commented on Sep 30, 2026

    @bukka
    Member

    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.

  2. frodeborli commented on Sep 30, 2026

    @frodeborli
    Author

    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 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()?

  3. frodeborli commented on Sep 30, 2026

    @frodeborli
    Author

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions