Skip to content

[IoPoll] Polyfill the Io\Poll API#612

Merged
fabpot merged 1 commit into
1.xfrom
polyfill-io-poll
Jul 5, 2026
Merged

[IoPoll] Polyfill the Io\Poll API#612
fabpot merged 1 commit into
1.xfrom
polyfill-io-poll

Conversation

@nicolas-grekas

@nicolas-grekas nicolas-grekas commented May 25, 2026

Copy link
Copy Markdown
Member

Tracks the Polling API RFC targeted at PHP 8.6.

Shipped as a dedicated symfony/polyfill-io-poll package (src/Io/Poll/), requiring PHP >= 8.1 (enums); the split repository and Packagist entry will need to be created before the next release. Surface:

  • Io\IoException, Io\Poll\PollException, abstract FailedPollOperationException (with ERROR_* constants), and the seven specialized exceptions (Failed{ContextInitialization,HandleAdd,WatcherModification,PollWait}Exception, BackendUnavailableException, InactiveWatcherException, HandleAlreadyWatchedException, InvalidHandleException).
  • Io\Poll\Backend enum with Auto, Poll, Epoll, Kqueue, EventPorts, WSAPoll cases and isAvailable() / supportsEdgeTriggering() / getAvailableBackends().
  • Io\Poll\Event enum with Read, Write, Error, HangUp, ReadHangUp, OneShot, EdgeTriggered.
  • Io\Poll\Handle marker interface (annotated @internal to discourage userland implementations).
  • Final Io\Poll\Context, Io\Poll\Watcher, and global StreamPollHandle, with private constructors, clone/double-construct/serialization guards throwing the native errors, and empty __debugInfo() like native; the watcher references its context through a WeakReference so that destroying the context deactivates its watchers like native refcounting does.

Backed by stream_select(), so only the Poll backend is available; Auto resolves to it and the other backends throw BackendUnavailableException. Behavior mirrors the native poll backend of io_poll.c / poll_backend_poll.c: exception messages and codes match, Event::EdgeTriggered is rejected with ERROR_NOSUPPORT, closed streams are dropped like POLLNVAL, a fired OneShot watcher stays active with modify*() throwing ERROR_NOTFOUND, HangUp (detected with stream_socket_recvfrom(..., STREAM_PEEK) and feof()) is reported alongside Read like POLLIN|POLLHUP, even when not watched, and never for regular files or datagram sockets, and a connection reset (peer closed with unread data, detected when peeking a readable socket fails) reports Error|HangUp like POLLERR|POLLHUP. Event::ReadHangUp is never emitted, matching the native poll backend. Known divergences are documented in the component README: TCP half-close reports Read|HangUp where native reports plain Read.

The test suite combines 64 unit tests with the 28 phpt tests of the native implementation, borrowed verbatim from php-src (ext/standard/tests/poll) and executed against the polyfill through a small PHPUnit runner; the phpt suite resolves its backend-specific expectations against the Poll backend, which is exactly what the polyfill emulates.

@nicolas-grekas nicolas-grekas force-pushed the polyfill-io-poll branch 5 times, most recently from 7e4b509 to b1dd831 Compare May 25, 2026 16:31
@nicolas-grekas nicolas-grekas force-pushed the polyfill-io-poll branch 5 times, most recently from 78ff76a to 789a5dd Compare June 30, 2026 11:26
@nicolas-grekas

Copy link
Copy Markdown
Member Author

@symfony/mergers this one is ready for review!

@alexandre-daubois alexandre-daubois left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing to say on my side, looks good!

@GromNaN GromNaN left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This polyfill is impressively thorough. Five conformity issues with the official stub or the RFC that should be addressed before merge.

