From 2a63d178d36c491eacb16e34296da023a538c1e8 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:15:03 +0000 Subject: [PATCH 1/2] Wait for directory changes as an op: PHP_IO_OP_DIR_CHANGES on the ring php_io_op_dir_changes() asks for the next ReadDirectoryChangesW() records of a directory handle on Windows. The ring submits IOR_OP_DIR_CHANGES into a buffer of its own, as it does for a read; a ring without the feature (io_uring, the thread pool, an ior before it) and the poll queue answer Unsupported. A file system watcher can then wait on the completion port instead of a thread of its own. --- main/io/php_io_hooks.c | 9 +++++++++ main/io/php_io_ring.c | 31 +++++++++++++++++++++++++++++++ main/php_io_hooks.h | 8 ++++++++ 3 files changed, 48 insertions(+) diff --git a/main/io/php_io_hooks.c b/main/io/php_io_hooks.c index b9f8229568c6..f1c7b4beba2c 100644 --- a/main/io/php_io_hooks.c +++ b/main/io/php_io_hooks.c @@ -126,6 +126,15 @@ PHPAPI void php_io_op_sigwait(php_io_op *op, zend_object *handle, const php_sigs op->u.sigwait.taken = 0; } +PHPAPI void php_io_op_dir_changes(php_io_op *op, php_socket_t dir, void *buf, size_t len, bool subtree) +{ + /* No readiness: a queue that only polls answers Unsupported */ + php_io_op_init(op, PHP_IO_OP_DIR_CHANGES, NULL, dir, 0, php_io_deadline_infinite()); + op->u.dir_changes.buf = buf; + op->u.dir_changes.len = len; + op->u.dir_changes.subtree = subtree; +} + PHPAPI void php_io_op_read(php_io_op *op, zend_object *handle, php_socket_t fd, void *buf, size_t len, int64_t off, php_deadline dl) { php_io_op_init(op, PHP_IO_OP_READ, handle, fd, PHP_POLL_READ, dl); diff --git a/main/io/php_io_ring.c b/main/io/php_io_ring.c index e38d6e1db589..d5592a5d9db9 100644 --- a/main/io/php_io_ring.c +++ b/main/io/php_io_ring.c @@ -454,6 +454,12 @@ static zend_always_inline unsigned php_io_ring_io_len(php_io_op *op) return (unsigned) MIN(op->u.io.len, UINT32_MAX); } +/* What one directory change wait takes at most: ior counts in unsigned */ +static zend_always_inline unsigned php_io_ring_dir_changes_len(php_io_op *op) +{ + return (unsigned) MIN(op->u.dir_changes.len, UINT32_MAX); +} + /* The inputs are copied and the outputs get slots of their own, so that a * record outliving the op's frame never touches it */ static void php_io_ring_req_capture(php_io_ring_req *req, php_io_op *op) @@ -515,6 +521,9 @@ static void php_io_ring_req_capture(php_io_ring_req *req, php_io_op *op) req->u.io.buf = pemalloc(MAX(php_io_ring_io_len(op), 1), 1); memcpy(req->u.io.buf, op->u.io.buf, php_io_ring_io_len(op)); break; + case PHP_IO_OP_DIR_CHANGES: + req->u.io.buf = pemalloc(MAX(php_io_ring_dir_changes_len(op), 1), 1); + break; default: break; } @@ -569,6 +578,11 @@ static void php_io_ring_req_output(php_io_ring_req *req, php_io_op *op) memcpy(op->u.io.buf, req->u.io.buf, res); } break; + case PHP_IO_OP_DIR_CHANGES: + if (res > 0) { + memcpy(op->u.dir_changes.buf, req->u.io.buf, res); + } + break; default: break; } @@ -615,6 +629,7 @@ static void php_io_ring_req_discard(php_io_ring_req *req) case PHP_IO_OP_RECV: case PHP_IO_OP_WRITE: case PHP_IO_OP_SEND: + case PHP_IO_OP_DIR_CHANGES: if (req->u.io.buf) { pefree(req->u.io.buf, 1); } @@ -1026,6 +1041,13 @@ static zend_result php_io_ring_submit_one(php_io_ring *ring, php_io_ring_req *re case PHP_IO_OP_WAITPID: case PHP_IO_OP_SIGWAIT: break; + case PHP_IO_OP_DIR_CHANGES: +#ifdef IOR_FEAT_DIR_CHANGES + if (ring->features & IOR_FEAT_DIR_CHANGES) { + break; + } +#endif + ZEND_FALLTHROUGH; default: errno = ENOTSUP; return FAILURE; @@ -1106,6 +1128,15 @@ static zend_result php_io_ring_submit_one(php_io_ring *ring, php_io_ring_req *re err = rc < 0 ? -rc : 0; break; } +#ifdef IOR_FEAT_DIR_CHANGES + case PHP_IO_OP_DIR_CHANGES: { + int rc = ior_prep_dir_changes(ctx, sqe, (ior_fd_t) op->fd, req->u.io.buf, php_io_ring_dir_changes_len(op), + op->u.dir_changes.subtree ? IOR_DIR_CHANGES_SUBTREE : 0); + err = rc < 0 ? -rc : 0; + uses_caller = true; + break; + } +#endif default: ZEND_UNREACHABLE(); } diff --git a/main/php_io_hooks.h b/main/php_io_hooks.h index d82eb5dbf43c..e0e785ce7cf2 100644 --- a/main/php_io_hooks.h +++ b/main/php_io_hooks.h @@ -37,6 +37,7 @@ typedef enum { PHP_IO_OP_FSYNC, PHP_IO_OP_WAITPID, PHP_IO_OP_SIGWAIT, + PHP_IO_OP_DIR_CHANGES, PHP_IO_OP_ANY, } php_io_op_type; @@ -122,6 +123,8 @@ struct _php_io_op { struct { pid_t pid; int options; int *status; } waitpid; /* fd is the signalfd, taken a signal a handle recorded */ struct { const php_sigset_t *set; php_siginfo_t *info; int taken; } sigwait; + /* fd is a directory HANDLE opened with FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED */ + struct { void *buf; size_t len; bool subtree; } dir_changes; struct { php_io_op **ops; /* caller owned */ uint32_t n; @@ -172,6 +175,11 @@ PHPAPI void php_io_op_waitpid(php_io_op *op, zend_object *handle, pid_t pid, int php_deadline dl); PHPAPI void php_io_op_sigwait(php_io_op *op, zend_object *handle, const php_sigset_t *set, php_siginfo_t *info, php_deadline dl); +/* Windows: the next changes in a directory, ReadDirectoryChangesW()'s FILE_NOTIFY_INFORMATION records + * in buf, which is DWORD-aligned. Done with the bytes written, 0 when changes were lost and the + * caller rescans; changes between two ops on the handle are kept. A queue without such a wait + * answers Unsupported. */ +PHPAPI void php_io_op_dir_changes(php_io_op *op, php_socket_t dir, void *buf, size_t len, bool subtree); PHPAPI void php_io_op_any(php_io_op *op, php_io_op **members, uint32_t n, php_io_op_result *results); /* The op's handle, created for a stream op on the first call and kept by the stream: borrowed */ From e06ed0973a2f2abfbf6bae95d5b96a45ab121eb0 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:40:52 +0000 Subject: [PATCH 2/2] Check PHP_IO_OP_DIR_CHANGES' result, rescan after any completion but Done A provider's Done may not report more bytes than buf holds, and userland can't fake its data. The parameter is fd, as in the other constructors, and buf's alignment is asserted. The header says the caller rescans after a completion other than Done, where changes may be lost. --- ext/standard/io_hooks.c | 3 ++- main/io/php_io_hooks.c | 20 +++++++++++--------- main/io/php_io_ring.c | 2 +- main/php_io_hooks.h | 14 ++++++++------ 4 files changed, 22 insertions(+), 17 deletions(-) diff --git a/ext/standard/io_hooks.c b/ext/standard/io_hooks.c index bdb4239027f8..207c4d31afe2 100644 --- a/ext/standard/io_hooks.c +++ b/ext/standard/io_hooks.c @@ -1347,7 +1347,7 @@ static void php_io_hooks_php_call(zend_fcall_info_cache *fcc, zval *retval, zend } /* A Done that hands data to the caller: bytes in its buffer, a descriptor, - * an exited child, a taken signal, a resolved name */ + * an exited child, a taken signal, a resolved name, directory changes */ static bool php_io_op_result_is_data(php_io_op *op, const php_io_completion_obj *c) { if (c->status != PHP_IO_DONE || c->error) { @@ -1356,6 +1356,7 @@ static bool php_io_op_result_is_data(php_io_op *op, const php_io_completion_obj switch (op->type) { case PHP_IO_OP_READ: case PHP_IO_OP_RECV: + case PHP_IO_OP_DIR_CHANGES: return c->res != 0; case PHP_IO_OP_ACCEPT: case PHP_IO_OP_WAITPID: diff --git a/main/io/php_io_hooks.c b/main/io/php_io_hooks.c index f1c7b4beba2c..6d9a56be586f 100644 --- a/main/io/php_io_hooks.c +++ b/main/io/php_io_hooks.c @@ -126,15 +126,6 @@ PHPAPI void php_io_op_sigwait(php_io_op *op, zend_object *handle, const php_sigs op->u.sigwait.taken = 0; } -PHPAPI void php_io_op_dir_changes(php_io_op *op, php_socket_t dir, void *buf, size_t len, bool subtree) -{ - /* No readiness: a queue that only polls answers Unsupported */ - php_io_op_init(op, PHP_IO_OP_DIR_CHANGES, NULL, dir, 0, php_io_deadline_infinite()); - op->u.dir_changes.buf = buf; - op->u.dir_changes.len = len; - op->u.dir_changes.subtree = subtree; -} - PHPAPI void php_io_op_read(php_io_op *op, zend_object *handle, php_socket_t fd, void *buf, size_t len, int64_t off, php_deadline dl) { php_io_op_init(op, PHP_IO_OP_READ, handle, fd, PHP_POLL_READ, dl); @@ -210,6 +201,15 @@ PHPAPI void php_io_op_fsync(php_io_op *op, zend_object *handle, php_socket_t fd, op->u.fsync.data_only = data_only; } +PHPAPI void php_io_op_dir_changes(php_io_op *op, php_socket_t fd, void *buf, size_t len, bool subtree) +{ + ZEND_ASSERT(((uintptr_t) buf & 3) == 0); + php_io_op_init(op, PHP_IO_OP_DIR_CHANGES, NULL, fd, 0, php_io_deadline_infinite()); + op->u.dir_changes.buf = buf; + op->u.dir_changes.len = len; + op->u.dir_changes.subtree = subtree; +} + PHPAPI void php_io_op_any(php_io_op *op, php_io_op **members, uint32_t n, php_io_op_result *results) { php_io_op_init(op, PHP_IO_OP_ANY, NULL, SOCK_ERR, 0, php_io_deadline_infinite()); @@ -1031,6 +1031,8 @@ static bool php_io_result_valid(const php_io_op *op, const php_io_op_result *res case PHP_IO_OP_RECV: case PHP_IO_OP_SEND: return result->res >= 0 && (uint64_t) result->res <= op->u.io.len; + case PHP_IO_OP_DIR_CHANGES: + return result->res >= 0 && (uint64_t) result->res <= op->u.dir_changes.len; case PHP_IO_OP_ACCEPT: return result->res >= 0; case PHP_IO_OP_WAITPID: diff --git a/main/io/php_io_ring.c b/main/io/php_io_ring.c index d5592a5d9db9..89fd3df81b81 100644 --- a/main/io/php_io_ring.c +++ b/main/io/php_io_ring.c @@ -455,7 +455,7 @@ static zend_always_inline unsigned php_io_ring_io_len(php_io_op *op) } /* What one directory change wait takes at most: ior counts in unsigned */ -static zend_always_inline unsigned php_io_ring_dir_changes_len(php_io_op *op) +static zend_always_inline unsigned php_io_ring_dir_changes_len(const php_io_op *op) { return (unsigned) MIN(op->u.dir_changes.len, UINT32_MAX); } diff --git a/main/php_io_hooks.h b/main/php_io_hooks.h index e0e785ce7cf2..94227580ffa6 100644 --- a/main/php_io_hooks.h +++ b/main/php_io_hooks.h @@ -123,7 +123,8 @@ struct _php_io_op { struct { pid_t pid; int options; int *status; } waitpid; /* fd is the signalfd, taken a signal a handle recorded */ struct { const php_sigset_t *set; php_siginfo_t *info; int taken; } sigwait; - /* fd is a directory HANDLE opened with FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED */ + /* fd is a directory HANDLE opened with FILE_LIST_DIRECTORY and + * FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED */ struct { void *buf; size_t len; bool subtree; } dir_changes; struct { php_io_op **ops; /* caller owned */ @@ -175,11 +176,12 @@ PHPAPI void php_io_op_waitpid(php_io_op *op, zend_object *handle, pid_t pid, int php_deadline dl); PHPAPI void php_io_op_sigwait(php_io_op *op, zend_object *handle, const php_sigset_t *set, php_siginfo_t *info, php_deadline dl); -/* Windows: the next changes in a directory, ReadDirectoryChangesW()'s FILE_NOTIFY_INFORMATION records - * in buf, which is DWORD-aligned. Done with the bytes written, 0 when changes were lost and the - * caller rescans; changes between two ops on the handle are kept. A queue without such a wait - * answers Unsupported. */ -PHPAPI void php_io_op_dir_changes(php_io_op *op, php_socket_t dir, void *buf, size_t len, bool subtree); +/* Windows: the next changes in a directory, ReadDirectoryChangesW()'s FILE_NOTIFY_INFORMATION + * records in buf, which must be DWORD-aligned. Done with the bytes written; after 0 (changes were + * lost) or any other completion the caller rescans. A queue that cannot wait for them answers + * Unsupported. */ +PHPAPI void php_io_op_dir_changes(php_io_op *op, php_socket_t fd, void *buf, size_t len, + bool subtree); PHPAPI void php_io_op_any(php_io_op *op, php_io_op **members, uint32_t n, php_io_op_result *results); /* The op's handle, created for a stream op on the first call and kept by the stream: borrowed */