Commit 6c6fb56fa63 for php.net

commit 6c6fb56fa6347ec372725adfa2864ba876b53632
Author: Jakub Zelenka <bukka@php.net>
Date:   Sun Mar 15 17:16:13 2026 +0100

    Introduce Io\Poll userspace API

    It makes use of internal polling API and exposes its functionality to
    user space.

    Closes GH-19572

diff --git a/NEWS b/NEWS
index eff542c0e46..efc85058719 100644
--- a/NEWS
+++ b/NEWS
@@ -92,6 +92,9 @@ PHP                                                                        NEWS
   . Fixed UConverter::transcode() silently truncating from_subst and to_subst
     option lengths greater than 127 bytes. (Weilin Du)

+- IO:
+  . Added new polling API. (Jakub Zelenka)
+
 - JSON:
   . Enriched JSON last error / exception message with error location.
     (Juan Morales)
diff --git a/UPGRADING b/UPGRADING
index 2ec6ce92aa5..bdadc6efbef 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -197,6 +197,10 @@ PHP 8.6 UPGRADE NOTES
     IntlNumberRangeFormatter::IDENTITY_FALLBACK_RANGE identity fallbacks.
     It is supported from icu 63.

+- IO:
+  . Added new polling API.
+    RFC: https://wiki.php.net/rfc/poll_api
+
 - JSON:
   . Added extra info about error location to the JSON error messages returned
     from json_last_error_msg() and JsonException message.
@@ -351,6 +355,23 @@ PHP 8.6 UPGRADE NOTES
   . enum StreamErrorMode
   . enum StreamErrorCode
     RFC: https://wiki.php.net/rfc/stream_errors
+  . Io\Poll\Context
+  . Io\Poll\Watcher
+  . enum Io\Poll\Backend
+  . enum Io\Poll\Event
+  . interface Io\Poll\Handle
+  . Io\IoException
+  . Io\Poll\PollException
+  . Io\Poll\FailedPollOperationException
+  . Io\Poll\FailedContextInitializationException
+  . Io\Poll\FailedHandleAddException
+  . Io\Poll\FailedWatcherModificationException
+  . Io\Poll\FailedPollWaitException
+  . Io\Poll\BackendUnavailableException
+  . Io\Poll\InactiveWatcherException
+  . Io\Poll\HandleAlreadyWatchedException
+  . Io\Poll\InvalidHandleException
+  . StreamPollHandle

 ========================================
 8. Removed Extensions and SAPIs
diff --git a/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt b/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt
index 9ef87f42b22..c497855f734 100644
--- a/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt
+++ b/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt
@@ -14,6 +14,22 @@
 --EXPECT--
 AssertionError
 Directory
+Io\IoException
+Io\Poll\Backend
+Io\Poll\BackendUnavailableException
+Io\Poll\Context
+Io\Poll\Event
+Io\Poll\FailedContextInitializationException
+Io\Poll\FailedHandleAddException
+Io\Poll\FailedPollOperationException
+Io\Poll\FailedPollWaitException
+Io\Poll\FailedWatcherModificationException
+Io\Poll\Handle
+Io\Poll\HandleAlreadyWatchedException
+Io\Poll\InactiveWatcherException
+Io\Poll\InvalidHandleException
+Io\Poll\PollException
+Io\Poll\Watcher
 RoundingMode
 SortDirection
 StreamBucket
@@ -22,5 +38,6 @@
 StreamErrorMode
 StreamErrorStore
 StreamException
+StreamPollHandle
 __PHP_Incomplete_Class
 php_user_filter
diff --git a/ext/standard/basic_functions.c b/ext/standard/basic_functions.c
index 0c1e2f8222a..f8b1cb5c4fd 100644
--- a/ext/standard/basic_functions.c
+++ b/ext/standard/basic_functions.c
@@ -302,6 +302,7 @@ PHP_MINIT_FUNCTION(basic) /* {{{ */
 	BASIC_MINIT_SUBMODULE(browscap)
 	BASIC_MINIT_SUBMODULE(standard_filters)
 	BASIC_MINIT_SUBMODULE(user_filters)
+	BASIC_MINIT_SUBMODULE(poll)
 	BASIC_MINIT_SUBMODULE(password)
 	BASIC_MINIT_SUBMODULE(image)

diff --git a/ext/standard/basic_functions.h b/ext/standard/basic_functions.h
index 6bd227eac5d..4d5a4c02aec 100644
--- a/ext/standard/basic_functions.h
+++ b/ext/standard/basic_functions.h
@@ -42,6 +42,7 @@ PHP_MINFO_FUNCTION(basic);

 ZEND_API void php_get_highlight_struct(zend_syntax_highlighter_ini *syntax_highlighter_ini);

+PHP_MINIT_FUNCTION(poll);
 PHP_MINIT_FUNCTION(user_filters);
 PHP_RSHUTDOWN_FUNCTION(user_filters);
 PHP_RSHUTDOWN_FUNCTION(browscap);