Comment thread src/Io/Poll/Resources/stubs/Io/Poll/Context.php
Comment thread src/Io/Poll/Resources/stubs/Io/Poll/Watcher.php
Comment thread src/Php86/Resources/stubs/StreamPollHandle.php Outdated
Comment thread src/Php86/Resources/stubs/Io/Poll/Watcher.php Outdated
Comment thread src/Php86/Resources/stubs/Io/Poll/Context.php Outdated
Comment thread src/Io/Poll/Resources/stubs/Io/Poll/Context.php
if (Event::Write === $event) {
$write[$id] = $stream;
break;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When no handles are registered and $timeoutSeconds === null (infinite wait), the polyfill returns [] immediately without blocking. Native PHP's wait(null) blocks indefinitely regardless of how many handles are registered.

This is probably an unavoidable limitation of a pure-PHP polyfill, but it should be documented — a note in the README and/or a // Note: ... comment here so callers are not surprised.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one is backend-dependent in native, and the polyfill matches the backend it emulates. The poll, wsapoll and eventport backends explicitly guard the empty set and return immediately on a null timeout — e.g. poll_backend_poll.c:

int fd_count = php_poll_fd_table_count(backend_data->fd_table);
if (fd_count == 0) {
    if (timeout != NULL && (timeout->tv_sec > 0 || timeout->tv_nsec > 0)) {
        nanosleep(timeout, NULL);
    }
    return 0;
}

Only epoll and kqueue lack that guard and block indefinitely on an empty set. Since the polyfill is the Poll backend (getBackend() === Backend::Poll), its immediate return matches native-poll exactly, so there's no divergence for the emulated backend — I've dropped the README/comment note I'd briefly added.

The epoll/kqueue side looks like an upstream inconsistency (those two arguably want the same zero-fd guard the poll family has); we may raise it with the RFC author separately.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ping @bukka about this ☝️

Comment thread src/Php86/Resources/stubs/Io/Poll/Context.php Outdated
Comment thread tests/Php86/PollTest.php
@nicolas-grekas nicolas-grekas force-pushed the polyfill-io-poll branch 2 times, most recently from 3129891 to 1c929ed Compare July 1, 2026 13:51
@nicolas-grekas nicolas-grekas changed the title [Php86] Polyfill the Io\Poll API [IoPoll] Polyfill the Io\Poll API Jul 2, 2026
@nicolas-grekas

Copy link
Copy Markdown
Member Author

Status update on the changes pushed since the first review round:

Review fixes (amended into the first commit)

  • wait() now throws FailedPollWaitException when interrupted by a signal instead of returning [], matching io_poll.c (no EINTR retry in any backend). Covered by a test that triggers a real EINTR via pcntl_alarm().
  • The parentheses around enum cases in the tests had to stay: Backend::Auto->isAvailable() is a parse error before PHP 8.0 and PHPUnit parses every test file regardless of @requires, so dropping them broke the 7.2-7.4 jobs.

Native-parity rework (c32e070)
A close review against php-src (ext/standard/io_poll.c, main/poll/poll_backend_poll.c) surfaced ten behavioral divergences, all fixed:

  • StreamPollHandle::__construct() rejects closed resources with the native TypeError; isValid() also checks EOF like php_stream_poll_handle_is_valid().
  • Watcher::modify() no longer erases the user data when the argument is omitted; watched events are normalized to declaration order like the native bitmask; Event::EdgeTriggered is rejected with ERROR_NOSUPPORT.
  • After a OneShot watcher fires, only the fd entry is dropped: the watcher stays active, remove() succeeds and modify*() throws FailedWatcherModificationException with ERROR_NOTFOUND.
  • Destroying the Context deactivates its watchers; the watcher references the context through a WeakReference so this happens as eagerly as with native refcounting.
  • wait() drops watchers on closed streams like POLLNVAL and returns immediately instead of sleeping the whole timeout; wake-ups that map to no watched event keep waiting instead of returning [] early; the select except set is unused since it signals OOB data, which native never reports, so Event::Error is never emitted; HangUp is reported alongside Read like POLLIN|POLLHUP, never for regular files or datagram sockets, and even when not watched; ReadHangUp is never emitted since the native poll backend never requests POLLRDHUP.
  • Exception messages and codes match io_poll.c, including Poll wait failed, Cannot modify/remove inactive watcher, Invalid handle for polling and Failed to add handle.

Remaining divergences, documented in the component README: a TCP half-close reports Read|HangUp where native reports plain Read (the distinction is kernel-only), and hang-ups on write-only watchers are not detectable (peeking without readability would block).

Package split (c3756da)
The polyfill moved from Php86 to a dedicated symfony/polyfill-io-poll package (src/Io/Poll/) requiring PHP >= 8.1, since polyfill-php86 supports PHP >= 7.2. The split repository and Packagist entry will need to be created before the next release.

Tests went from 35 to 64, CI is green on the whole 7.2-8.6 matrix.

@nicolas-grekas

nicolas-grekas commented Jul 2, 2026

Copy link
Copy Markdown
Member Author

Now with phpt borrowed from php-src

Note the proposal: split this into its own symfony/polyfill-io-poll package.

@GromNaN GromNaN left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given the size of the package and the specific constraints, splitting it into a separate package makes sense.
Thank you for the work you put into creating this polyfill; I can't wait to start using it.

@fabpot

fabpot commented Jul 5, 2026

Copy link
Copy Markdown
Member

Thank you @nicolas-grekas.

@fabpot fabpot merged commit 174d00a into 1.x Jul 5, 2026
40 checks passed
@fabpot fabpot deleted the polyfill-io-poll branch July 5, 2026 07:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants