Skip to content

Fix Io\Poll memory-safety and error-handling issues#85

Closed
iliaal wants to merge 1 commit into
masterfrom
fix/io-poll-memory-safety
Closed

Fix Io\Poll memory-safety and error-handling issues#85
iliaal wants to merge 1 commit into
masterfrom
fix/io-poll-memory-safety

Conversation

@iliaal

@iliaal iliaal commented Jun 14, 2026

Copy link
Copy Markdown
Owner

Fixes memory-safety and error-handling bugs in the new Io\Poll API, found by review and confirmed under valgrind on the epoll backend.

  • Use-after-free: a Watcher kept a raw pointer to its Context's poll context with no reference, so dropping the Context while still holding a Watcher made remove()/modify() touch freed memory. The Context now clears its watchers (active=false, poll_ctx=NULL) before destruction, so those calls throw InactiveWatcherException.
  • Descriptor leak: StreamPollHandle referenced the stream resource in its constructor but never released it. Released in the handle cleanup.
  • Missing get_gc on Watcher and Context, so cycles through Watcher::$data leaked. Added for both.
  • epoll and poll wait() returned -1 without recording the error, so the thrown exception reported a stale code. Now set from errno.
  • kqueue grouped-event path guarded the result buffer with only a ZEND_ASSERT, allowing an out-of-bounds write in release builds when more distinct descriptors were ready than maxEvents. Bounded at runtime, matching the raw-event path. This one is from code inspection: the kqueue backend isn't compiled on Linux, so I couldn't reproduce it here, only the epoll/poll fixes are valgrind-confirmed.
  • NULL guard in php_poll_create_by_name().

Tests cover the UAF, the fd leak, and the get_gc cycle on Linux.

Several issues in the new Io\Poll API, found by review and confirmed
under valgrind:

- Watcher kept a raw pointer to its Context's php_poll_ctx with no
  reference, so dropping the Context while holding a Watcher left
  remove()/modify() dereferencing freed memory (use-after-free). The
  Context now neutralizes its watchers (active=false, poll_ctx=NULL)
  before it is destroyed, so those calls throw InactiveWatcherException.
- StreamPollHandle took a reference on the stream resource in the
  constructor but never released it, leaking the descriptor for the
  rest of the request. Store the zend_resource and release it in the
  handle cleanup; the php_stream may already be freed by then (e.g.
  the user closed it), so the cleanup must not dereference it.
- Watcher and Context had no get_gc handler, so reference cycles through
  Watcher::$data were uncollectable. Add get_gc for both.
- The epoll and poll backends returned -1 from wait() without recording
  the error, so the thrown exception carried a stale code. Set it from
  errno, which maps EINTR to the interrupted error.
- The kqueue grouped-event path bounded the result buffer only with a
  ZEND_ASSERT, allowing an out-of-bounds write in release builds when
  more distinct descriptors were ready than the caller's maxEvents. Cap
  it at runtime, matching the raw-event path.
- php_poll_create_by_name() dereferenced a NULL backend name; guard it.
@iliaal iliaal force-pushed the fix/io-poll-memory-safety branch from 9c54144 to ef34a6e Compare June 14, 2026 23:58
@iliaal

iliaal commented Jun 15, 2026

Copy link
Copy Markdown
Owner Author

Promoted upstream: php#22316.

@iliaal iliaal closed this Jun 15, 2026
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.

1 participant