diff --git a/ext/standard/config.m4 b/ext/standard/config.m4
index 1d64c7ff696..67c36b93ba3 100644
--- a/ext/standard/config.m4
+++ b/ext/standard/config.m4
@@ -418,6 +418,7 @@ PHP_NEW_EXTENSION([standard], m4_normalize([
     image.c
     incomplete_class.c
     info.c
+    io_poll.c
     iptc.c
     levenshtein.c
     libavifinfo/avifinfo.c
diff --git a/ext/standard/config.w32 b/ext/standard/config.w32
index 589929027f6..50031ac3b8a 100644
--- a/ext/standard/config.w32
+++ b/ext/standard/config.w32
@@ -28,14 +28,15 @@ EXTENSION("standard", "array.c base64.c basic_functions.c browscap.c \
 	crypt_sha512.c  php_crypt_r.c " + (TARGET_ARCH != 'arm64'? " crc32_x86.c" : "") + " \
 	datetime.c dir.c dl.c dns.c dns_win32.c exec.c \
 	file.c filestat.c formatted_print.c fsock.c head.c html.c image.c \
-	info.c iptc.c link.c mail.c math.c md5.c metaphone.c microtime.c \
+	info.c io_poll.c iptc.c link.c mail.c math.c md5.c metaphone.c microtime.c \
 	net.c pack.c pageinfo.c quot_print.c soundex.c \
 	string.c scanf.c syslog.c type.c uniqid.c url.c var.c \
 	versioning.c assert.c strnatcmp.c levenshtein.c incomplete_class.c \
 	url_scanner_ex.c ftp_fopen_wrapper.c http_fopen_wrapper.c \
 	php_fopen_wrapper.c credits.c css.c var_unserializer.c ftok.c sha1.c \
 	user_filters.c uuencode.c filters.c proc_open.c password.c \
-	streamsfuncs.c http.c flock_compat.c hrtime.c", false /* never shared */,
+	streamsfuncs.c http.c flock_compat.c hrtime.c",
+	false /* never shared */,
 	'/DZEND_ENABLE_STATIC_TSRMLS_CACHE=1');
 ADD_SOURCES("ext/standard/libavifinfo", "avifinfo.c", "standard");
 PHP_STANDARD = "yes";
diff --git a/ext/standard/io_poll.c b/ext/standard/io_poll.c
new file mode 100644
index 00000000000..05e54696d69
--- /dev/null
+++ b/ext/standard/io_poll.c
@@ -0,0 +1,928 @@
+/*
+   +----------------------------------------------------------------------+
+   | Copyright (c) The PHP Group                                          |
+   +----------------------------------------------------------------------+
+   | This source file is subject to version 3.01 of the PHP license,      |
+   | that is bundled with this package in the file LICENSE, and is        |
+   | available through the world-wide-web at the following url:           |
+   | https://www.php.net/license/3_01.txt                                 |
+   | If you did not receive a copy of the PHP license and are unable to   |
+   | obtain it through the world-wide-web, please send a note to          |
+   | license@php.net so we can mail you a copy immediately.               |
+   +----------------------------------------------------------------------+
+   | Author: Jakub Zelenka <bukka@php.net>                                |
+   +----------------------------------------------------------------------+
+*/
+
+#include "php.h"
+#include "zend_enum.h"
+#include "zend_exceptions.h"
+#include "php_network.h"
+#include "php_poll.h"
+#include "io_poll_arginfo.h"
+
+/* Class entries */
+static zend_class_entry *php_io_poll_backend_class_entry;
+static zend_class_entry *php_io_poll_event_class_entry;
+static zend_class_entry *php_io_poll_context_class_entry;
+static zend_class_entry *php_io_poll_watcher_class_entry;
+static zend_class_entry *php_io_poll_handle_class_entry;
+static zend_class_entry *php_io_exception_class_entry;
+static zend_class_entry *php_io_poll_exception_class_entry;
+static zend_class_entry *php_io_poll_failed_backend_unavailable_class_entry;
+static zend_class_entry *php_io_poll_failed_operation_class_entry;
+static zend_class_entry *php_io_poll_failed_context_init_class_entry;
+static zend_class_entry *php_io_poll_failed_handle_add_class_entry;
+static zend_class_entry *php_io_poll_failed_watcher_mod_class_entry;
+static zend_class_entry *php_io_poll_failed_wait_class_entry;
+static zend_class_entry *php_io_poll_inactive_watcher_class_entry;
+static zend_class_entry *php_io_poll_handle_already_watched_class_entry;
+static zend_class_entry *php_io_poll_invalid_handle_class_entry;
+static zend_class_entry *php_stream_poll_handle_class_entry;
+
+/* Object handlers */
+static zend_object_handlers php_io_poll_context_object_handlers;
+static zend_object_handlers php_io_poll_watcher_object_handlers;
+static zend_object_handlers php_io_poll_handle_object_handlers;
+
+/* Watcher object structure */
+typedef struct {
+	php_poll_handle_object *handle;
+	uint32_t watched_events;
+	uint32_t triggered_events;
+	zval data;
+	bool active;
+	php_poll_ctx *poll_ctx; /* Back reference to poll context */
+	zend_object std;
+} php_io_poll_watcher_object;
+
+/* Context object structure */
+typedef struct {
+	php_poll_ctx *ctx;
+	HashTable *watchers; /* Maps handle pointer -> watcher object */
+	zend_object std;
+} php_io_poll_context_object;
+
+/* Stream poll handle specific data */
+typedef struct {
+	php_stream *stream;
+} php_stream_poll_handle_data;
+
+/* Accessor macros */
+#define PHP_POLL_CONTEXT_OBJ_FROM_ZOBJ(_obj) \
+	((php_io_poll_context_object *) ((char *) (_obj) - offsetof(php_io_poll_context_object, std)))
+
+#define PHP_POLL_WATCHER_OBJ_FROM_ZOBJ(_obj) \
+	((php_io_poll_watcher_object *) ((char *) (_obj) - offsetof(php_io_poll_watcher_object, std)))
+
+#define PHP_POLL_WATCHER_OBJ_FROM_ZV(_zv) PHP_POLL_WATCHER_OBJ_FROM_ZOBJ(Z_OBJ_P(_zv))
+#define PHP_POLL_CONTEXT_OBJ_FROM_ZV(_zv) PHP_POLL_CONTEXT_OBJ_FROM_ZOBJ(Z_OBJ_P(_zv))
+
+/* Helper to throw failed operation exceptions with error code */
+static inline void php_io_poll_throw_failed_operation(
+		zend_class_entry *ce, const char *message, php_poll_error error)
+{
+	zend_throw_exception(ce, message, (zend_long) error);
+}
+
+/* Event enum to bit mask mapping */
+static uint32_t php_io_poll_event_enum_to_bit(zend_object *event_enum)
+{
+	zval *case_name = zend_enum_fetch_case_name(event_enum);
+	const char *name = Z_STRVAL_P(case_name);
+
+	if (strcmp(name, "Read") == 0) {
+		return PHP_POLL_READ;
+	} else if (strcmp(name, "Write") == 0) {
+		return PHP_POLL_WRITE;
+	} else if (strcmp(name, "Error") == 0) {
+		return PHP_POLL_ERROR;
+	} else if (strcmp(name, "HangUp") == 0) {
+		return PHP_POLL_HUP;
+	} else if (strcmp(name, "ReadHangUp") == 0) {
+		return PHP_POLL_RDHUP;
+	} else if (strcmp(name, "OneShot") == 0) {
+		return PHP_POLL_ONESHOT;
+	} else if (strcmp(name, "EdgeTriggered") == 0) {
+		return PHP_POLL_ET;
+	}
+
+	return 0;
+}
+
+static uint32_t php_io_poll_event_enums_to_events(zval *event_enums)
+{
+	HashTable *ht;
+	zval *entry;
+	uint32_t events = 0;
+
+	if (Z_TYPE_P(event_enums) != IS_ARRAY) {
+		return 0;
+	}
+
+	ht = Z_ARRVAL_P(event_enums);
+
+	ZEND_HASH_FOREACH_VAL(ht, entry) {
+		if (Z_TYPE_P(entry) != IS_OBJECT
+				|| !instanceof_function(Z_OBJCE_P(entry), php_io_poll_event_class_entry)) {
+			return 0;
+		}
+		events |= php_io_poll_event_enum_to_bit(Z_OBJ_P(entry));
+	}
+	ZEND_HASH_FOREACH_END();
+
+	return events;
+}
+
+static zend_result php_io_poll_events_to_event_enums(uint32_t events, zval *event_enums)
+{
+	zval enum_case;
+
+	array_init(event_enums);
+
+	if (events & PHP_POLL_READ) {
+		ZVAL_OBJ(&enum_case, zend_enum_get_case_cstr(php_io_poll_event_class_entry, "Read"));
+		GC_ADDREF(Z_OBJ(enum_case));
+		add_next_index_zval(event_enums, &enum_case);
+	}
+	if (events & PHP_POLL_WRITE) {
+		ZVAL_OBJ(&enum_case, zend_enum_get_case_cstr(php_io_poll_event_class_entry, "Write"));
+		GC_ADDREF(Z_OBJ(enum_case));
+		add_next_index_zval(event_enums, &enum_case);
+	}
+	if (events & PHP_POLL_ERROR) {
+		ZVAL_OBJ(&enum_case, zend_enum_get_case_cstr(php_io_poll_event_class_entry, "Error"));
+		GC_ADDREF(Z_OBJ(enum_case));
+		add_next_index_zval(event_enums, &enum_case);
+	}
+	if (events & PHP_POLL_HUP) {
+		ZVAL_OBJ(&enum_case, zend_enum_get_case_cstr(php_io_poll_event_class_entry, "HangUp"));
+		GC_ADDREF(Z_OBJ(enum_case));
+		add_next_index_zval(event_enums, &enum_case);
+	}
+	if (events & PHP_POLL_RDHUP) {
+		ZVAL_OBJ(&enum_case, zend_enum_get_case_cstr(php_io_poll_event_class_entry, "ReadHangUp"));
+		GC_ADDREF(Z_OBJ(enum_case));
+		add_next_index_zval(event_enums, &enum_case);
+	}
+	if (events & PHP_POLL_ONESHOT) {
+		ZVAL_OBJ(&enum_case, zend_enum_get_case_cstr(php_io_poll_event_class_entry, "OneShot"));
+		GC_ADDREF(Z_OBJ(enum_case));
+		add_next_index_zval(event_enums, &enum_case);
+	}
+	if (events & PHP_POLL_ET) {
+		ZVAL_OBJ(&enum_case, zend_enum_get_case_cstr(php_io_poll_event_class_entry, "EdgeTriggered"));
+		GC_ADDREF(Z_OBJ(enum_case));
+		add_next_index_zval(event_enums, &enum_case);
+	}
+
+	return SUCCESS;
+}
+
+/* Backend enum name to backend type mapping */
+static php_poll_backend_type php_io_poll_backend_enum_to_type(zend_object *backend_enum)
+{
+	zval *case_name = zend_enum_fetch_case_name(backend_enum);
+	const char *name = Z_STRVAL_P(case_name);
+
+	if (strcmp(name, "Auto") == 0) {
+		return PHP_POLL_BACKEND_AUTO;
+	} else if (strcmp(name, "Poll") == 0) {
+		return PHP_POLL_BACKEND_POLL;
+	} else if (strcmp(name, "Epoll") == 0) {
+		return PHP_POLL_BACKEND_EPOLL;
+	} else if (strcmp(name, "Kqueue") == 0) {
+		return PHP_POLL_BACKEND_KQUEUE;
+	} else if (strcmp(name, "EventPorts") == 0) {
+		return PHP_POLL_BACKEND_EVENTPORT;
+	} else if (strcmp(name, "WSAPoll") == 0) {
+		return PHP_POLL_BACKEND_WSAPOLL;
+	}
+
+	return PHP_POLL_BACKEND_AUTO;
+}
+
+static const char *php_io_poll_backend_type_to_name(php_poll_backend_type type)
+{
+	switch (type) {
+		case PHP_POLL_BACKEND_POLL:
+			return "Poll";
+		case PHP_POLL_BACKEND_EPOLL:
+			return "Epoll";
+		case PHP_POLL_BACKEND_KQUEUE:
+			return "Kqueue";
+		case PHP_POLL_BACKEND_EVENTPORT:
+			return "EventPorts";
+		case PHP_POLL_BACKEND_WSAPOLL:
+			return "WSAPoll";
+		case PHP_POLL_BACKEND_AUTO:
+		default:
+			return "Auto";
+	}
+}
+
+/* Stream Poll Handle Implementation */
+
+static php_socket_t php_stream_poll_handle_get_fd(php_poll_handle_object *handle)
+{
+	php_stream_poll_handle_data *data = (php_stream_poll_handle_data *) handle->handle_data;
+	php_socket_t fd;
+
+	if (!data || !data->stream) {
+		return SOCK_ERR;
+	}
+
+	if (php_stream_cast(data->stream, PHP_STREAM_AS_FD_FOR_SELECT | PHP_STREAM_CAST_INTERNAL,
+				(void *) &fd, 1)
+					!= SUCCESS
+			|| fd == -1) {
+		return SOCK_ERR;
+	}
+
+	return fd;
+}
+
+static int php_stream_poll_handle_is_valid(php_poll_handle_object *handle)
+{
+	php_stream_poll_handle_data *data = (php_stream_poll_handle_data *) handle->handle_data;
+	return data && data->stream && !php_stream_eof(data->stream);
+}
+
+static void php_stream_poll_handle_cleanup(php_poll_handle_object *handle)
+{
+	php_stream_poll_handle_data *data = (php_stream_poll_handle_data *) handle->handle_data;
+	if (data) {
+		/* Don't close the stream - user still owns it */
+		efree(data);
+		handle->handle_data = NULL;
+	}
+}
+
+static php_poll_handle_ops php_stream_poll_handle_ops = {
+	.get_fd = php_stream_poll_handle_get_fd,
+	.is_valid = php_stream_poll_handle_is_valid,
+	.cleanup = php_stream_poll_handle_cleanup
+};
+
+/* Handle interface internal only */
+static int php_stream_poll_handle_implement_interface(zend_class_entry *interface, zend_class_entry *implementor)
+{
+	if (implementor->type == ZEND_USER_CLASS) {
+		zend_error_noreturn(E_ERROR, "Io\\Poll\\Handle cannot be implemented by user classes");
+		return FAILURE;
+	}
+
+	return SUCCESS;
+}
+
+/* Object Creation Functions */
+
+static zend_object *php_stream_poll_handle_create_object(zend_class_entry *ce)
+{
+	php_poll_handle_object *intern = php_poll_handle_object_create(
+			sizeof(php_poll_handle_object), ce, &php_stream_poll_handle_ops);
+	return &intern->std;
+}
+
+static zend_object *php_io_poll_watcher_create_object(zend_class_entry *ce)
+{
+	php_io_poll_watcher_object *intern = zend_object_alloc(sizeof(php_io_poll_watcher_object), ce);
+
+	zend_object_std_init(&intern->std, ce);
+	object_properties_init(&intern->std, ce);
+
+	intern->handle = NULL;
+	intern->watched_events = 0;
+	intern->triggered_events = 0;
+	intern->active = false;
+	intern->poll_ctx = NULL;
+	ZVAL_NULL(&intern->data);
+
+	return &intern->std;
+}
+
+static zend_object *php_io_poll_context_create_object(zend_class_entry *ce)
+{
+	php_io_poll_context_object *intern = zend_object_alloc(sizeof(php_io_poll_context_object), ce);
+
+	zend_object_std_init(&intern->std, ce);
+	object_properties_init(&intern->std, ce);
+
+	intern->ctx = NULL;
+	intern->watchers = NULL;
+
+	return &intern->std;
+}
+
+/* Object Destruction Functions */
+
+static void php_io_poll_watcher_free_object(zend_object *obj)
+{
+	php_io_poll_watcher_object *intern = PHP_POLL_WATCHER_OBJ_FROM_ZOBJ(obj);
+
+	zval_ptr_dtor(&intern->data);
+
+	if (intern->handle) {
+		OBJ_RELEASE(&intern->handle->std);
+	}
+
+	zend_object_std_dtor(&intern->std);
+}
+
+static void php_io_poll_context_free_object(zend_object *obj)
+{
+	php_io_poll_context_object *intern = PHP_POLL_CONTEXT_OBJ_FROM_ZOBJ(obj);
+
+	if (intern->ctx) {
+		php_poll_destroy(intern->ctx);
+	}
+
+	if (intern->watchers) {
+		zend_hash_destroy(intern->watchers);
+		efree(intern->watchers);
+	}
+
+	zend_object_std_dtor(&intern->std);
+}
+
+/* Utility functions */
+
+static zend_always_inline zend_ulong php_io_poll_compute_ptr_key(void *ptr)
+{
+	zend_ulong key = (zend_ulong) (uintptr_t) ptr;
+	return (key >> 3) | (key << ((sizeof(key) * 8) - 3));
+}
+
+static zend_result php_io_poll_watcher_modify_events(
+		php_io_poll_watcher_object *watcher, uint32_t events)
+{
+	if (!watcher->active || !watcher->poll_ctx) {
+		zend_throw_exception(
+				php_io_poll_inactive_watcher_class_entry, "Cannot modify inactive watcher", 0);
+		return FAILURE;
+	}
+
+	php_socket_t fd = php_poll_handle_get_fd(watcher->handle);
+	if (fd == SOCK_ERR) {
+		zend_throw_exception(
+				php_io_poll_invalid_handle_class_entry, "Invalid handle for polling", 0);
+		return FAILURE;
+	}
+
+	/* Modify in poll context */
+	if (php_poll_modify(watcher->poll_ctx, (int) fd, events, watcher) != SUCCESS) {
+		php_poll_error err = php_poll_get_error(watcher->poll_ctx);
+		php_io_poll_throw_failed_operation(php_io_poll_failed_watcher_mod_class_entry,
+				"Failed to modify watcher in polling system", err);
+		return FAILURE;
+	}
+
+	/* Update watcher state */
+	watcher->watched_events = events;
+
+	return SUCCESS;
+}
+
+static zend_result php_io_poll_watcher_modify_data(php_io_poll_watcher_object *watcher, zval *data)
+{
+	if (!watcher->active) {
+		zend_throw_exception(
+				php_io_poll_inactive_watcher_class_entry, "Cannot modify inactive watcher", 0);
+		return FAILURE;
+	}
+
+	/* Update user data */
+	zval_ptr_dtor(&watcher->data);
+	ZVAL_COPY(&watcher->data, data);
+
+	return SUCCESS;
+}
+
+/* PHP Method Implementations */
+
+PHP_METHOD(Io_Poll_Backend, getAvailableBackends)
+{
+	ZEND_PARSE_PARAMETERS_NONE();
+
+	array_init(return_value);
+
+	/* Check each backend type for availability */
+	php_poll_backend_type backends[] = {PHP_POLL_BACKEND_POLL, PHP_POLL_BACKEND_EPOLL,
+			PHP_POLL_BACKEND_KQUEUE, PHP_POLL_BACKEND_EVENTPORT, PHP_POLL_BACKEND_WSAPOLL};
+
+	for (size_t i = 0; i < sizeof(backends) / sizeof(backends[0]); i++) {
+		if (php_poll_is_backend_available(backends[i])) {
+			const char *name = php_io_poll_backend_type_to_name(backends[i]);
+			zval enum_case;
+			ZVAL_OBJ(&enum_case, zend_enum_get_case_cstr(php_io_poll_backend_class_entry, name));
+			add_next_index_zval(return_value, &enum_case);
+		}
+	}
+}
+
+PHP_METHOD(Io_Poll_Backend, isAvailable)
+{
+	ZEND_PARSE_PARAMETERS_NONE();
+
+	zend_object *enum_obj = Z_OBJ_P(ZEND_THIS);
+	php_poll_backend_type type = php_io_poll_backend_enum_to_type(enum_obj);
+
+	RETURN_BOOL(php_poll_is_backend_available(type));
+}
+
+PHP_METHOD(Io_Poll_Backend, supportsEdgeTriggering)
+{
+	ZEND_PARSE_PARAMETERS_NONE();
+
+	zend_object *enum_obj = Z_OBJ_P(ZEND_THIS);
+	php_poll_backend_type type = php_io_poll_backend_enum_to_type(enum_obj);
+
+	RETURN_BOOL(php_poll_backend_supports_edge_triggering(type));
+}
+
+PHP_METHOD(StreamPollHandle, __construct)
+{
+	php_stream *stream;
+
+	ZEND_PARSE_PARAMETERS_START(1, 1)
+		PHP_Z_PARAM_STREAM(stream)
+	ZEND_PARSE_PARAMETERS_END();
+
+	php_poll_handle_object *intern = PHP_POLL_HANDLE_OBJ_FROM_ZV(getThis());
+
+	/* Set up stream-specific data */
+	php_stream_poll_handle_data *data = emalloc(sizeof(php_stream_poll_handle_data));
+	data->stream = stream;
+	intern->handle_data = data;
+
+	/* Add reference to stream */
+	GC_ADDREF(stream->res);
+}
+
+PHP_METHOD(StreamPollHandle, getStream)
+{
+	ZEND_PARSE_PARAMETERS_NONE();
+
+	php_poll_handle_object *intern = PHP_POLL_HANDLE_OBJ_FROM_ZV(getThis());
+	php_stream_poll_handle_data *data = (php_stream_poll_handle_data *) intern->handle_data;
+
+	if (!data || !data->stream) {
+		RETURN_NULL();
+	}
+
+	GC_ADDREF(data->stream->res);
+	php_stream_to_zval(data->stream, return_value);
+}
+
+PHP_METHOD(StreamPollHandle, isValid)
+{
+	ZEND_PARSE_PARAMETERS_NONE();
+
+	php_poll_handle_object *intern = PHP_POLL_HANDLE_OBJ_FROM_ZV(getThis());
+	RETURN_BOOL(intern->ops->is_valid(intern));
+}
+
+PHP_METHOD(StreamPollHandle, getFileDescriptor)
+{
+	ZEND_PARSE_PARAMETERS_NONE();
+
+	php_poll_handle_object *intern = PHP_POLL_HANDLE_OBJ_FROM_ZV(getThis());
+	php_socket_t fd = php_poll_handle_get_fd(intern);
+
+	if (fd == SOCK_ERR) {
+		RETURN_LONG(0);
+	}
+
+	RETURN_LONG((zend_long) fd);
+}
+
+PHP_METHOD(Io_Poll_Watcher, __construct)
+{
+	zend_throw_error(NULL, "Cannot directly construct Watcher, use Context::add");
+}
+
+PHP_METHOD(Io_Poll_Watcher, getHandle)
+{
+	ZEND_PARSE_PARAMETERS_NONE();
+
+	php_io_poll_watcher_object *intern = PHP_POLL_WATCHER_OBJ_FROM_ZV(getThis());
+	if (!intern->handle) {
+		RETURN_NULL();
+	}
+
+	RETURN_OBJ_COPY(&intern->handle->std);
+}
+
+PHP_METHOD(Io_Poll_Watcher, getWatchedEvents)
+{
+	ZEND_PARSE_PARAMETERS_NONE();
+
+	php_io_poll_watcher_object *intern = PHP_POLL_WATCHER_OBJ_FROM_ZV(getThis());
+	php_io_poll_events_to_event_enums(intern->watched_events, return_value);
+}
+
+PHP_METHOD(Io_Poll_Watcher, getTriggeredEvents)
+{
+	ZEND_PARSE_PARAMETERS_NONE();
+
+	php_io_poll_watcher_object *intern = PHP_POLL_WATCHER_OBJ_FROM_ZV(getThis());
+	php_io_poll_events_to_event_enums(intern->triggered_events, return_value);
+}
+
+PHP_METHOD(Io_Poll_Watcher, getData)
+{
+	ZEND_PARSE_PARAMETERS_NONE();
+
+	php_io_poll_watcher_object *intern = PHP_POLL_WATCHER_OBJ_FROM_ZV(getThis());
+	ZVAL_COPY(return_value, &intern->data);
+}
+
+PHP_METHOD(Io_Poll_Watcher, hasTriggered)
+{
+	zval *event_enum;
+
+	ZEND_PARSE_PARAMETERS_START(1, 1)
+		Z_PARAM_OBJECT_OF_CLASS(event_enum, php_io_poll_event_class_entry)
+	ZEND_PARSE_PARAMETERS_END();
+
+	uint32_t event_bit = php_io_poll_event_enum_to_bit(Z_OBJ_P(event_enum));
+
+	php_io_poll_watcher_object *intern = PHP_POLL_WATCHER_OBJ_FROM_ZV(getThis());
+	RETURN_BOOL((intern->triggered_events & event_bit) != 0);
+}
+
+PHP_METHOD(Io_Poll_Watcher, isActive)
+{
+	ZEND_PARSE_PARAMETERS_NONE();
+
+	php_io_poll_watcher_object *intern = PHP_POLL_WATCHER_OBJ_FROM_ZV(getThis());
+	RETURN_BOOL(intern->active);
+}
+
+PHP_METHOD(Io_Poll_Watcher, modify)
+{
+	zval *event_enums;
+	zval *data = NULL;
+
+	ZEND_PARSE_PARAMETERS_START(1, 2)
+		Z_PARAM_ARRAY(event_enums)
+		Z_PARAM_OPTIONAL
+		Z_PARAM_ZVAL(data)
+	ZEND_PARSE_PARAMETERS_END();
+
+	uint32_t events = php_io_poll_event_enums_to_events(event_enums);
+	if (!events) {
+		zend_argument_type_error(1, "must be array of Event enums");
+		RETURN_THROWS();
+	}
+
+	php_io_poll_watcher_object *intern = PHP_POLL_WATCHER_OBJ_FROM_ZV(getThis());
+
+	/* Modify events first */
+	if (php_io_poll_watcher_modify_events(intern, events) != SUCCESS) {
+		RETURN_THROWS();
+	}
+
+	/* Then modify data if provided */
+	if (data) {
+		if (php_io_poll_watcher_modify_data(intern, data) != SUCCESS) {
+			RETURN_THROWS();
+		}
+	}
+}
+
+PHP_METHOD(Io_Poll_Watcher, modifyEvents)
+{
+	zval *event_enums;
+
+	ZEND_PARSE_PARAMETERS_START(1, 1)
+		Z_PARAM_ARRAY(event_enums)
+	ZEND_PARSE_PARAMETERS_END();
+
+	uint32_t events = php_io_poll_event_enums_to_events(event_enums);
+	if (!events) {
+		zend_argument_type_error(1, "must be array of Event enums");
+		RETURN_THROWS();
+	}
+
+	php_io_poll_watcher_object *intern = PHP_POLL_WATCHER_OBJ_FROM_ZV(getThis());
+
+	if (php_io_poll_watcher_modify_events(intern, events) != SUCCESS) {
+		RETURN_THROWS();
+	}
+}
+
+PHP_METHOD(Io_Poll_Watcher, modifyData)
+{
+	zval *data;
+
+	ZEND_PARSE_PARAMETERS_START(1, 1)
+		Z_PARAM_ZVAL(data)
+	ZEND_PARSE_PARAMETERS_END();
+
+	php_io_poll_watcher_object *intern = PHP_POLL_WATCHER_OBJ_FROM_ZV(getThis());
+
+	if (php_io_poll_watcher_modify_data(intern, data) != SUCCESS) {
+		RETURN_THROWS();
+	}
+}
+
+PHP_METHOD(Io_Poll_Watcher, remove)
+{
+	ZEND_PARSE_PARAMETERS_NONE();
+
+	php_io_poll_watcher_object *intern = PHP_POLL_WATCHER_OBJ_FROM_ZV(getThis());
+
+	if (!intern->active || !intern->poll_ctx) {
+		zend_throw_exception(
+				php_io_poll_inactive_watcher_class_entry, "Cannot remove inactive watcher", 0);
+		RETURN_THROWS();
+	}
+
+	php_socket_t fd = php_poll_handle_get_fd(intern->handle);
+	if (fd != SOCK_ERR) {
+		php_poll_remove(intern->poll_ctx, (int) fd);
+	}
+
+	intern->active = false;
+	intern->poll_ctx = NULL;
+}
+
+PHP_METHOD(Io_Poll_Context, __construct)
+{
+	zval *backend_obj = NULL;
+
+	ZEND_PARSE_PARAMETERS_START(0, 1)
+		Z_PARAM_OPTIONAL
+		Z_PARAM_OBJECT_OF_CLASS(backend_obj, php_io_poll_backend_class_entry)
+	ZEND_PARSE_PARAMETERS_END();
+
+	php_io_poll_context_object *intern = PHP_POLL_CONTEXT_OBJ_FROM_ZV(getThis());
+
+	php_poll_backend_type backend_type = PHP_POLL_BACKEND_AUTO;
+	if (backend_obj != NULL) {
+		backend_type = php_io_poll_backend_enum_to_type(Z_OBJ_P(backend_obj));
+	}
+
+	intern->ctx = php_poll_create(backend_type, 0);
+
+	if (!intern->ctx) {
+		zend_throw_exception_ex(
+				php_io_poll_failed_backend_unavailable_class_entry, 0, "Backend %s not available",
+				php_io_poll_backend_type_to_name(backend_type));
+		RETURN_THROWS();
+	}
+
+	if (php_poll_init(intern->ctx) != SUCCESS) {
+		php_poll_error err = php_poll_get_error(intern->ctx);
+		php_poll_destroy(intern->ctx);
+		intern->ctx = NULL;
+		php_io_poll_throw_failed_operation(php_io_poll_failed_context_init_class_entry,
+				"Failed to initialize polling context", err);
+		RETURN_THROWS();
+	}
+
+	intern->watchers = emalloc(sizeof(HashTable));
+	zend_hash_init(intern->watchers, 8, NULL, ZVAL_PTR_DTOR, 0);
+}
+
+PHP_METHOD(Io_Poll_Context, add)
+{
+	zval *handle_obj, *event_enums;
+	uint32_t events;
+	zval *data = NULL;
+
+	ZEND_PARSE_PARAMETERS_START(2, 3)
+		Z_PARAM_OBJECT_OF_CLASS(handle_obj, php_io_poll_handle_class_entry)
+		Z_PARAM_ARRAY(event_enums)
+		Z_PARAM_OPTIONAL
+		Z_PARAM_ZVAL(data)
+	ZEND_PARSE_PARAMETERS_END();
+
+	php_io_poll_context_object *intern = PHP_POLL_CONTEXT_OBJ_FROM_ZV(getThis());
+	php_poll_handle_object *handle = PHP_POLL_HANDLE_OBJ_FROM_ZV(handle_obj);
+
+	/* Get file descriptor */
+	php_socket_t fd = php_poll_handle_get_fd(handle);
+	if (fd == SOCK_ERR) {
+		zend_throw_exception(
+				php_io_poll_invalid_handle_class_entry, "Invalid handle for polling", 0);
+		RETURN_THROWS();
+	}
+
+	/* Create watcher object */
+	object_init_ex(return_value, php_io_poll_watcher_class_entry);
+	php_io_poll_watcher_object *watcher = PHP_POLL_WATCHER_OBJ_FROM_ZV(return_value);
+
+	events = php_io_poll_event_enums_to_events(event_enums);
+	if (!events) {
+		zend_argument_type_error(2, "must be array of Event enums");
+		RETURN_THROWS();
+	}
+
+	watcher->handle = handle;
+	watcher->watched_events = events;
+	watcher->triggered_events = 0;
+	watcher->active = true;
+	watcher->poll_ctx = intern->ctx;
+
+	GC_ADDREF(&handle->std);
+
+	if (data) {
+		ZVAL_COPY(&watcher->data, data);
+	} else {
+		ZVAL_NULL(&watcher->data);
+	}
+
+	/* Add to poll context */
+	if (php_poll_add(intern->ctx, (int) fd, events, watcher) != SUCCESS) {
+		php_poll_error err = php_poll_get_error(intern->ctx);
+		if (err == PHP_POLL_ERR_EXISTS) {
+			zend_throw_exception(
+					php_io_poll_handle_already_watched_class_entry, "Handle already added", 0);
+		} else {
+			php_io_poll_throw_failed_operation(
+					php_io_poll_failed_handle_add_class_entry, "Failed to add handle", err);
+		}
+		RETURN_THROWS();
+	}
+
+	/* Store in our watchers map using shifted pointer as key */
+	zval watcher_zv;
+	ZVAL_OBJ(&watcher_zv, &watcher->std);
+	GC_ADDREF(&watcher->std);
+
+	zend_ulong hash_key = php_io_poll_compute_ptr_key(handle);
+	zend_hash_index_add(intern->watchers, hash_key, &watcher_zv);
+}
+
+PHP_METHOD(Io_Poll_Context, wait)
+{
+	zend_long timeout_seconds = -1;
+	bool timeout_seconds_is_null = true;
+	zend_long timeout_microseconds = 0;
+	zend_long max_events = 0;
+	bool max_events_is_null = true;
+
+	ZEND_PARSE_PARAMETERS_START(0, 3)
+		Z_PARAM_OPTIONAL
+		Z_PARAM_LONG_OR_NULL(timeout_seconds, timeout_seconds_is_null)
+		Z_PARAM_LONG(timeout_microseconds)
+		Z_PARAM_LONG_OR_NULL(max_events, max_events_is_null)
+	ZEND_PARSE_PARAMETERS_END();
+
+	php_io_poll_context_object *intern = PHP_POLL_CONTEXT_OBJ_FROM_ZV(getThis());
+
+	/* Build timespec from seconds + microseconds, or NULL for indefinite */
+	struct timespec ts;
+	const struct timespec *timeout = NULL;
+	if (timeout_seconds >= 0) {
+		if (timeout_microseconds < 0) {
+			zend_argument_value_error(2, "must be greater than or equal to 0");
+			RETURN_THROWS();
+		}
+
+		/* Allow microseconds >= 1000000, carry overflow into seconds
+		 * (same behavior as stream_select) */
+		ts.tv_sec = (time_t) (timeout_seconds + (timeout_microseconds / 1000000));
+		ts.tv_nsec = (long) ((timeout_microseconds % 1000000) * 1000);
+		timeout = &ts;
+	} else if (!timeout_seconds_is_null) {
+		zend_argument_value_error(1, "must be greater than or equal to 0");
+		RETURN_THROWS();
+	}
+
+	if (max_events_is_null) {
+		max_events = php_poll_get_suitable_max_events(intern->ctx);
+		if (max_events <= 0) {
+			max_events = 64;
+		}
+	} else if (max_events <= 0) {
+		zend_argument_value_error(3, "must be greater than 0");
+		RETURN_THROWS();
+	}
+
+	php_poll_event *events = emalloc(sizeof(php_poll_event) * max_events);
+	int num_events = php_poll_wait(intern->ctx, events, (int) max_events, timeout);
+
+	if (num_events < 0) {
+		php_poll_error err = php_poll_get_error(intern->ctx);
+		efree(events);
+		php_io_poll_throw_failed_operation(
+				php_io_poll_failed_wait_class_entry, "Poll wait failed", err);
+		RETURN_THROWS();
+	}
+
+	array_init(return_value);
+
+	for (int i = 0; i < num_events; i++) {
+		php_io_poll_watcher_object *watcher = (php_io_poll_watcher_object *) events[i].data;
+		if (watcher) {
+			watcher->triggered_events = events[i].revents;
+
+			zval watcher_zv;
+			ZVAL_OBJ(&watcher_zv, &watcher->std);
+			GC_ADDREF(&watcher->std);
+
+			add_next_index_zval(return_value, &watcher_zv);
+		}
+	}
+
+	efree(events);
+}
+
+PHP_METHOD(Io_Poll_Context, getBackend)
+{
+	ZEND_PARSE_PARAMETERS_NONE();
+
+	php_io_poll_context_object *intern = PHP_POLL_CONTEXT_OBJ_FROM_ZV(getThis());
+	php_poll_backend_type backend_type = php_poll_get_backend_type(intern->ctx);
+	const char *backend_name = php_io_poll_backend_type_to_name(backend_type);
+
+	RETURN_OBJ_COPY(zend_enum_get_case_cstr(php_io_poll_backend_class_entry, backend_name));
+}
+
+/* Initialize the stream poll classes - add to PHP_MINIT_FUNCTION */
+PHP_MINIT_FUNCTION(poll)
+{
+	/* Register backend enum */
+	php_io_poll_backend_class_entry = register_class_Io_Poll_Backend();
+
+	/* Register event enum */
+	php_io_poll_event_class_entry = register_class_Io_Poll_Event();
+
+	/* Register Handle interface */
+	php_io_poll_handle_class_entry = register_class_Io_Poll_Handle();
+	php_io_poll_handle_class_entry->interface_gets_implemented = php_stream_poll_handle_implement_interface;
+
+	/* Register StreamPollHandle class */
+	php_stream_poll_handle_class_entry
+			= register_class_StreamPollHandle(php_io_poll_handle_class_entry);
+	php_stream_poll_handle_class_entry->create_object = php_stream_poll_handle_create_object;
+
+	memcpy(&php_io_poll_handle_object_handlers, &std_object_handlers, sizeof(zend_object_handlers));
+	php_io_poll_handle_object_handlers.offset = offsetof(php_poll_handle_object, std);
+	php_io_poll_handle_object_handlers.free_obj = php_poll_handle_object_free;
+	php_stream_poll_handle_class_entry->default_object_handlers = &php_io_poll_handle_object_handlers;
+
+	/* Register Watcher class */
+	php_io_poll_watcher_class_entry = register_class_Io_Poll_Watcher();
+	php_io_poll_watcher_class_entry->create_object = php_io_poll_watcher_create_object;
+
+	memcpy(&php_io_poll_watcher_object_handlers, &std_object_handlers,
+			sizeof(zend_object_handlers));
+	php_io_poll_watcher_object_handlers.offset = offsetof(php_io_poll_watcher_object, std);
+	php_io_poll_watcher_object_handlers.free_obj = php_io_poll_watcher_free_object;
+	php_io_poll_watcher_class_entry->default_object_handlers = &php_io_poll_watcher_object_handlers;
+
+	/* Register Context class */
+	php_io_poll_context_class_entry = register_class_Io_Poll_Context();
+	php_io_poll_context_class_entry->create_object = php_io_poll_context_create_object;
+
+	memcpy(&php_io_poll_context_object_handlers, &std_object_handlers,
+			sizeof(zend_object_handlers));
+	php_io_poll_context_object_handlers.offset = offsetof(php_io_poll_context_object, std);
+	php_io_poll_context_object_handlers.free_obj = php_io_poll_context_free_object;
+	php_io_poll_context_class_entry->default_object_handlers = &php_io_poll_context_object_handlers;
+
+	/* Register exception hierarchy */
+	php_io_exception_class_entry = register_class_Io_IoException(zend_ce_exception);
+
+	php_io_poll_exception_class_entry
+			= register_class_Io_Poll_PollException(php_io_exception_class_entry);
+
+	php_io_poll_failed_operation_class_entry = register_class_Io_Poll_FailedPollOperationException(
+			php_io_poll_exception_class_entry);
+
+	php_io_poll_failed_context_init_class_entry
+			= register_class_Io_Poll_FailedContextInitializationException(
+					php_io_poll_failed_operation_class_entry);
+
+	php_io_poll_failed_handle_add_class_entry = register_class_Io_Poll_FailedHandleAddException(
+			php_io_poll_failed_operation_class_entry);
+
+	php_io_poll_failed_watcher_mod_class_entry
+			= register_class_Io_Poll_FailedWatcherModificationException(
+					php_io_poll_failed_operation_class_entry);
+
+	php_io_poll_failed_wait_class_entry = register_class_Io_Poll_FailedPollWaitException(
+			php_io_poll_failed_operation_class_entry);
+
+	php_io_poll_failed_backend_unavailable_class_entry = register_class_Io_Poll_BackendUnavailableException(
+		php_io_poll_exception_class_entry);
+
+	php_io_poll_inactive_watcher_class_entry = register_class_Io_Poll_InactiveWatcherException(
+			php_io_poll_exception_class_entry);
+
+	php_io_poll_handle_already_watched_class_entry
+			= register_class_Io_Poll_HandleAlreadyWatchedException(
+					php_io_poll_exception_class_entry);
+
+	php_io_poll_invalid_handle_class_entry
+			= register_class_Io_Poll_InvalidHandleException(php_io_poll_exception_class_entry);
+
+	/* Initialize polling backends */
+	php_poll_register_backends();
+
+	return SUCCESS;
+}
diff --git a/ext/standard/io_poll.stub.php b/ext/standard/io_poll.stub.php
new file mode 100644
index 00000000000..689099a0b39
--- /dev/null
+++ b/ext/standard/io_poll.stub.php
@@ -0,0 +1,162 @@
+<?php
+
+/** @generate-class-entries */
+
+namespace Io {
+    class IoException extends \Exception {}
+}
+
+namespace Io\Poll {
+
+    enum Backend
+    {
+        case Auto;
+        case Poll;
+        case Epoll;
+        case Kqueue;
+        case EventPorts;
+        case WSAPoll;
+
+        /** @return list<Backend> */
+        static public function getAvailableBackends(): array {}
+
+        public function isAvailable(): bool {}
+
+        public function supportsEdgeTriggering(): bool {}
+    }
+
+    enum Event {
+        case Read;
+        case Write;
+        case Error;
+        case HangUp;
+        case ReadHangUp;
+        case OneShot;
+        case EdgeTriggered;
+    }
+
+    interface Handle
+    {
+    }
+
+    /*
+     * @strict-properties
+     * @not-serializable
+     */
+    final class Watcher
+    {
+        private final function __construct() {}
+
+        public function getHandle(): Handle {}
+
+        /** @return list<Event> */
+        public function getWatchedEvents(): array {}
+
+        /** @return list<Event> */
+        public function getTriggeredEvents(): array {}
+
+        public function getData(): mixed {}
+
+        public function hasTriggered(Event $event): bool {}
+
+        public function isActive(): bool {}
+
+        public function modify(array $events, mixed $data = null): void {}
+
+        public function modifyEvents(array $events): void {}
+
+        public function modifyData(mixed $data): void {}
+
+        public function remove(): void {}
+    }
+
+    /*
+     * @strict-properties
+     * @not-serializable
+     */
+    final class Context
+    {
+        public function __construct(Backend $backend = Backend::Auto) {}
+
+        public function add(Handle $handle, array $events, mixed $data = null): Watcher {}
+
+        /** @return list<Watcher> */
+        public function wait(?int $timeoutSeconds = null, int $timeoutMicroseconds = 0, ?int $maxEvents = null): array {}
+
+        public function getBackend(): Backend {}
+    }
+
+    class PollException extends \Io\IoException {}
+
+    abstract class FailedPollOperationException extends PollException
+    {
+        /** @cvalue PHP_POLL_ERROR_CODE_NONE */
+        public const int ERROR_NONE = UNKNOWN;
+
+        /** @cvalue PHP_POLL_ERROR_CODE_SYSTEM */
+        public const int ERROR_SYSTEM = UNKNOWN;
+
+        /** @cvalue PHP_POLL_ERROR_CODE_NOMEM */
+        public const int ERROR_NOMEM = UNKNOWN;
+
+        /** @cvalue PHP_POLL_ERROR_CODE_INVALID */
+        public const int ERROR_INVALID = UNKNOWN;
+
+        /** @cvalue PHP_POLL_ERROR_CODE_EXISTS */
+        public const int ERROR_EXISTS = UNKNOWN;
+
+        /** @cvalue PHP_POLL_ERROR_CODE_NOTFOUND */
+        public const int ERROR_NOTFOUND = UNKNOWN;
+
+        /** @cvalue PHP_POLL_ERROR_CODE_TIMEOUT */
+        public const int ERROR_TIMEOUT = UNKNOWN;
+
+        /** @cvalue PHP_POLL_ERROR_CODE_INTERRUPTED */
+        public const int ERROR_INTERRUPTED = UNKNOWN;
+
+        /** @cvalue PHP_POLL_ERROR_CODE_PERMISSION */
+        public const int ERROR_PERMISSION = UNKNOWN;
+
+        /** @cvalue PHP_POLL_ERROR_CODE_TOOBIG */
+        public const int ERROR_TOOBIG = UNKNOWN;
+
+        /** @cvalue PHP_POLL_ERROR_CODE_AGAIN */
+        public const int ERROR_AGAIN = UNKNOWN;
+
+        /** @cvalue PHP_POLL_ERROR_CODE_NOSUPPORT */
+        public const int ERROR_NOSUPPORT = UNKNOWN;
+    }
+
+    class FailedContextInitializationException extends FailedPollOperationException {}
+
+    class FailedHandleAddException extends FailedPollOperationException {}
+
+    class FailedWatcherModificationException extends FailedPollOperationException {}
+
+    class FailedPollWaitException extends FailedPollOperationException {}
+
+    class BackendUnavailableException extends PollException {}
+
+    class InactiveWatcherException extends PollException {}
+
+    class HandleAlreadyWatchedException extends PollException {}
+
+    class InvalidHandleException extends PollException {}
+}
+
+namespace {
+    /*
+     * @strict-properties
+     * @not-serializable
+     */
+    final class StreamPollHandle implements Io\Poll\Handle
+    {
+        /** @param resource $stream */
+        public function __construct($stream) {}
+
+        /** @return resource */
+        public function getStream() {}
+
+        public function isValid(): bool {}
+    }
+}
diff --git a/ext/standard/io_poll_arginfo.h b/ext/standard/io_poll_arginfo.h
new file mode 100644
index 00000000000..5272a2b2739
Binary files /dev/null and b/ext/standard/io_poll_arginfo.h differ
diff --git a/ext/standard/tests/poll/poll.inc b/ext/standard/tests/poll/poll.inc
new file mode 100644
index 00000000000..a1ca01fc7d4
--- /dev/null
+++ b/ext/standard/tests/poll/poll.inc
@@ -0,0 +1,321 @@
+<?php
+// stream Poll Testing helper
+
+function pt_new_stream_poll(): Io\Poll\Context {
+    $backend = getenv('POLL_TEST_BACKEND');
+    if ($backend === false) {
+        return new Io\Poll\Context(Io\Poll\Backend::Auto);
+    }
+
+    // Map string to enum case using match
+    $backend_enum = match(strtolower($backend)) {
+        'auto' => Io\Poll\Backend::Auto,
+        'poll' => Io\Poll\Backend::Poll,
+        'epoll' => Io\Poll\Backend::Epoll,
+        'kqueue' => Io\Poll\Backend::Kqueue,
+        'eventports' => Io\Poll\Backend::EventPorts,
+        'wsapoll' => Io\Poll\Backend::WSAPoll,
+        default => throw new ValueError("Unknown backend: $backend"),
+    };
+
+    return new Io\Poll\Context($backend_enum);
+}
+
+function pt_stream_poll_add($poll_ctx, $stream, $events, $data = null): Io\Poll\Watcher {
+    return $poll_ctx->add(new StreamPollHandle($stream), $events, $data);
+}
+
+function pt_skip_for_backend($backend, $msg): void {
+    $backends_to_skip = is_array($backend) ? $backend : array($backend);
+    $current_backend = pt_new_stream_poll()->getBackend();
+    if (in_array($current_backend->name, $backends_to_skip)) {
+        die("skip backend {$current_backend->name} $msg\n");
+    }
+}
+
+function pt_new_socket_pair(): array {
+    $domain = (strtoupper(substr(PHP_OS, 0, 3) == 'WIN') ? STREAM_PF_INET : STREAM_PF_UNIX);
+    $sockets = stream_socket_pair($domain, STREAM_SOCK_STREAM, 0);
+    if ($sockets === false) {
+        die("Cannot create socket pair\n");
+    }
+    return $sockets;
+}
+
+function pt_new_tcp_socket_pair(): array {
+    $server = stream_socket_server("tcp://127.0.0.1:0", $errno, $errstr);
+    if (!$server) {
+        die("Cannot create TCP server: $errstr\n");
+    }
+    $address = stream_socket_get_name($server, false);
+
+    $client = stream_socket_client("tcp://$address", $errno, $errstr);
+    if (!$client) {
+        fclose($server);
+        die("Cannot connect to TCP server: $errstr\n");
+    }
+
+    $server_conn = stream_socket_accept($server);
+    if (!$server_conn) {
+        fclose($server);
+        fclose($client);
+        die("Cannot accept connection\n");
+    }
+
+    // Close the listening socket (no longer needed)
+    fclose($server);
+
+    return [$client, $server_conn];
+}
+
+function pt_new_tcp_socket_connections(int $num_conns): array {
+    $server = stream_socket_server("tcp://127.0.0.1:0", $errno, $errstr);
+    if (!$server) {
+        die("Cannot create TCP server: $errstr\n");
+    }
+    $address = stream_socket_get_name($server, false);
+
+    $clients = [];
+    $server_conns = [];
+    for ($i = 0; $i < $num_conns; ++$i) {
+        $clients[$i] = stream_socket_client("tcp://$address", $errno, $errstr);
+        if (!$clients[$i]) {
+            fclose($server);
+            die("Cannot connect to TCP server: $errstr\n");
+        }
+
+        $server_conns[$i] = stream_socket_accept($server);
+        if (!$server_conns[$i]) {
+            fclose($server);
+            die("Cannot accept connection\n");
+        }
+    }
+
+    // Close the listening socket (no longer needed)
+    fclose($server);
+
+    return [$clients, $server_conns];
+}
+
+function pt_write_sleep($stream, $data, $delay = 10000): int|false {
+    $result = fwrite($stream, $data);
+    usleep($delay);
+    return $result;
+}
+
+function pt_event_array_to_string(array $events): string {
+    $names = [];
+    foreach ($events as $event) {
+        $names[] = $event->name;
+    }
+    return empty($names) ? 'NONE' : implode('|', $names);
+}
+
+function pt_print_events($watchers, $read_data = false): void {
+    if (!is_array($watchers)) {
+        die("Events must be an array\n");
+    }
+    echo "Events count: " . count($watchers) . "\n";
+    foreach ($watchers as $i => $watcher) {
+        if (!$watcher instanceof Io\Poll\Watcher) {
+            die('Invalid event type');
+        }
+        echo "Event[$i]: " . pt_event_array_to_string($watcher->getTriggeredEvents()) . ", user data: " . $watcher->getData();
+        if ($read_data && $watcher->hasTriggered(Io\Poll\Event::Read)) {
+            $data = fread($watcher->getHandle()->getStream(), 1024);
+            echo ", read data: '$data'";
+        }
+        echo "\n";
+    }
+}
+
+function pt_expect_events($watchers, $expected, ?Io\Poll\Context $poll_ctx = null): void {
+    if (!is_array($watchers)) {
+        die("Events must be an array\n");
+    }
+
+    if (!is_array($expected)) {
+        die("Expected events must be an array\n");
+    }
+
+    $event_count = count($watchers);
+    $expected_count = count($expected);
+
+    // Get the current backend for backend-specific expectations
+    $backend = $poll_ctx ? $poll_ctx->getBackend()->name : 'unknown';
+
+    if ($event_count !== $expected_count) {
+        echo "Event count mismatch: got $event_count, expected $expected_count\n";
+        pt_print_mismatched_events($watchers, $expected, [], $backend);
+        return;
+    }
+
+    // Convert events to comparable format for matching
+    $actual_events = [];
+    foreach ($watchers as $watcher) {
+        if (!$watcher instanceof Io\Poll\Watcher) {
+            die('Invalid event type');
+        }
+
+        $event_data = [
+            'events' => $watcher->getTriggeredEvents(),
+            'data' => $watcher->getData(),
+        ];
+
+        $actual_events[] = $event_data;
+    }
+
+    // Resolve backend-specific expectations
+    $resolved_expected = [];
+    foreach ($expected as $exp_event) {
+        $resolved_event = $exp_event;
+
+        // Check if events field is a backend-specific map (associative array with string keys)
+        if (isset($exp_event['events']) && is_array($exp_event['events'])) {
+            // Check if it's a backend map (has string keys like 'default', 'Kqueue', etc.)
+            $keys = array_keys($exp_event['events']);
+            $is_backend_map = false;
+            foreach ($keys as $key) {
+                if (is_string($key)) {
+                    $is_backend_map = true;
+                    break;
+                }
+            }
+
+            if ($is_backend_map) {
+                // It's a backend-specific map, resolve it
+                $resolved_event['events'] = pt_resolve_backend_specific_value($exp_event['events'], $backend);
+            }
+            // Otherwise it's already an array of Event enums, leave it as-is
+        }
+
+        $resolved_expected[] = $resolved_event;
+    }
+
+    // Try to match each expected event with an actual event
+    $matched = [];
+    $unmatched_expected = [];
+
+    foreach ($resolved_expected as $exp_idx => $exp_event) {
+        $found_match = false;
+
+        foreach ($actual_events as $act_idx => $act_event) {
+            if (isset($matched[$act_idx])) {
+                continue; // Already matched
+            }
+
+            // Check if events and data match
+            if (pt_events_equal($act_event['events'], $exp_event['events']) &&
+                $act_event['data'] === $exp_event['data']) {
+
+                // If read data is expected, check it
+                if (isset($exp_event['read'])) {
+                    $read_data = fread($watchers[$act_idx]->getHandle()->getStream(), 1024);
+                    if ($read_data !== $exp_event['read']) {
+                        continue; // Read data doesn't match
+                    }
+                }
+
+                $matched[$act_idx] = $exp_idx;
+                $found_match = true;
+                break;
+            }
+        }
+
+        if (!$found_match) {
+            $unmatched_expected[] = $exp_event;
+        }
+    }
+
+    // Check if all events matched
+    if (count($matched) === $event_count && empty($unmatched_expected)) {
+        echo "Events matched - count: $event_count\n";
+    } else {
+        echo "Events did not match:\n";
+        pt_print_mismatched_events($watchers, $expected, $matched, $backend);
+    }
+}
+
+function pt_events_equal($actual, $expected): bool {
+    // Both should be arrays of Event enums
+    if (!is_array($actual) || !is_array($expected)) {
+        return false;
+    }
+
+    if (count($actual) !== count($expected)) {
+        return false;
+    }
+
+    // Sort both arrays by event name for comparison
+    $actual_names = array_map(fn($e) => $e->name, $actual);
+    $expected_names = array_map(fn($e) => $e->name, $expected);
+    sort($actual_names);
+    sort($expected_names);
+
+    return $actual_names === $expected_names;
+}
+
+function pt_resolve_backend_specific_value($backend_map, $current_backend) {
+    // Direct backend match
+    if (isset($backend_map[$current_backend])) {
+        return $backend_map[$current_backend];
+    }
+
+    // Check for multi-backend keys (e.g., "Kqueue|EventPorts")
+    foreach ($backend_map as $key => $value) {
+        if (strpos($key, '|') !== false) {
+            $backends = array_map('trim', explode('|', $key));
+            if (in_array($current_backend, $backends)) {
+                return $value;
+            }
+        }
+    }
+
+    // Fall back to default
+    if (isset($backend_map['default'])) {
+        return $backend_map['default'];
+    }
+
+    // If no match found, this is an error
+    die("No backend-specific value found for '$current_backend' and no default specified\n");
+}
+
+function pt_print_mismatched_events($actual_watchers, $expected_watchers, $matched = [], $backend_name = null): void {
+    echo "Actual events:\n";
+    foreach ($actual_watchers as $i => $watcher) {
+        $match_status = isset($matched[$i]) ? " [MATCHED]" : " [UNMATCHED]";
+        $watcher_names = pt_event_array_to_string($watcher->getTriggeredEvents());
+        echo "  Event[$i]: $watcher_names, user data: " . $watcher->getData() . $match_status . "\n";
+    }
+
+    echo "Expected events:\n";
+    foreach ($expected_watchers as $i => $exp_event) {
+        $was_matched = in_array($i, $matched);
+        $match_status = $was_matched ? " [MATCHED]" : " [UNMATCHED]";
+
+        $events_value = $exp_event['events'];
+
+        // Check if it's a backend-specific map
+        if (is_array($events_value) && $backend_name !== null) {
+            $keys = array_keys($events_value);
+            $is_backend_map = false;
+            foreach ($keys as $key) {
+                if (is_string($key)) {
+                    $is_backend_map = true;
+                    break;
+                }
+            }
+
+            if ($is_backend_map) {
+                $events_value = pt_resolve_backend_specific_value($events_value, $backend_name);
+            }
+        }
+
+        $watcher_names = is_array($events_value) ? pt_event_array_to_string($events_value) : 'INVALID';
+        echo "  Event[$i]: $watcher_names, user data: " . $exp_event['data'];
+        if (isset($exp_event['read'])) {
+            echo ", read data: '" . $exp_event['read'] . "'";
+        }
+        echo $match_status . "\n";
+    }
+}
diff --git a/ext/standard/tests/poll/poll_ctx_backend_unix.phpt b/ext/standard/tests/poll/poll_ctx_backend_unix.phpt
new file mode 100644
index 00000000000..89f4f3eea10
--- /dev/null
+++ b/ext/standard/tests/poll/poll_ctx_backend_unix.phpt
@@ -0,0 +1,28 @@
+--TEST--
+Poll context - backend on Unix
+--SKIPIF--
+<?php
+if (substr(PHP_OS, 0, 3) == 'WIN') {
+    die ("skip not for Windows");
+}
+?>
+--FILE--
+<?php
+require_once __DIR__ . '/poll.inc';
+// this just prints default poll ctx
+$poll_ctx = new Io\Poll\Context();
+var_dump($poll_ctx->getBackend());
+// test with poll that is always available on Unix systems
+$poll_ctx = new Io\Poll\Context(Io\Poll\Backend::Poll);
+$backend = $poll_ctx->getBackend();
+var_dump($backend->name);
+try {
+    new Io\Poll\Context(Io\Poll\Backend::WSAPoll);
+} catch (\Io\Poll\BackendUnavailableException $e) {
+    var_dump($e->getMessage());
+}
+?>
+--EXPECTF--
+enum(Io\Poll\Backend::%s)
+string(4) "Poll"
+string(29) "Backend WSAPoll not available"
diff --git a/ext/standard/tests/poll/poll_ctx_backend_windows.phpt b/ext/standard/tests/poll/poll_ctx_backend_windows.phpt
new file mode 100644
index 00000000000..a8f8498800c
--- /dev/null
+++ b/ext/standard/tests/poll/poll_ctx_backend_windows.phpt
@@ -0,0 +1,28 @@
+--TEST--
+Poll context - backend on Windows
+--SKIPIF--
+<?php
+if (substr(PHP_OS, 0, 3) != 'WIN') {
+    die ("skip only for Windows");
+}
+?>
+--FILE--
+<?php
+require_once __DIR__ . '/poll.inc';
+// this just prints default poll ctx
+$poll_ctx = new Io\Poll\Context();
+var_dump($poll_ctx->getBackend());
+// test with WSAPoll
+$poll_ctx = new Io\Poll\Context(Io\Poll\Backend::WSAPoll);
+$backend = $poll_ctx->getBackend();
+var_dump($backend->name);
+try {
+    new Io\Poll\Context(Io\Poll\Backend::Epoll);
+} catch (\Io\Poll\BackendUnavailableException $e) {
+    var_dump($e->getMessage());
+}
+?>
+--EXPECTF--
+enum(Io\Poll\Backend::%s)
+string(7) "WSAPoll"
+string(27) "Backend Epoll not available"
diff --git a/ext/standard/tests/poll/poll_stream_add_error_duplicate.phpt b/ext/standard/tests/poll/poll_stream_add_error_duplicate.phpt
new file mode 100644
index 00000000000..8753cad7eb8
--- /dev/null
+++ b/ext/standard/tests/poll/poll_stream_add_error_duplicate.phpt
@@ -0,0 +1,19 @@
+--TEST--
+Poll stream - add duplicite error
+--FILE--
+<?php
+require_once __DIR__ . '/poll.inc';
+
+list($socket1r, $socket1w) = pt_new_socket_pair();
+$poll_ctx = pt_new_stream_poll();
+
+pt_stream_poll_add($poll_ctx, $socket1w, [Io\Poll\Event::Write], "socket2_data");
+
+try {
+    pt_stream_poll_add($poll_ctx, $socket1w, [Io\Poll\Event::Write], "socket2_data");
+} catch (Io\Poll\HandleAlreadyWatchedException $e) {
+    echo "ERROR: " . $e->getMessage() . "\n";
+}
+?>
+--EXPECT--
+ERROR: Handle already added
diff --git a/ext/standard/tests/poll/poll_stream_sock_add_only.phpt b/ext/standard/tests/poll/poll_stream_sock_add_only.phpt
new file mode 100644
index 00000000000..b282ae84632
--- /dev/null
+++ b/ext/standard/tests/poll/poll_stream_sock_add_only.phpt
@@ -0,0 +1,17 @@
+--TEST--
+Poll stream - only add
+--FILE--
+<?php
+require_once __DIR__ . '/poll.inc';
+
+list($socket1, $socket2) = pt_new_socket_pair();
+$poll_ctx = pt_new_stream_poll();
+
+pt_stream_poll_add($poll_ctx, $socket2, [Io\Poll\Event::Write], "socket2_data");
+
+var_dump($poll_ctx);
+
+?>
+--EXPECT--
+object(Io\Poll\Context)#1 (0) {
+}
diff --git a/ext/standard/tests/poll/poll_stream_sock_modify_write.phpt b/ext/standard/tests/poll/poll_stream_sock_modify_write.phpt
new file mode 100644
index 00000000000..4f32ec28c88
--- /dev/null
+++ b/ext/standard/tests/poll/poll_stream_sock_modify_write.phpt
@@ -0,0 +1,18 @@
+--TEST--
+Poll stream - socket modify write
+--FILE--
+<?php
+require_once __DIR__ . '/poll.inc';
+
+list($socket1, $socket2) = pt_new_socket_pair();
+$poll_ctx = pt_new_stream_poll();
+
+$watcher = pt_stream_poll_add($poll_ctx, $socket2, [Io\Poll\Event::Write], "socket_data");
+$watcher->modify([Io\Poll\Event::Write], "modified_data");
+
+pt_expect_events($poll_ctx->wait(0), [
+    ['events' => [Io\Poll\Event::Write], 'data' => 'modified_data']
+]);
+?>
+--EXPECT--
+Events matched - count: 1
diff --git a/ext/standard/tests/poll/poll_stream_sock_read.phpt b/ext/standard/tests/poll/poll_stream_sock_read.phpt
new file mode 100644
index 00000000000..70aebc9cd92
--- /dev/null
+++ b/ext/standard/tests/poll/poll_stream_sock_read.phpt
@@ -0,0 +1,19 @@
+--TEST--
+Poll stream - socket read
+--FILE--
+<?php
+require_once __DIR__ . '/poll.inc';
+
+list($socket1r, $socket1w) = pt_new_socket_pair();
+$poll_ctx = pt_new_stream_poll();
+
+pt_stream_poll_add($poll_ctx, $socket1r, [Io\Poll\Event::Read], "socket_data");
+
+fwrite($socket1w, "test data");
+pt_expect_events($poll_ctx->wait(0, 100000), [
+    ['events' => [Io\Poll\Event::Read], 'data' => 'socket_data', 'read' => 'test data']
+]);
+
+?>
+--EXPECT--
+Events matched - count: 1
diff --git a/ext/standard/tests/poll/poll_stream_sock_remove_write.phpt b/ext/standard/tests/poll/poll_stream_sock_remove_write.phpt
new file mode 100644
index 00000000000..c5fec539165
--- /dev/null
+++ b/ext/standard/tests/poll/poll_stream_sock_remove_write.phpt
@@ -0,0 +1,38 @@
+--TEST--
+Poll stream - socket remove write
+--FILE--
+<?php
+require_once __DIR__ . '/poll.inc';
+
+list($socket1r, $socket1w) = pt_new_socket_pair();
+list($socket2r, $socket2w) = pt_new_socket_pair();
+$poll_ctx = pt_new_stream_poll();
+
+$watcher1w = pt_stream_poll_add($poll_ctx, $socket1w, [Io\Poll\Event::Write], "socket_data_1");
+pt_stream_poll_add($poll_ctx, $socket2w, [Io\Poll\Event::Write], "socket_data_2");
+
+pt_expect_events($poll_ctx->wait(0), [
+    ['events' => [Io\Poll\Event::Write], 'data' => 'socket_data_1'],
+    ['events' => [Io\Poll\Event::Write], 'data' => 'socket_data_2']
+]);
+
+$watcher1w->remove();
+
+pt_expect_events($poll_ctx->wait(0), [
+    ['events' => [Io\Poll\Event::Write], 'data' => 'socket_data_2']
+]);
+
+// check that both streams are still usable
+var_dump(fwrite($socket1w, "test 1"));
+var_dump(fwrite($socket2w, "test 2"));
+var_dump(fread($socket1r, 100));
+var_dump(fread($socket2r, 100));
+
+?>
+--EXPECT--
+Events matched - count: 2
+Events matched - count: 1
+int(6)
+int(6)
+string(6) "test 1"
+string(6) "test 2"
diff --git a/ext/standard/tests/poll/poll_stream_sock_rw_close.phpt b/ext/standard/tests/poll/poll_stream_sock_rw_close.phpt
new file mode 100644
index 00000000000..4edd1619d30
--- /dev/null
+++ b/ext/standard/tests/poll/poll_stream_sock_rw_close.phpt
@@ -0,0 +1,32 @@
+--TEST--
+Poll stream - socket write / read close
+--FILE--
+<?php
+require_once __DIR__ . '/poll.inc';
+
+list($socket1r, $socket1w) = pt_new_socket_pair();
+$poll_ctx = pt_new_stream_poll();
+
+pt_stream_poll_add($poll_ctx, $socket1r, [Io\Poll\Event::Read], "socket1_data");
+pt_stream_poll_add($poll_ctx, $socket1w, [Io\Poll\Event::Write], "socket2_data");
+
+fwrite($socket1w, "test data");
+
+fclose($socket1r);
+pt_expect_events($poll_ctx->wait(0, 100000), [
+    [
+        'events' => [
+            'default' => [Io\Poll\Event::Write, Io\Poll\Event::Error, Io\Poll\Event::HangUp],
+            'Kqueue|EventPorts' => [Io\Poll\Event::Write, Io\Poll\Event::HangUp],
+        ],
+        'data' => 'socket2_data'
+    ]
+], $poll_ctx);
+
+fclose($socket1w);
+pt_expect_events($poll_ctx->wait(0, 100000), []);
+
+?>
+--EXPECT--
+Events matched - count: 1
+Events matched - count: 0
diff --git a/ext/standard/tests/poll/poll_stream_sock_rw_multi_edge.phpt b/ext/standard/tests/poll/poll_stream_sock_rw_multi_edge.phpt
new file mode 100644
index 00000000000..f25cb973ddc
--- /dev/null
+++ b/ext/standard/tests/poll/poll_stream_sock_rw_multi_edge.phpt
@@ -0,0 +1,62 @@
+--TEST--
+Poll stream - socket write / read multiple times with edge triggering
+--SKIPIF--
+<?php
+require_once __DIR__ . '/poll.inc';
+pt_skip_for_backend(['Poll', 'WSAPoll', 'EventPorts'], 'does not support edge triggering')
+?>
+--FILE--
+<?php
+require_once __DIR__ . '/poll.inc';
+
+list($socket1r, $socket1w) = pt_new_socket_pair();
+$poll_ctx = pt_new_stream_poll();
+
+pt_stream_poll_add($poll_ctx, $socket1r, [Io\Poll\Event::Read, Io\Poll\Event::EdgeTriggered], "socket1_data");
+pt_stream_poll_add($poll_ctx, $socket1w, [Io\Poll\Event::Write, Io\Poll\Event::EdgeTriggered], "socket2_data");
+
+pt_expect_events($poll_ctx->wait(0), [
+    ['events' => [Io\Poll\Event::Write], 'data' => 'socket2_data']
+]);
+
+pt_expect_events($poll_ctx->wait(0), []);
+
+fwrite($socket1w, "test data");
+pt_expect_events($poll_ctx->wait(0, 100000), [
+    ['events' => [Io\Poll\Event::Read], 'data' => 'socket1_data', 'read' => 'test data']
+]);
+
+fwrite($socket1w, "more data");
+pt_expect_events($poll_ctx->wait(0, 100000), [
+    ['events' => [Io\Poll\Event::Write], 'data' => 'socket2_data'],
+    ['events' => [Io\Poll\Event::Read], 'data' => 'socket1_data']
+]);
+
+pt_expect_events($poll_ctx->wait(0, 100000), []);
+
+fwrite($socket1w, " and even more data");
+pt_expect_events($poll_ctx->wait(0, 100000), [
+    ['events' => [Io\Poll\Event::Read], 'data' => 'socket1_data', 'read' => 'more data and even more data']
+]);
+
+fclose($socket1r);
+pt_expect_events($poll_ctx->wait(0, 100000), [
+    [
+        'events' => ['default' => [Io\Poll\Event::Write, Io\Poll\Event::HangUp]],
+        'data' => 'socket2_data'
+    ]
+], $poll_ctx);
+
+fclose($socket1w);
+pt_expect_events($poll_ctx->wait(0, 100000), []);
+
+?>
+--EXPECT--
+Events matched - count: 1
+Events matched - count: 0
+Events matched - count: 1
+Events matched - count: 2
+Events matched - count: 0
+Events matched - count: 1
+Events matched - count: 1
+Events matched - count: 0
diff --git a/ext/standard/tests/poll/poll_stream_sock_rw_multi_level.phpt b/ext/standard/tests/poll/poll_stream_sock_rw_multi_level.phpt
new file mode 100644
index 00000000000..57fc335c26c
--- /dev/null
+++ b/ext/standard/tests/poll/poll_stream_sock_rw_multi_level.phpt
@@ -0,0 +1,61 @@
+--TEST--
+Poll stream - socket write / read multiple times with level triggering
+--FILE--
+<?php
+require_once __DIR__ . '/poll.inc';
+
+list($socket1r, $socket1w) = pt_new_socket_pair();
+$poll_ctx = pt_new_stream_poll();
+
+pt_stream_poll_add($poll_ctx, $socket1r, [Io\Poll\Event::Read], "socket1_data");
+pt_stream_poll_add($poll_ctx, $socket1w, [Io\Poll\Event::Write], "socket2_data");
+
+pt_expect_events($poll_ctx->wait(0), [
+    ['events' => [Io\Poll\Event::Write], 'data' => 'socket2_data']
+]);
+
+pt_expect_events($poll_ctx->wait(0), [
+    ['events' => [Io\Poll\Event::Write], 'data' => 'socket2_data']
+]);
+
+fwrite($socket1w, "test data");
+pt_expect_events($poll_ctx->wait(0, 100000), [
+    ['events' => [Io\Poll\Event::Write], 'data' => 'socket2_data'],
+    ['events' => [Io\Poll\Event::Read], 'data' => 'socket1_data', 'read' => 'test data']
+]);
+
+fwrite($socket1w, "more data");
+pt_expect_events($poll_ctx->wait(0, 100000), [
+    ['events' => [Io\Poll\Event::Write], 'data' => 'socket2_data'],
+    ['events' => [Io\Poll\Event::Read], 'data' => 'socket1_data']
+]);
+
+pt_expect_events($poll_ctx->wait(0, 100000), [
+    ['events' => [Io\Poll\Event::Write], 'data' => 'socket2_data'],
+    ['events' => [Io\Poll\Event::Read], 'data' => 'socket1_data']
+]);
+
+fwrite($socket1w, " and even more data");
+pt_expect_events($poll_ctx->wait(0, 100000), [
+    ['events' => [Io\Poll\Event::Write], 'data' => 'socket2_data'],
+    ['events' => [Io\Poll\Event::Read], 'data' => 'socket1_data', 'read' => 'more data and even more data']
+]);
+
+fclose($socket1r);
+pt_expect_events($poll_ctx->wait(0, 100000), [
+    ['events' => [Io\Poll\Event::Write, Io\Poll\Event::HangUp], 'data' => 'socket2_data']
+], $poll_ctx);
+
+fclose($socket1w);
+pt_expect_events($poll_ctx->wait(0, 100000), []);
+
+?>
+--EXPECT--
+Events matched - count: 1
+Events matched - count: 1
+Events matched - count: 2
+Events matched - count: 2
+Events matched - count: 2
+Events matched - count: 2
+Events matched - count: 1
+Events matched - count: 0
diff --git a/ext/standard/tests/poll/poll_stream_sock_rw_single_edge.phpt b/ext/standard/tests/poll/poll_stream_sock_rw_single_edge.phpt
new file mode 100644
index 00000000000..4a7b67b8243
--- /dev/null
+++ b/ext/standard/tests/poll/poll_stream_sock_rw_single_edge.phpt
@@ -0,0 +1,29 @@
+--TEST--
+Poll stream - socket write / read few time only
+--SKIPIF--
+<?php
+require_once __DIR__ . '/poll.inc';
+pt_skip_for_backend(['Poll', 'WSAPoll', 'EventPorts'], 'does not support edge triggering')
+?>
+--FILE--
+<?php
+require_once __DIR__ . '/poll.inc';
+
+list($socket1r, $socket1w) = pt_new_socket_pair();
+$poll_ctx = pt_new_stream_poll();
+
+pt_stream_poll_add($poll_ctx, $socket1r, [Io\Poll\Event::Read, Io\Poll\Event::EdgeTriggered], "socket1_data");
+pt_stream_poll_add($poll_ctx, $socket1w, [Io\Poll\Event::Write, Io\Poll\Event::EdgeTriggered], "socket2_data");
+
+pt_expect_events($poll_ctx->wait(0), [
+    ['events' => [Io\Poll\Event::Write], 'data' => 'socket2_data']
+]);
+fwrite($socket1w, "test data");
+pt_expect_events($poll_ctx->wait(0, 100000), [
+    ['events' => [Io\Poll\Event::Read], 'data' => 'socket1_data', 'read' => 'test data']
+]);
+
+?>
+--EXPECT--
+Events matched - count: 1
+Events matched - count: 1
diff --git a/ext/standard/tests/poll/poll_stream_sock_rw_single_level.phpt b/ext/standard/tests/poll/poll_stream_sock_rw_single_level.phpt
new file mode 100644
index 00000000000..a807e573c5f
--- /dev/null
+++ b/ext/standard/tests/poll/poll_stream_sock_rw_single_level.phpt
@@ -0,0 +1,25 @@
+--TEST--
+Poll stream - socket write / read few time only
+--FILE--
+<?php
+require_once __DIR__ . '/poll.inc';
+
+list($socket1r, $socket1w) = pt_new_socket_pair();
+$poll_ctx = pt_new_stream_poll();
+
+pt_stream_poll_add($poll_ctx, $socket1r, [Io\Poll\Event::Read], "socket1_data");
+pt_stream_poll_add($poll_ctx, $socket1w, [Io\Poll\Event::Write], "socket2_data");
+
+pt_expect_events($poll_ctx->wait(0), [
+    ['events' => [Io\Poll\Event::Write], 'data' => 'socket2_data']
+]);
+fwrite($socket1w, "test data");
+pt_expect_events($poll_ctx->wait(0, 100000), [
+    ['events' => [Io\Poll\Event::Write], 'data' => 'socket2_data'],
+    ['events' => [Io\Poll\Event::Read], 'data' => 'socket1_data', 'read' => 'test data']
+]);
+
+?>
+--EXPECT--
+Events matched - count: 1
+Events matched - count: 2
diff --git a/ext/standard/tests/poll/poll_stream_sock_write.phpt b/ext/standard/tests/poll/poll_stream_sock_write.phpt
new file mode 100644
index 00000000000..2d937d7e9a3
--- /dev/null
+++ b/ext/standard/tests/poll/poll_stream_sock_write.phpt
@@ -0,0 +1,18 @@
+--TEST--
+Poll stream - socket write
+--FILE--
+<?php
+require_once __DIR__ . '/poll.inc';
+
+list($socket1r, $socket1w) = pt_new_socket_pair();
+$poll_ctx = pt_new_stream_poll();
+
+pt_stream_poll_add($poll_ctx, $socket1w, [Io\Poll\Event::Write], "socket_data");
+
+pt_expect_events($poll_ctx->wait(0, 100000), [
+    ['events' => [Io\Poll\Event::Write], 'data' => 'socket_data']
+]);
+
+?>
+--EXPECT--
+Events matched - count: 1
diff --git a/ext/standard/tests/poll/poll_stream_sock_write_close.phpt b/ext/standard/tests/poll/poll_stream_sock_write_close.phpt
new file mode 100644
index 00000000000..8927e017b05
--- /dev/null
+++ b/ext/standard/tests/poll/poll_stream_sock_write_close.phpt
@@ -0,0 +1,20 @@
+--TEST--
+Poll stream - socket write close
+--FILE--
+<?php
+require_once __DIR__ . '/poll.inc';
+
+list($socket1r, $socket1w) = pt_new_socket_pair();
+$poll_ctx = pt_new_stream_poll();
+list($socket2r, $socket2w) = pt_new_socket_pair();
+
+pt_stream_poll_add($poll_ctx, $socket1w, [Io\Poll\Event::Write], "socket1w_data");
+pt_stream_poll_add($poll_ctx, $socket2w, [Io\Poll\Event::Write], "socket2w_data");
+
+fclose($socket1w);
+fclose($socket2w);
+pt_expect_events($poll_ctx->wait(0, 100000), []);
+
+?>
+--EXPECT--
+Events matched - count: 0
diff --git a/ext/standard/tests/poll/poll_stream_tcp_read.phpt b/ext/standard/tests/poll/poll_stream_tcp_read.phpt
new file mode 100644
index 00000000000..71c7af0ca73
--- /dev/null
+++ b/ext/standard/tests/poll/poll_stream_tcp_read.phpt
@@ -0,0 +1,19 @@
+--TEST--
+Poll stream - socket read
+--FILE--
+<?php
+require_once __DIR__ . '/poll.inc';
+
+list($socket1r, $socket1w) = pt_new_tcp_socket_pair();
+$poll_ctx = pt_new_stream_poll();
+
+pt_stream_poll_add($poll_ctx, $socket1r, [Io\Poll\Event::Read], "socket_data");
+
+pt_write_sleep($socket1w, "test data");
+pt_expect_events($poll_ctx->wait(0, 100000), [
+    ['events' => [Io\Poll\Event::Read], 'data' => 'socket_data', 'read' => 'test data']
+]);
+
+?>
+--EXPECT--
+Events matched - count: 1
diff --git a/ext/standard/tests/poll/poll_stream_tcp_read_multiple_level.phpt b/ext/standard/tests/poll/poll_stream_tcp_read_multiple_level.phpt
new file mode 100644
index 00000000000..db2ff71fb7c
--- /dev/null
+++ b/ext/standard/tests/poll/poll_stream_tcp_read_multiple_level.phpt
@@ -0,0 +1,41 @@
+--TEST--
+Poll stream - TCP read write level
+--FILE--
+<?php
+require_once __DIR__ . '/poll.inc';
+
+list($clients, $servers) = pt_new_tcp_socket_connections(20);
+$poll_ctx = pt_new_stream_poll();
+
+for ($i = 0; $i < count($servers); $i++) {
+    pt_stream_poll_add($poll_ctx, $servers[$i], [Io\Poll\Event::Read], "server{$i}_data");
+}
+
+pt_expect_events($poll_ctx->wait(0), []);
+
+for ($i = 0; $i < count($clients); $i++) {
+    pt_write_sleep($clients[$i], "test $i data");
+}
+
+// Build expected events for all 20 connections
+$expected_events = [];
+for ($i = 0; $i < 20; $i++) {
+    $expected_events[] = ['events' => [Io\Poll\Event::Read], 'data' => "server{$i}_data", 'read' => "test $i data"];
+}
+pt_expect_events($poll_ctx->wait(0, 100000), $expected_events);
+
+pt_write_sleep($clients[1], "more data");
+pt_write_sleep($clients[2], "more data");
+pt_expect_events($poll_ctx->wait(0, 100000), [
+    ['events' => [Io\Poll\Event::Read], 'data' => 'server1_data', 'read' => 'more data'],
+    ['events' => [Io\Poll\Event::Read], 'data' => 'server2_data', 'read' => 'more data']
+]);
+
+pt_expect_events($poll_ctx->wait(0, 100000), []);
+
+?>
+--EXPECT--
+Events matched - count: 0
+Events matched - count: 20
+Events matched - count: 2
+Events matched - count: 0
diff --git a/ext/standard/tests/poll/poll_stream_tcp_read_one_shot.phpt b/ext/standard/tests/poll/poll_stream_tcp_read_one_shot.phpt
new file mode 100644
index 00000000000..02db6355ca1
--- /dev/null
+++ b/ext/standard/tests/poll/poll_stream_tcp_read_one_shot.phpt
@@ -0,0 +1,33 @@
+--TEST--
+Poll stream - TCP read write oneshot
+--FILE--
+<?php
+require_once __DIR__ . '/poll.inc';
+
+list($client1, $server1) = pt_new_tcp_socket_pair();
+list($client2, $server2) = pt_new_tcp_socket_pair();
+$poll_ctx = pt_new_stream_poll();
+
+pt_stream_poll_add($poll_ctx, $client1, [Io\Poll\Event::Read, Io\Poll\Event::OneShot], "client1_data");
+pt_stream_poll_add($poll_ctx, $server1, [Io\Poll\Event::Read, Io\Poll\Event::OneShot], "server1_data");
+pt_stream_poll_add($poll_ctx, $client2, [Io\Poll\Event::Read, Io\Poll\Event::OneShot], "client2_data");
+pt_stream_poll_add($poll_ctx, $server2, [Io\Poll\Event::Read, Io\Poll\Event::OneShot], "server2_data");
+
+pt_expect_events($poll_ctx->wait(0), []);
+
+pt_write_sleep($client1, "test data");
+pt_write_sleep($client2, "test data");
+pt_expect_events($poll_ctx->wait(0, 100000), [
+    ['events' => [Io\Poll\Event::Read], 'data' => 'server1_data', 'read' => 'test data'],
+    ['events' => [Io\Poll\Event::Read], 'data' => 'server2_data', 'read' => 'test data']
+]);
+
+pt_write_sleep($client1, "more data");
+pt_write_sleep($client2, "more data");
+pt_expect_events($poll_ctx->wait(0, 100000), []);
+
+?>
+--EXPECT--
+Events matched - count: 0
+Events matched - count: 2
+Events matched - count: 0
diff --git a/ext/standard/tests/poll/poll_stream_tcp_rw_one_shot.phpt b/ext/standard/tests/poll/poll_stream_tcp_rw_one_shot.phpt
new file mode 100644
index 00000000000..6132975e1b5
--- /dev/null
+++ b/ext/standard/tests/poll/poll_stream_tcp_rw_one_shot.phpt
@@ -0,0 +1,28 @@
+--TEST--
+Poll stream - TCP read write oneshot combined
+--FILE--
+<?php
+require_once __DIR__ . '/poll.inc';
+
+list($client, $server) = pt_new_tcp_socket_pair();
+$poll_ctx = pt_new_stream_poll();
+
+pt_stream_poll_add($poll_ctx, $client, [Io\Poll\Event::Read, Io\Poll\Event::Write, Io\Poll\Event::OneShot], "client_data");
+pt_stream_poll_add($poll_ctx, $server, [Io\Poll\Event::Read, Io\Poll\Event::Write, Io\Poll\Event::OneShot], "server_data");
+
+pt_expect_events($poll_ctx->wait(0), [
+    ['events' => [Io\Poll\Event::Write], 'data' => 'client_data'],
+    ['events' => [Io\Poll\Event::Write], 'data' => 'server_data']
+]);
+
+pt_write_sleep($client, "test data");
+pt_expect_events($poll_ctx->wait(0, 100000), []);
+
+pt_write_sleep($client, "test data");
+pt_expect_events($poll_ctx->wait(0, 100000), []);
+
+?>
+--EXPECT--
+Events matched - count: 2
+Events matched - count: 0
+Events matched - count: 0
diff --git a/ext/standard/tests/poll/poll_stream_tcp_rw_one_shot_mixed.phpt b/ext/standard/tests/poll/poll_stream_tcp_rw_one_shot_mixed.phpt
new file mode 100644
index 00000000000..c480caf1b73
--- /dev/null
+++ b/ext/standard/tests/poll/poll_stream_tcp_rw_one_shot_mixed.phpt
@@ -0,0 +1,25 @@
+--TEST--
+Poll stream - TCP read write oneshot combined and mixed
+--FILE--
+<?php
+require_once __DIR__ . '/poll.inc';
+
+list($client, $server) = pt_new_tcp_socket_pair();
+$poll_ctx = pt_new_stream_poll();
+
+pt_stream_poll_add($poll_ctx, $client, [Io\Poll\Event::Read, Io\Poll\Event::Write, Io\Poll\Event::OneShot], "client_data");
+pt_stream_poll_add($poll_ctx, $server, [Io\Poll\Event::Read, Io\Poll\Event::Write, Io\Poll\Event::OneShot], "server_data");
+
+pt_write_sleep($client, "test data");
+pt_expect_events($poll_ctx->wait(0), [
+    ['events' => [Io\Poll\Event::Write], 'data' => 'client_data'],
+    ['events' => [Io\Poll\Event::Read, Io\Poll\Event::Write], 'data' => 'server_data']
+]);
+
+pt_write_sleep($client, "test data");
+pt_expect_events($poll_ctx->wait(0, 100000), []);
+
+?>
+--EXPECT--
+Events matched - count: 2
+Events matched - count: 0
diff --git a/ext/standard/tests/poll/poll_stream_tcp_rw_single_level.phpt b/ext/standard/tests/poll/poll_stream_tcp_rw_single_level.phpt
new file mode 100644
index 00000000000..f29a74f19ed
--- /dev/null
+++ b/ext/standard/tests/poll/poll_stream_tcp_rw_single_level.phpt
@@ -0,0 +1,28 @@
+--TEST--
+Poll stream - TCP read write level combined
+--FILE--
+<?php
+require_once __DIR__ . '/poll.inc';
+
+list($client, $server) = pt_new_tcp_socket_pair();
+$poll_ctx = pt_new_stream_poll();
+
+pt_stream_poll_add($poll_ctx, $client, [Io\Poll\Event::Read, Io\Poll\Event::Write], "client_data");
+pt_stream_poll_add($poll_ctx, $server, [Io\Poll\Event::Read, Io\Poll\Event::Write], "server_data");
+
+pt_expect_events($poll_ctx->wait(0), [
+    ['events' => [Io\Poll\Event::Write], 'data' => 'client_data'],
+    ['events' => [Io\Poll\Event::Write], 'data' => 'server_data']
+]);
+
+fwrite($client, "test data");
+usleep(10000);
+pt_expect_events($poll_ctx->wait(0, 100000), [
+    ['events' => [Io\Poll\Event::Write], 'data' => 'client_data'],
+    ['events' => [Io\Poll\Event::Read, Io\Poll\Event::Write], 'data' => 'server_data', 'read' => 'test data']
+]);
+
+?>
+--EXPECT--
+Events matched - count: 2
+Events matched - count: 2
diff --git a/ext/standard/tests/poll/poll_stream_wait_no_add.phpt b/ext/standard/tests/poll/poll_stream_wait_no_add.phpt
new file mode 100644
index 00000000000..a297fa5e116
--- /dev/null
+++ b/ext/standard/tests/poll/poll_stream_wait_no_add.phpt
@@ -0,0 +1,12 @@
+--TEST--
+Poll stream - only wait
+--FILE--
+<?php
+require_once __DIR__ . '/poll.inc';
+$poll_ctx = pt_new_stream_poll();
+$events = $poll_ctx->wait(0, 100000);
+pt_print_events($events);
+
+?>
+--EXPECT--
+Events count: 0
diff --git a/main/php_poll.h b/main/php_poll.h
index e329c703399..254f885a6d3 100644
--- a/main/php_poll.h
+++ b/main/php_poll.h
@@ -159,7 +159,7 @@ struct php_poll_handle_object {
 };

 #define PHP_POLL_HANDLE_OBJ_FROM_ZOBJ(obj) \
-	((php_poll_handle_object *) ((char *) (obj) - XtOffsetOf(php_poll_handle_object, std)))
+	((php_poll_handle_object *) ((char *) (obj) - offsetof(php_poll_handle_object, std)))

 #define PHP_POLL_HANDLE_OBJ_FROM_ZV(zv) PHP_POLL_HANDLE_OBJ_FROM_ZOBJ(Z_OBJ_P(zv))