From 9e34876cab57f49ccf41008b560728c58978d881 Mon Sep 17 00:00:00 2001 From: Arnaud Le Blanc Date: Mon, 25 May 2026 14:06:31 +0200 Subject: [PATCH 1/9] Stream hook Introduce function stream_set_hook(callable $hook). The given hook is called just before performing a read or write operation on any stream, and must have the following signature: function (/*resource*/ $fd, StreamOperation $operation). --- ext/standard/basic_functions.c | 12 ++ ext/standard/basic_functions.stub.php | 7 ++ ext/standard/basic_functions_arginfo.h | 19 +++- ext/standard/basic_functions_decl.h | 13 ++- ext/standard/file.h | 1 + ext/standard/streamsfuncs.c | 24 ++++ ext/standard/tests/streams/hooks/001.phpt | 130 ++++++++++++++++++++++ main/php_streams.h | 6 + main/streams/streams.c | 54 +++++++++ 9 files changed, 261 insertions(+), 5 deletions(-) create mode 100644 ext/standard/tests/streams/hooks/001.phpt diff --git a/ext/standard/basic_functions.c b/ext/standard/basic_functions.c index d63f8cbbfe64..8691d5bb5cd8 100644 --- a/ext/standard/basic_functions.c +++ b/ext/standard/basic_functions.c @@ -137,6 +137,9 @@ typedef struct { static void user_shutdown_function_dtor(zval *zv); static void user_tick_function_dtor(user_tick_function_entry *tick_function_entry); +// TODO: move elsewhere +PHPAPI zend_class_entry *php_stream_operation_ce; + static const zend_module_dep standard_deps[] = { /* {{{ */ ZEND_MOD_REQUIRED("random") ZEND_MOD_REQUIRED("uri") @@ -338,6 +341,8 @@ PHP_MINIT_FUNCTION(basic) /* {{{ */ BASIC_MINIT_SUBMODULE(user_streams) + php_stream_operation_ce = register_class_StreamOperation(); + php_register_url_stream_wrapper("php", &php_stream_php_wrapper); php_register_url_stream_wrapper("file", &php_plain_files_wrapper); php_register_url_stream_wrapper("glob", &php_glob_stream_wrapper); @@ -423,6 +428,8 @@ PHP_RINIT_FUNCTION(basic) /* {{{ */ /* Default to global filters only */ FG(stream_filters) = NULL; + FG(hook_fcc) = empty_fcall_info_cache; + return SUCCESS; } /* }}} */ @@ -483,6 +490,11 @@ PHP_RSHUTDOWN_FUNCTION(basic) /* {{{ */ BG(page_uid) = -1; BG(page_gid) = -1; + + if (ZEND_FCC_INITIALIZED(FG(hook_fcc))) { + zend_fcc_dtor(&FG(hook_fcc)); + } + return SUCCESS; } /* }}} */ diff --git a/ext/standard/basic_functions.stub.php b/ext/standard/basic_functions.stub.php index 1999c9b92be1..9d377a3c329f 100644 --- a/ext/standard/basic_functions.stub.php +++ b/ext/standard/basic_functions.stub.php @@ -3578,6 +3578,13 @@ function sapi_windows_vt100_support($stream, ?bool $enable = null): bool {} /** @param resource $stream */ function stream_set_chunk_size($stream, int $size): int {} +enum StreamOperation { + case Read; + case Write; +}; + +function stream_set_hook(?callable $hook): ?callable {} + #if (defined(HAVE_SYS_TIME_H) || defined(PHP_WIN32)) /** @param resource $stream */ function stream_set_timeout($stream, int $seconds, int $microseconds = 0): bool {} diff --git a/ext/standard/basic_functions_arginfo.h b/ext/standard/basic_functions_arginfo.h index e51a837ffa4d..402413ee629a 100644 --- a/ext/standard/basic_functions_arginfo.h +++ b/ext/standard/basic_functions_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit basic_functions.stub.php instead. - * Stub hash: 36b71aa7bbfe478a5e4af400b2822a77067efa2f + * Stub hash: 7875f65f7ae0c4b2491295c6613afe628db17168 * Has decl header: yes */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_set_time_limit, 0, 1, _IS_BOOL, 0) @@ -2002,6 +2002,10 @@ ZEND_END_ARG_INFO() #define arginfo_stream_set_chunk_size arginfo_stream_set_write_buffer +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_stream_set_hook, 0, 1, IS_CALLABLE, 1) + ZEND_ARG_TYPE_INFO(0, hook, IS_CALLABLE, 1) +ZEND_END_ARG_INFO() + #if (defined(HAVE_SYS_TIME_H) || defined(PHP_WIN32)) ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_stream_set_timeout, 0, 2, _IS_BOOL, 0) ZEND_ARG_INFO(0, stream) @@ -2836,6 +2840,7 @@ ZEND_FUNCTION(stream_isatty); ZEND_FUNCTION(sapi_windows_vt100_support); #endif ZEND_FUNCTION(stream_set_chunk_size); +ZEND_FUNCTION(stream_set_hook); #if (defined(HAVE_SYS_TIME_H) || defined(PHP_WIN32)) ZEND_FUNCTION(stream_set_timeout); #endif @@ -3452,6 +3457,7 @@ static const zend_function_entry ext_functions[] = { ZEND_FE(sapi_windows_vt100_support, arginfo_sapi_windows_vt100_support) #endif ZEND_FE(stream_set_chunk_size, arginfo_stream_set_chunk_size) + ZEND_FE(stream_set_hook, arginfo_stream_set_hook) #if (defined(HAVE_SYS_TIME_H) || defined(PHP_WIN32)) ZEND_FE(stream_set_timeout, arginfo_stream_set_timeout) ZEND_RAW_FENTRY("socket_set_timeout", zif_stream_set_timeout, arginfo_socket_set_timeout, ZEND_ACC_DEPRECATED, NULL, NULL) @@ -4045,3 +4051,14 @@ static zend_class_entry *register_class_RoundingMode(void) return class_entry; } + +static zend_class_entry *register_class_StreamOperation(void) +{ + zend_class_entry *class_entry = zend_register_internal_enum("StreamOperation", IS_UNDEF, NULL); + + zend_enum_add_case_cstr(class_entry, "Read", NULL); + + zend_enum_add_case_cstr(class_entry, "Write", NULL); + + return class_entry; +} diff --git a/ext/standard/basic_functions_decl.h b/ext/standard/basic_functions_decl.h index b3eb25c5d988..7382d0b8bcb7 100644 --- a/ext/standard/basic_functions_decl.h +++ b/ext/standard/basic_functions_decl.h @@ -1,8 +1,8 @@ /* This is a generated file, edit basic_functions.stub.php instead. - * Stub hash: 36b71aa7bbfe478a5e4af400b2822a77067efa2f */ + * Stub hash: 7875f65f7ae0c4b2491295c6613afe628db17168 */ -#ifndef ZEND_BASIC_FUNCTIONS_DECL_36b71aa7bbfe478a5e4af400b2822a77067efa2f_H -#define ZEND_BASIC_FUNCTIONS_DECL_36b71aa7bbfe478a5e4af400b2822a77067efa2f_H +#ifndef ZEND_BASIC_FUNCTIONS_DECL_7875f65f7ae0c4b2491295c6613afe628db17168_H +#define ZEND_BASIC_FUNCTIONS_DECL_7875f65f7ae0c4b2491295c6613afe628db17168_H typedef enum zend_enum_SortDirection { ZEND_ENUM_SortDirection_Ascending = 1, @@ -20,4 +20,9 @@ typedef enum zend_enum_RoundingMode { ZEND_ENUM_RoundingMode_PositiveInfinity = 8, } zend_enum_RoundingMode; -#endif /* ZEND_BASIC_FUNCTIONS_DECL_36b71aa7bbfe478a5e4af400b2822a77067efa2f_H */ +typedef enum zend_enum_StreamOperation { + ZEND_ENUM_StreamOperation_Read = 1, + ZEND_ENUM_StreamOperation_Write = 2, +} zend_enum_StreamOperation; + +#endif /* ZEND_BASIC_FUNCTIONS_DECL_7875f65f7ae0c4b2491295c6613afe628db17168_H */ diff --git a/ext/standard/file.h b/ext/standard/file.h index 3c6160fd4bb1..874400eeff68 100644 --- a/ext/standard/file.h +++ b/ext/standard/file.h @@ -100,6 +100,7 @@ typedef struct { HashTable *stream_filters; /* per-request copy of stream_filters_hash */ HashTable *wrapper_errors; /* key: wrapper address; value: linked list of char* */ int pclose_wait; + zend_fcall_info_cache hook_fcc; #ifdef HAVE_GETHOSTBYNAME_R struct hostent tmp_host_info; char *tmp_host_buf; diff --git a/ext/standard/streamsfuncs.c b/ext/standard/streamsfuncs.c index 68f79783c6f8..32e1b83b337c 100644 --- a/ext/standard/streamsfuncs.c +++ b/ext/standard/streamsfuncs.c @@ -1703,3 +1703,27 @@ PHP_FUNCTION(stream_socket_shutdown) } /* }}} */ #endif + +PHP_FUNCTION(stream_set_hook) +{ + zend_fcall_info fci; + zend_fcall_info_cache fcc; + + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_FUNC_OR_NULL(fci, fcc) + ZEND_PARSE_PARAMETERS_END(); + + if (ZEND_FCC_INITIALIZED(FG(hook_fcc))) { + zend_get_callable_zval_from_fcc(&FG(hook_fcc), return_value); + zend_fcc_dtor(&FG(hook_fcc)); + } + + if (!ZEND_FCI_INITIALIZED(fci)) { + return; + } + + if (!ZEND_FCC_INITIALIZED(fcc)) { + zend_is_callable_ex(&fci.function_name, NULL, IS_CALLABLE_SUPPRESS_DEPRECATIONS, NULL, &fcc, NULL); + } + zend_fcc_dup(&FG(hook_fcc), &fcc); +} diff --git a/ext/standard/tests/streams/hooks/001.phpt b/ext/standard/tests/streams/hooks/001.phpt new file mode 100644 index 000000000000..4a887f5ecff4 --- /dev/null +++ b/ext/standard/tests/streams/hooks/001.phpt @@ -0,0 +1,130 @@ +--TEST-- +Stream hook: use case +--FILE-- +ready[] = new Fiber($main); + + while ($this->ready !== [] || $this->readFds !== [] || $this->writeFds !== []) { + $this->runReadyFibers(); + $this->pollFds(); + } + } + + public function runReadyFibers() + { + while ($this->ready !== []) { + $fiber = array_shift($this->ready); + $fiber->isSuspended() ? $fiber->resume() : $fiber->start(); + } + } + + public function pollFds() + { + if ($this->readFds !== [] || $this->writeFds !== []) { + $read = $this->readFds; + $write = $this->writeFds; + $except = []; + stream_select($read, $write, $except, null); + foreach ($read as $fd) { + $id = (int)$fd; + array_push($this->ready, ...$this->readFibers[$id]); + unset($this->readFds[$id]); + unset($this->readFibers[$id]); + } + foreach ($write as $fd) { + $id = (int)$fd; + array_push($this->ready, ...$this->writeFibers[$id]); + unset($this->writeFds[$id]); + unset($this->writeFibers[$id]); + } + } + } + + public function waitRead($fd) { + $id = (int)$fd; + $this->readFds[$id] = $fd; + $this->readFibers[$id][] = Fiber::getCurrent(); + Fiber::suspend(); + } + + public function waitWrite($fd) { + $id = (int)$fd; + $this->writeFds[$id] = $fd; + $this->writeFibers[$id][] = Fiber::getCurrent(); + Fiber::suspend(); + } + + public function go(callable $fn) { + $this->ready[] = new Fiber($fn); + $this->ready[] = Fiber::getCurrent(); + Fiber::suspend(); + } +} + +$scheduler = new Scheduler(); + +stream_set_hook(function ($fd, StreamOperation $operation) use ($scheduler) { + switch ($operation) { + case StreamOperation::Read: + $scheduler->waitRead($fd); + break; + case StreamOperation::Write: + $scheduler->waitWrite($fd); + break; + } +}); + +function go($fn) { + global $scheduler; + $scheduler->go($fn); +} + +$scheduler->run(function () { + [$client, $server] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); + + go(function () use ($client) { + fwrite($client, "GET / HTTP/1.0\r\n"); + fwrite($client, "Host: localhost\r\n"); + fwrite($client, "\r\n"); + while (!feof($client)) { + echo "< " . trim(fgets($client)) . "\n"; + } + fclose($client); + }); + + go(function () use ($server) { + $headers = []; + while (!feof($server)) { + $line = fgets($server); + if ($line === false) { + break; + } + if ($line === "\r\n") { + break; + } + $headers[] = $line; + } + foreach ($headers as $header) { + echo "> " . trim($header) . "\n"; + } + fwrite($server, "Hello world!\n"); + }); +}); + +?> +--EXPECT-- +> GET / HTTP/1.0 +> Host: localhost +< Hello world! diff --git a/main/php_streams.h b/main/php_streams.h index d248de7a8168..4d435a33e16b 100644 --- a/main/php_streams.h +++ b/main/php_streams.h @@ -22,6 +22,7 @@ #include #include "zend.h" #include "zend_stream.h" +#include "ext/standard/basic_functions_decl.h" BEGIN_EXTERN_C() PHPAPI int php_file_le_stream(void); @@ -667,6 +668,11 @@ PHPAPI HashTable *_php_get_stream_filters_hash(void); PHPAPI HashTable *php_get_stream_filters_hash_global(void); extern const php_stream_wrapper_ops *php_stream_user_wrapper_ops; +PHPAPI extern zend_class_entry *php_stream_operation_ce; + +/* Call stream hook if any. Returns true if a hook was called. */ +PHPAPI bool php_stream_call_hook(php_stream *stream, zend_enum_StreamOperation); + static inline bool php_is_stream_path(const char *filename) { const char *p; diff --git a/main/streams/streams.c b/main/streams/streams.c index 31d1eda16790..9449991ec7fe 100644 --- a/main/streams/streams.c +++ b/main/streams/streams.c @@ -30,6 +30,7 @@ #include #include #include "php_streams_int.h" +#include "zend_enum.h" /* {{{ resource and registration code */ /* Global wrapper hash, copied to FG(stream_wrappers) on registration of volatile wrapper */ @@ -562,6 +563,13 @@ PHPAPI zend_result _php_stream_fill_read_buffer(php_stream *stream, size_t size) php_stream_filter_status_t status = PSFS_ERR_FATAL; php_stream_filter *filter; + if (php_stream_call_hook(stream, ZEND_ENUM_StreamOperation_Read)) { + if (stream->eof || (stream->writepos - stream->readpos >= (zend_off_t)to_read_now)) { + break; + } + // TODO: stream closed? + } + /* read a chunk into a bucket */ justread = stream->ops->read(stream, chunk_buf, stream->chunk_size); if (justread < 0 && stream->writepos == stream->readpos) { @@ -689,6 +697,13 @@ PHPAPI zend_result _php_stream_fill_read_buffer(php_stream *stream, size_t size) stream->is_persistent); } + if (php_stream_call_hook(stream, ZEND_ENUM_StreamOperation_Read)) { + if (UNEXPECTED(stream->writepos - stream->readpos >= (zend_off_t)size)) { + return SUCCESS; + } + // TODO: stream closed? + } + justread = stream->ops->read(stream, (char*)stream->readbuf + stream->writepos, stream->readbuflen - stream->writepos ); @@ -742,6 +757,14 @@ PHPAPI ssize_t _php_stream_read(php_stream *stream, char *buf, size_t size) } if (!stream->readfilters.head && ((stream->flags & PHP_STREAM_FLAG_NO_BUFFER) || stream->chunk_size == 1)) { + + if (php_stream_call_hook(stream, ZEND_ENUM_StreamOperation_Read)) { + if (UNEXPECTED(stream->writepos > stream->readpos)) { + // TODO: stream closed? + continue; + } + } + toread = stream->ops->read(stream, buf, size); if (toread < 0) { /* Report an error if the read failed and we did not read any data @@ -1173,6 +1196,7 @@ static ssize_t _php_stream_write_buffer(php_stream *stream, const char *buf, siz * current stream->position. This means invalidating the read buffer and then * performing a low-level seek */ if (stream->ops->seek && (stream->flags & PHP_STREAM_FLAG_NO_SEEK) == 0 && stream->readpos != stream->writepos) { +seek: stream->readpos = stream->writepos = 0; stream->ops->seek(stream, stream->position, SEEK_SET, &stream->position); @@ -1188,6 +1212,12 @@ static ssize_t _php_stream_write_buffer(php_stream *stream, const char *buf, siz } while (count > 0) { + if (php_stream_call_hook(stream, ZEND_ENUM_StreamOperation_Write)) { + if (stream->ops->seek && (stream->flags & PHP_STREAM_FLAG_NO_SEEK) == 0 && stream->readpos != stream->writepos) { + goto seek; + } + // TODO: eof? closed? + } ssize_t justwrote = stream->ops->write(stream, buf, MIN(chunk_size, count)); if (justwrote <= 0) { /* If we already successfully wrote some bytes and a write error occurred @@ -2607,3 +2637,27 @@ PHPAPI int _php_stream_scandir(const char *dirname, zend_string **namelist[], in return -1; } /* }}} */ + +static zend_object *php_stream_operation_get_case(zend_enum_StreamOperation operation) +{ + switch (operation) { + case ZEND_ENUM_StreamOperation_Read: + return zend_enum_get_case_cstr(php_stream_operation_ce, "Read"); + case ZEND_ENUM_StreamOperation_Write: + return zend_enum_get_case_cstr(php_stream_operation_ce, "Write"); + default: + ZEND_UNREACHABLE(); + } +} + +PHPAPI bool php_stream_call_hook(php_stream *stream, zend_enum_StreamOperation operation) +{ + if (!ZEND_FCC_INITIALIZED(FG(hook_fcc))) { + return false; + } + zval params[2]; + ZVAL_RES(¶ms[0], stream->res); + ZVAL_OBJ(¶ms[1], php_stream_operation_get_case(operation)); + zend_call_known_fcc(&FG(hook_fcc), NULL, 2, params, NULL); + return true; +} From 3adf8a82136b4c65b964575a27ebed81285e232c Mon Sep 17 00:00:00 2001 From: Arnaud Le Blanc Date: Wed, 3 Jun 2026 10:03:11 +0200 Subject: [PATCH 2/9] stream_socket_accept() --- ext/standard/streamsfuncs.c | 3 ++ ext/standard/tests/streams/hooks/001.phpt | 59 ++++++++++++++--------- 2 files changed, 39 insertions(+), 23 deletions(-) diff --git a/ext/standard/streamsfuncs.c b/ext/standard/streamsfuncs.c index 32e1b83b337c..d68315d0bb0c 100644 --- a/ext/standard/streamsfuncs.c +++ b/ext/standard/streamsfuncs.c @@ -293,6 +293,9 @@ PHP_FUNCTION(stream_socket_accept) tv_pointer = &tv; } + php_stream_call_hook(stream, ZEND_ENUM_StreamOperation_Read); + // TODO: closed? + if (0 == php_stream_xport_accept(stream, &clistream, zpeername ? &peername : NULL, NULL, NULL, diff --git a/ext/standard/tests/streams/hooks/001.phpt b/ext/standard/tests/streams/hooks/001.phpt index 4a887f5ecff4..312d40c3e1e1 100644 --- a/ext/standard/tests/streams/hooks/001.phpt +++ b/ext/standard/tests/streams/hooks/001.phpt @@ -92,34 +92,45 @@ function go($fn) { } $scheduler->run(function () { - [$client, $server] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); - - go(function () use ($client) { - fwrite($client, "GET / HTTP/1.0\r\n"); - fwrite($client, "Host: localhost\r\n"); - fwrite($client, "\r\n"); - while (!feof($client)) { - echo "< " . trim(fgets($client)) . "\n"; - } - fclose($client); - }); + $server = stream_socket_server('tcp://localhost:0'); + $socket_name = stream_socket_get_name($server, false); + if (!preg_match('/:(\d+)$/', $socket_name, $m)) { + die("Could not extract port from '$socket_name'"); + } + $port = $m[1]; go(function () use ($server) { - $headers = []; - while (!feof($server)) { - $line = fgets($server); - if ($line === false) { - break; + $client = stream_socket_accept($server); + go(function () use ($client) { + $headers = []; + while (!feof($client)) { + $line = fgets($client); + if ($line === false) { + break; + } + if ($line === "\r\n") { + break; + } + $headers[] = $line; } - if ($line === "\r\n") { - break; + foreach ($headers as $header) { + echo "> " . trim($header) . "\n"; } - $headers[] = $line; - } - foreach ($headers as $header) { - echo "> " . trim($header) . "\n"; + fwrite($client, "HTTP/1.0 200 OK\r\n"); + fwrite($client, "\r\n"); + fwrite($client, "Hello world!\n"); + }); + }); + + go(function () use ($port) { + $fd = stream_socket_client("tcp://localhost:$port"); + fwrite($fd, "GET / HTTP/1.0\r\n"); + fwrite($fd, "Host: localhost\r\n"); + fwrite($fd, "\r\n"); + while (!feof($fd)) { + echo "< " . trim(fgets($fd)) . "\n"; } - fwrite($server, "Hello world!\n"); + fclose($fd); }); }); @@ -127,4 +138,6 @@ $scheduler->run(function () { --EXPECT-- > GET / HTTP/1.0 > Host: localhost +< HTTP/1.0 200 OK +< < Hello world! From a6860b51548883b28e059a4a66fa2c0ca3a24664 Mon Sep 17 00:00:00 2001 From: Arnaud Le Blanc Date: Wed, 3 Jun 2026 10:48:34 +0200 Subject: [PATCH 3/9] Explore fclose() handling Closing a stream resource frees the stream itself, so doing that in a hook will result in UAFs in some parent function. Possible solutions: * Deny fclose() during hook invokation * Never access streams after stream operations, or use the resource to check whether the stream was closed * Do not free the php_stream itself in fclose(). Replace ->ops with always-fail handlers, mark as eof. --- ext/standard/streamsfuncs.c | 12 ++- .../tests/streams/hooks/hook-close-fgets.phpt | 16 ++++ .../streams/hooks/hook-close-fwrite.phpt | 15 ++++ .../streams/hooks/{001.phpt => use-case.phpt} | 2 +- main/php_streams.h | 10 ++- main/streams/streams.c | 79 +++++++++++++------ 6 files changed, 106 insertions(+), 28 deletions(-) create mode 100644 ext/standard/tests/streams/hooks/hook-close-fgets.phpt create mode 100644 ext/standard/tests/streams/hooks/hook-close-fwrite.phpt rename ext/standard/tests/streams/hooks/{001.phpt => use-case.phpt} (99%) diff --git a/ext/standard/streamsfuncs.c b/ext/standard/streamsfuncs.c index d68315d0bb0c..3976b405611b 100644 --- a/ext/standard/streamsfuncs.c +++ b/ext/standard/streamsfuncs.c @@ -293,8 +293,15 @@ PHP_FUNCTION(stream_socket_accept) tv_pointer = &tv; } - php_stream_call_hook(stream, ZEND_ENUM_StreamOperation_Read); - // TODO: closed? + // ???: Timeout handling? + switch (php_stream_call_hook(stream, ZEND_ENUM_StreamOperation_Read)) { + case PHP_STREAM_HOOK_STREAM_CLOSED: + RETVAL_FALSE; + goto out; + case PHP_STREAM_HOOK_INVOKED: + case PHP_STREAM_HOOK_NO_HOOK: + break; + } if (0 == php_stream_xport_accept(stream, &clistream, zpeername ? &peername : NULL, @@ -314,6 +321,7 @@ PHP_FUNCTION(stream_socket_accept) RETVAL_FALSE; } +out: if (errstr) { zend_string_release_ex(errstr, 0); } diff --git a/ext/standard/tests/streams/hooks/hook-close-fgets.phpt b/ext/standard/tests/streams/hooks/hook-close-fgets.phpt new file mode 100644 index 000000000000..8fc9147c77df --- /dev/null +++ b/ext/standard/tests/streams/hooks/hook-close-fgets.phpt @@ -0,0 +1,16 @@ +--TEST-- +Stream hook: closed during read hook +--XFAIL-- +UAF in _php_stream_get_line +--FILE-- + +--EXPECTF-- diff --git a/ext/standard/tests/streams/hooks/hook-close-fwrite.phpt b/ext/standard/tests/streams/hooks/hook-close-fwrite.phpt new file mode 100644 index 000000000000..b4fd87e6c6f9 --- /dev/null +++ b/ext/standard/tests/streams/hooks/hook-close-fwrite.phpt @@ -0,0 +1,15 @@ +--TEST-- +Stream hook: closed during write hook +--FILE-- + +--EXPECT-- + diff --git a/ext/standard/tests/streams/hooks/001.phpt b/ext/standard/tests/streams/hooks/use-case.phpt similarity index 99% rename from ext/standard/tests/streams/hooks/001.phpt rename to ext/standard/tests/streams/hooks/use-case.phpt index 312d40c3e1e1..c31a1aeb3aea 100644 --- a/ext/standard/tests/streams/hooks/001.phpt +++ b/ext/standard/tests/streams/hooks/use-case.phpt @@ -139,5 +139,5 @@ $scheduler->run(function () { > GET / HTTP/1.0 > Host: localhost < HTTP/1.0 200 OK -< +< < Hello world! diff --git a/main/php_streams.h b/main/php_streams.h index 4d435a33e16b..3ec10da2459e 100644 --- a/main/php_streams.h +++ b/main/php_streams.h @@ -670,8 +670,14 @@ extern const php_stream_wrapper_ops *php_stream_user_wrapper_ops; PHPAPI extern zend_class_entry *php_stream_operation_ce; -/* Call stream hook if any. Returns true if a hook was called. */ -PHPAPI bool php_stream_call_hook(php_stream *stream, zend_enum_StreamOperation); +C23_ENUM(php_stream_hook_result, uint8_t) { + PHP_STREAM_HOOK_NO_HOOK, /* No hook was invoked */ + PHP_STREAM_HOOK_INVOKED, /* Hook was invoked */ + PHP_STREAM_HOOK_STREAM_CLOSED, /* Stream was closed during hook invokation */ +}; + +/* Call stream hook if any */ +PHPAPI php_stream_hook_result php_stream_call_hook(php_stream *stream, zend_enum_StreamOperation); static inline bool php_is_stream_path(const char *filename) { diff --git a/main/streams/streams.c b/main/streams/streams.c index 9449991ec7fe..5378b5fb7ad1 100644 --- a/main/streams/streams.c +++ b/main/streams/streams.c @@ -563,11 +563,18 @@ PHPAPI zend_result _php_stream_fill_read_buffer(php_stream *stream, size_t size) php_stream_filter_status_t status = PSFS_ERR_FATAL; php_stream_filter *filter; - if (php_stream_call_hook(stream, ZEND_ENUM_StreamOperation_Read)) { - if (stream->eof || (stream->writepos - stream->readpos >= (zend_off_t)to_read_now)) { + switch (php_stream_call_hook(stream, ZEND_ENUM_StreamOperation_Read)) { + case PHP_STREAM_HOOK_STREAM_CLOSED: + return FAILURE; + case PHP_STREAM_HOOK_INVOKED: + // ???: Should polling mechanisms report streams as + // ready when read buffer is non-empty? + if (stream->eof || (stream->writepos - stream->readpos >= (zend_off_t)to_read_now)) { + goto done; + } + break; + case PHP_STREAM_HOOK_NO_HOOK: break; - } - // TODO: stream closed? } /* read a chunk into a bucket */ @@ -673,6 +680,7 @@ PHPAPI zend_result _php_stream_fill_read_buffer(php_stream *stream, size_t size) } } +done: efree(chunk_buf); return SUCCESS; } else { @@ -697,11 +705,16 @@ PHPAPI zend_result _php_stream_fill_read_buffer(php_stream *stream, size_t size) stream->is_persistent); } - if (php_stream_call_hook(stream, ZEND_ENUM_StreamOperation_Read)) { - if (UNEXPECTED(stream->writepos - stream->readpos >= (zend_off_t)size)) { - return SUCCESS; - } - // TODO: stream closed? + switch (php_stream_call_hook(stream, ZEND_ENUM_StreamOperation_Read)) { + case PHP_STREAM_HOOK_STREAM_CLOSED: + return FAILURE; + case PHP_STREAM_HOOK_INVOKED: + if (UNEXPECTED(stream->writepos - stream->readpos >= (zend_off_t)size)) { + return SUCCESS; + } + break; + case PHP_STREAM_HOOK_NO_HOOK: + break; } justread = stream->ops->read(stream, (char*)stream->readbuf + stream->writepos, @@ -758,11 +771,16 @@ PHPAPI ssize_t _php_stream_read(php_stream *stream, char *buf, size_t size) if (!stream->readfilters.head && ((stream->flags & PHP_STREAM_FLAG_NO_BUFFER) || stream->chunk_size == 1)) { - if (php_stream_call_hook(stream, ZEND_ENUM_StreamOperation_Read)) { - if (UNEXPECTED(stream->writepos > stream->readpos)) { - // TODO: stream closed? - continue; - } + switch (php_stream_call_hook(stream, ZEND_ENUM_StreamOperation_Read)) { + case PHP_STREAM_HOOK_STREAM_CLOSED: + return didread; + case PHP_STREAM_HOOK_INVOKED: + if (UNEXPECTED(stream->writepos > stream->readpos)) { + continue; + } + break; + case PHP_STREAM_HOOK_NO_HOOK: + break; } toread = stream->ops->read(stream, buf, size); @@ -1212,12 +1230,18 @@ static ssize_t _php_stream_write_buffer(php_stream *stream, const char *buf, siz } while (count > 0) { - if (php_stream_call_hook(stream, ZEND_ENUM_StreamOperation_Write)) { - if (stream->ops->seek && (stream->flags & PHP_STREAM_FLAG_NO_SEEK) == 0 && stream->readpos != stream->writepos) { - goto seek; - } - // TODO: eof? closed? + switch (php_stream_call_hook(stream, ZEND_ENUM_StreamOperation_Write)) { + case PHP_STREAM_HOOK_STREAM_CLOSED: + return didwrite; + case PHP_STREAM_HOOK_INVOKED: + if (stream->ops->seek && (stream->flags & PHP_STREAM_FLAG_NO_SEEK) == 0 && stream->readpos != stream->writepos) { + goto seek; + } + break; + case PHP_STREAM_HOOK_NO_HOOK: + break; } + ssize_t justwrote = stream->ops->write(stream, buf, MIN(chunk_size, count)); if (justwrote <= 0) { /* If we already successfully wrote some bytes and a write error occurred @@ -2650,14 +2674,23 @@ static zend_object *php_stream_operation_get_case(zend_enum_StreamOperation oper } } -PHPAPI bool php_stream_call_hook(php_stream *stream, zend_enum_StreamOperation operation) +PHPAPI php_stream_hook_result php_stream_call_hook(php_stream *stream, zend_enum_StreamOperation operation) { if (!ZEND_FCC_INITIALIZED(FG(hook_fcc))) { - return false; + return PHP_STREAM_HOOK_NO_HOOK; } + + zend_resource *res = stream->res; + zval params[2]; - ZVAL_RES(¶ms[0], stream->res); + ZVAL_RES(¶ms[0], res); ZVAL_OBJ(¶ms[1], php_stream_operation_get_case(operation)); zend_call_known_fcc(&FG(hook_fcc), NULL, 2, params, NULL); - return true; + + if (res->type == -1) { + // TODO: https://wiki.php.net/rfc/stream_errors + return PHP_STREAM_HOOK_STREAM_CLOSED; + } + + return PHP_STREAM_HOOK_INVOKED; } From 738db1d8a23eeab90a3b925af1d69a6615aac26e Mon Sep 17 00:00:00 2001 From: Arnaud Le Blanc Date: Thu, 4 Jun 2026 16:21:40 +0200 Subject: [PATCH 4/9] Prevent closing stream during hook invokation --- ext/standard/streamsfuncs.c | 10 +--------- .../tests/streams/hooks/hook-close-fgets.phpt | 5 +++-- .../streams/hooks/hook-close-fwrite.phpt | 5 +++-- .../tests/streams/hooks/use-case.phpt | 2 +- main/php_streams.h | 1 - main/streams/streams.c | 19 +++++-------------- 6 files changed, 13 insertions(+), 29 deletions(-) diff --git a/ext/standard/streamsfuncs.c b/ext/standard/streamsfuncs.c index 3976b405611b..148dbe2a5f8c 100644 --- a/ext/standard/streamsfuncs.c +++ b/ext/standard/streamsfuncs.c @@ -294,14 +294,7 @@ PHP_FUNCTION(stream_socket_accept) } // ???: Timeout handling? - switch (php_stream_call_hook(stream, ZEND_ENUM_StreamOperation_Read)) { - case PHP_STREAM_HOOK_STREAM_CLOSED: - RETVAL_FALSE; - goto out; - case PHP_STREAM_HOOK_INVOKED: - case PHP_STREAM_HOOK_NO_HOOK: - break; - } + php_stream_call_hook(stream, ZEND_ENUM_StreamOperation_Read); if (0 == php_stream_xport_accept(stream, &clistream, zpeername ? &peername : NULL, @@ -321,7 +314,6 @@ PHP_FUNCTION(stream_socket_accept) RETVAL_FALSE; } -out: if (errstr) { zend_string_release_ex(errstr, 0); } diff --git a/ext/standard/tests/streams/hooks/hook-close-fgets.phpt b/ext/standard/tests/streams/hooks/hook-close-fgets.phpt index 8fc9147c77df..e86600bc7d83 100644 --- a/ext/standard/tests/streams/hooks/hook-close-fgets.phpt +++ b/ext/standard/tests/streams/hooks/hook-close-fgets.phpt @@ -1,7 +1,5 @@ --TEST-- Stream hook: closed during read hook ---XFAIL-- -UAF in _php_stream_get_line --FILE-- --EXPECTF-- +Warning: fclose(): cannot close the provided stream, as it must not be manually closed in %s on line %d +string(6) " ---EXPECT-- - +--EXPECTF-- +Warning: fclose(): cannot close the provided stream, as it must not be manually closed in %s on line %d +int(1) diff --git a/ext/standard/tests/streams/hooks/use-case.phpt b/ext/standard/tests/streams/hooks/use-case.phpt index c31a1aeb3aea..85bd6b1e4cb1 100644 --- a/ext/standard/tests/streams/hooks/use-case.phpt +++ b/ext/standard/tests/streams/hooks/use-case.phpt @@ -128,7 +128,7 @@ $scheduler->run(function () { fwrite($fd, "Host: localhost\r\n"); fwrite($fd, "\r\n"); while (!feof($fd)) { - echo "< " . trim(fgets($fd)) . "\n"; + echo trim("< " . fgets($fd)) . "\n"; } fclose($fd); }); diff --git a/main/php_streams.h b/main/php_streams.h index 3ec10da2459e..1c9807e5733c 100644 --- a/main/php_streams.h +++ b/main/php_streams.h @@ -673,7 +673,6 @@ PHPAPI extern zend_class_entry *php_stream_operation_ce; C23_ENUM(php_stream_hook_result, uint8_t) { PHP_STREAM_HOOK_NO_HOOK, /* No hook was invoked */ PHP_STREAM_HOOK_INVOKED, /* Hook was invoked */ - PHP_STREAM_HOOK_STREAM_CLOSED, /* Stream was closed during hook invokation */ }; /* Call stream hook if any */ diff --git a/main/streams/streams.c b/main/streams/streams.c index 5378b5fb7ad1..e87a6cf3f74f 100644 --- a/main/streams/streams.c +++ b/main/streams/streams.c @@ -564,8 +564,6 @@ PHPAPI zend_result _php_stream_fill_read_buffer(php_stream *stream, size_t size) php_stream_filter *filter; switch (php_stream_call_hook(stream, ZEND_ENUM_StreamOperation_Read)) { - case PHP_STREAM_HOOK_STREAM_CLOSED: - return FAILURE; case PHP_STREAM_HOOK_INVOKED: // ???: Should polling mechanisms report streams as // ready when read buffer is non-empty? @@ -706,8 +704,6 @@ PHPAPI zend_result _php_stream_fill_read_buffer(php_stream *stream, size_t size) } switch (php_stream_call_hook(stream, ZEND_ENUM_StreamOperation_Read)) { - case PHP_STREAM_HOOK_STREAM_CLOSED: - return FAILURE; case PHP_STREAM_HOOK_INVOKED: if (UNEXPECTED(stream->writepos - stream->readpos >= (zend_off_t)size)) { return SUCCESS; @@ -772,8 +768,6 @@ PHPAPI ssize_t _php_stream_read(php_stream *stream, char *buf, size_t size) if (!stream->readfilters.head && ((stream->flags & PHP_STREAM_FLAG_NO_BUFFER) || stream->chunk_size == 1)) { switch (php_stream_call_hook(stream, ZEND_ENUM_StreamOperation_Read)) { - case PHP_STREAM_HOOK_STREAM_CLOSED: - return didread; case PHP_STREAM_HOOK_INVOKED: if (UNEXPECTED(stream->writepos > stream->readpos)) { continue; @@ -1231,8 +1225,6 @@ static ssize_t _php_stream_write_buffer(php_stream *stream, const char *buf, siz while (count > 0) { switch (php_stream_call_hook(stream, ZEND_ENUM_StreamOperation_Write)) { - case PHP_STREAM_HOOK_STREAM_CLOSED: - return didwrite; case PHP_STREAM_HOOK_INVOKED: if (stream->ops->seek && (stream->flags & PHP_STREAM_FLAG_NO_SEEK) == 0 && stream->readpos != stream->writepos) { goto seek; @@ -2680,17 +2672,16 @@ PHPAPI php_stream_hook_result php_stream_call_hook(php_stream *stream, zend_enum return PHP_STREAM_HOOK_NO_HOOK; } - zend_resource *res = stream->res; + uint32_t orig_no_fclose = stream->flags & PHP_STREAM_FLAG_NO_FCLOSE; + stream->flags |= PHP_STREAM_FLAG_NO_FCLOSE; zval params[2]; - ZVAL_RES(¶ms[0], res); + ZVAL_RES(¶ms[0], stream->res); ZVAL_OBJ(¶ms[1], php_stream_operation_get_case(operation)); zend_call_known_fcc(&FG(hook_fcc), NULL, 2, params, NULL); - if (res->type == -1) { - // TODO: https://wiki.php.net/rfc/stream_errors - return PHP_STREAM_HOOK_STREAM_CLOSED; - } + stream->flags &= ~PHP_STREAM_FLAG_NO_FCLOSE; + stream->flags |= orig_no_fclose; return PHP_STREAM_HOOK_INVOKED; } From 6f8cd98087d29c69f02afda6f97fd78c4dca6073 Mon Sep 17 00:00:00 2001 From: Arnaud Le Blanc Date: Thu, 4 Jun 2026 19:12:12 +0200 Subject: [PATCH 5/9] Call the hook in php_pollfd_for The stream hook is now a php_pollfd_for() replacement. Stream ops typically call php_pollfd_for() before any blocking operations, to implement timeouts. We can hook here to delegate polling to user-space. TODO: * php_pollfd_for() is not called where there is no timeout. Ensure that we call it when a hook is installed. * Prevent concurrent stream ops (lock / serialize) * Timeouts should be handled by the hook --- ext/openssl/xp_ssl.c | 23 ++-- ext/standard/basic_functions.c | 4 +- ext/standard/basic_functions.stub.php | 7 +- ext/standard/basic_functions_arginfo.h | 12 +- ext/standard/basic_functions_decl.h | 17 +-- ext/standard/io_poll.c | 2 +- ext/standard/io_poll.h | 22 +++ ext/standard/streamsfuncs.c | 3 - .../tests/streams/hooks/use-case.phpt | 75 +++++------ main/network.c | 19 +-- main/php_network.h | 55 +++++++- main/php_streams.h | 9 +- main/streams/streams.c | 125 ++++++++++-------- main/streams/xp_socket.c | 16 +-- 14 files changed, 231 insertions(+), 158 deletions(-) create mode 100644 ext/standard/io_poll.h diff --git a/ext/openssl/xp_ssl.c b/ext/openssl/xp_ssl.c index eea758da4713..cd6113e2919e 100644 --- a/ext/openssl/xp_ssl.c +++ b/ext/openssl/xp_ssl.c @@ -1873,7 +1873,7 @@ static int php_openssl_enable_crypto(php_stream *stream, if (has_timeout) { left_time = php_openssl_subtract_timeval(*timeout, elapsed_time); } - php_pollfd_for(sslsock->s.socket, (err == SSL_ERROR_WANT_READ) ? + php_pollstream_for(stream, sslsock->s.socket, (err == SSL_ERROR_WANT_READ) ? (POLLIN|POLLPRI) : POLLOUT, has_timeout ? &left_time : NULL); } } else { @@ -2048,10 +2048,10 @@ static ssize_t php_openssl_sockop_io(int read, php_stream *stream, char *buf, si */ if (retry) { if (read) { - php_pollfd_for(sslsock->s.socket, (err == SSL_ERROR_WANT_WRITE) ? + php_pollstream_for(stream, sslsock->s.socket, (err == SSL_ERROR_WANT_WRITE) ? (POLLOUT|POLLPRI) : (POLLIN|POLLPRI), has_timeout ? &left_time : NULL); } else { - php_pollfd_for(sslsock->s.socket, (err == SSL_ERROR_WANT_READ) ? + php_pollstream_for(stream, sslsock->s.socket, (err == SSL_ERROR_WANT_READ) ? (POLLIN|POLLPRI) : (POLLOUT|POLLPRI), has_timeout ? &left_time : NULL); } } @@ -2067,10 +2067,10 @@ static ssize_t php_openssl_sockop_io(int read, php_stream *stream, char *buf, si /* Otherwise, we need to wait again (up to time_left or we get an error) */ if (began_blocked) { if (read) { - php_pollfd_for(sslsock->s.socket, (err == SSL_ERROR_WANT_WRITE) ? + php_pollstream_for(stream, sslsock->s.socket, (err == SSL_ERROR_WANT_WRITE) ? (POLLOUT|POLLPRI) : (POLLIN|POLLPRI), has_timeout ? &left_time : NULL); } else { - php_pollfd_for(sslsock->s.socket, (err == SSL_ERROR_WANT_READ) ? + php_pollstream_for(stream, sslsock->s.socket, (err == SSL_ERROR_WANT_READ) ? (POLLIN|POLLPRI) : (POLLOUT|POLLPRI), has_timeout ? &left_time : NULL); } } @@ -2136,7 +2136,6 @@ static int php_openssl_sockop_close(php_stream *stream, int close_handle) /* {{{ { php_openssl_netstream_data_t *sslsock = (php_openssl_netstream_data_t*)stream->abstract; #ifdef PHP_WIN32 - int n; #endif unsigned i; @@ -2164,6 +2163,7 @@ static int php_openssl_sockop_close(php_stream *stream, int close_handle) /* {{{ #endif if (sslsock->s.socket != SOCK_ERR) { #ifdef PHP_WIN32 + php_pollstream_result res; /* prevent more data from coming in */ shutdown(sslsock->s.socket, SHUT_RD); @@ -2174,8 +2174,8 @@ static int php_openssl_sockop_close(php_stream *stream, int close_handle) /* {{{ * but at the same time avoid hanging indefinitely. * */ do { - n = php_pollfd_for_ms(sslsock->s.socket, POLLOUT, 500); - } while (n == -1 && php_socket_errno() == EINTR); + res = php_pollstream_for_ms(stream, sslsock->s.socket, POLLOUT, 500); + } while (res == PHP_POLLSTREAM_ERROR && php_socket_errno() == EINTR); #endif closesocket(sslsock->s.socket); sslsock->s.socket = SOCK_ERR; @@ -2240,7 +2240,8 @@ static inline int php_openssl_tcp_sockop_accept(php_stream *stream, php_openssl_ } } - php_socket_t clisock = php_network_accept_incoming_ex(sock->s.socket, + php_socket_t clisock = php_network_accept_incoming_ex(stream, + sock->s.socket, xparam->want_textaddr ? &xparam->outputs.textaddr : NULL, xparam->want_addr ? &xparam->outputs.addr : NULL, xparam->want_addr ? &xparam->outputs.addrlen : NULL, @@ -2388,7 +2389,7 @@ static int php_openssl_sockop_set_option(php_stream *stream, int option, int val !(stream->flags & PHP_STREAM_FLAG_NO_IO) && ((MSG_DONTWAIT != 0) || !sslsock->s.is_blocked) ) || - php_pollfd_for(sslsock->s.socket, PHP_POLLREADABLE|POLLPRI, &tv) > 0 + php_pollstream_for(stream, sslsock->s.socket, PHP_POLLREADABLE|POLLPRI, &tv) == PHP_POLLSTREAM_READY ) { /* the poll() call was skipped if the socket is non-blocking (or MSG_DONTWAIT is available) and if the timeout is zero */ /* additionally, we don't use this optimization if SSL is active because in that case, we're not using MSG_DONTWAIT */ @@ -2466,7 +2467,7 @@ static int php_openssl_sockop_set_option(php_stream *stream, int option, int val if (retry) { /* Now, how much time until we time out? */ left_time = php_openssl_subtract_timeval(*timeout, elapsed_time); - if (php_pollfd_for(sslsock->s.socket, PHP_POLLREADABLE|POLLPRI|POLLOUT, has_timeout ? &left_time : NULL) <= 0) { + if (php_pollstream_for(stream, sslsock->s.socket, PHP_POLLREADABLE|POLLPRI|POLLOUT, has_timeout ? &left_time : NULL) < PHP_POLLSTREAM_READY) { retry = 0; alive = 0; }; diff --git a/ext/standard/basic_functions.c b/ext/standard/basic_functions.c index 8691d5bb5cd8..ac1c904396c5 100644 --- a/ext/standard/basic_functions.c +++ b/ext/standard/basic_functions.c @@ -138,7 +138,7 @@ static void user_shutdown_function_dtor(zval *zv); static void user_tick_function_dtor(user_tick_function_entry *tick_function_entry); // TODO: move elsewhere -PHPAPI zend_class_entry *php_stream_operation_ce; +PHPAPI zend_class_entry *php_stream_hook_result_ce; static const zend_module_dep standard_deps[] = { /* {{{ */ ZEND_MOD_REQUIRED("random") @@ -341,7 +341,7 @@ PHP_MINIT_FUNCTION(basic) /* {{{ */ BASIC_MINIT_SUBMODULE(user_streams) - php_stream_operation_ce = register_class_StreamOperation(); + php_stream_hook_result_ce = register_class_StreamHookResult(); php_register_url_stream_wrapper("php", &php_stream_php_wrapper); php_register_url_stream_wrapper("file", &php_plain_files_wrapper); diff --git a/ext/standard/basic_functions.stub.php b/ext/standard/basic_functions.stub.php index 9d377a3c329f..4d33df94626a 100644 --- a/ext/standard/basic_functions.stub.php +++ b/ext/standard/basic_functions.stub.php @@ -3578,9 +3578,10 @@ function sapi_windows_vt100_support($stream, ?bool $enable = null): bool {} /** @param resource $stream */ function stream_set_chunk_size($stream, int $size): int {} -enum StreamOperation { - case Read; - case Write; +enum StreamHookResult { + case Error; + case Timeout; + case Ready; }; function stream_set_hook(?callable $hook): ?callable {} diff --git a/ext/standard/basic_functions_arginfo.h b/ext/standard/basic_functions_arginfo.h index 402413ee629a..949441f07fb2 100644 --- a/ext/standard/basic_functions_arginfo.h +++ b/ext/standard/basic_functions_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit basic_functions.stub.php instead. - * Stub hash: 7875f65f7ae0c4b2491295c6613afe628db17168 + * Stub hash: 5b3a836c82a9be012550252226392be28e805dce * Has decl header: yes */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_set_time_limit, 0, 1, _IS_BOOL, 0) @@ -4052,13 +4052,15 @@ static zend_class_entry *register_class_RoundingMode(void) return class_entry; } -static zend_class_entry *register_class_StreamOperation(void) +static zend_class_entry *register_class_StreamHookResult(void) { - zend_class_entry *class_entry = zend_register_internal_enum("StreamOperation", IS_UNDEF, NULL); + zend_class_entry *class_entry = zend_register_internal_enum("StreamHookResult", IS_UNDEF, NULL); - zend_enum_add_case_cstr(class_entry, "Read", NULL); + zend_enum_add_case_cstr(class_entry, "Error", NULL); - zend_enum_add_case_cstr(class_entry, "Write", NULL); + zend_enum_add_case_cstr(class_entry, "Timeout", NULL); + + zend_enum_add_case_cstr(class_entry, "Ready", NULL); return class_entry; } diff --git a/ext/standard/basic_functions_decl.h b/ext/standard/basic_functions_decl.h index 7382d0b8bcb7..0f0768ec8ef1 100644 --- a/ext/standard/basic_functions_decl.h +++ b/ext/standard/basic_functions_decl.h @@ -1,8 +1,8 @@ /* This is a generated file, edit basic_functions.stub.php instead. - * Stub hash: 7875f65f7ae0c4b2491295c6613afe628db17168 */ + * Stub hash: 5b3a836c82a9be012550252226392be28e805dce */ -#ifndef ZEND_BASIC_FUNCTIONS_DECL_7875f65f7ae0c4b2491295c6613afe628db17168_H -#define ZEND_BASIC_FUNCTIONS_DECL_7875f65f7ae0c4b2491295c6613afe628db17168_H +#ifndef ZEND_BASIC_FUNCTIONS_DECL_5b3a836c82a9be012550252226392be28e805dce_H +#define ZEND_BASIC_FUNCTIONS_DECL_5b3a836c82a9be012550252226392be28e805dce_H typedef enum zend_enum_SortDirection { ZEND_ENUM_SortDirection_Ascending = 1, @@ -20,9 +20,10 @@ typedef enum zend_enum_RoundingMode { ZEND_ENUM_RoundingMode_PositiveInfinity = 8, } zend_enum_RoundingMode; -typedef enum zend_enum_StreamOperation { - ZEND_ENUM_StreamOperation_Read = 1, - ZEND_ENUM_StreamOperation_Write = 2, -} zend_enum_StreamOperation; +typedef enum zend_enum_StreamHookResult { + ZEND_ENUM_StreamHookResult_Error = 1, + ZEND_ENUM_StreamHookResult_Timeout = 2, + ZEND_ENUM_StreamHookResult_Ready = 3, +} zend_enum_StreamHookResult; -#endif /* ZEND_BASIC_FUNCTIONS_DECL_7875f65f7ae0c4b2491295c6613afe628db17168_H */ +#endif /* ZEND_BASIC_FUNCTIONS_DECL_5b3a836c82a9be012550252226392be28e805dce_H */ diff --git a/ext/standard/io_poll.c b/ext/standard/io_poll.c index 7ac079d77f2f..be5b4acf1168 100644 --- a/ext/standard/io_poll.c +++ b/ext/standard/io_poll.c @@ -23,7 +23,7 @@ /* Class entries */ static zend_class_entry *php_io_poll_backend_class_entry; -static zend_class_entry *php_io_poll_event_class_entry; +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; diff --git a/ext/standard/io_poll.h b/ext/standard/io_poll.h new file mode 100644 index 000000000000..81d770de9db2 --- /dev/null +++ b/ext/standard/io_poll.h @@ -0,0 +1,22 @@ +/* + +----------------------------------------------------------------------+ + | 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. | + +----------------------------------------------------------------------+ +*/ + +#ifndef PHP_IO_POLL_H +#define PHP_IO_POLL_H + +#include "Zend/zend_types.h" + +extern zend_class_entry *php_io_poll_event_class_entry; + +#endif /* PHP_IO_POLL_H */ diff --git a/ext/standard/streamsfuncs.c b/ext/standard/streamsfuncs.c index 148dbe2a5f8c..32e1b83b337c 100644 --- a/ext/standard/streamsfuncs.c +++ b/ext/standard/streamsfuncs.c @@ -293,9 +293,6 @@ PHP_FUNCTION(stream_socket_accept) tv_pointer = &tv; } - // ???: Timeout handling? - php_stream_call_hook(stream, ZEND_ENUM_StreamOperation_Read); - if (0 == php_stream_xport_accept(stream, &clistream, zpeername ? &peername : NULL, NULL, NULL, diff --git a/ext/standard/tests/streams/hooks/use-case.phpt b/ext/standard/tests/streams/hooks/use-case.phpt index 85bd6b1e4cb1..79ab26fd9a3d 100644 --- a/ext/standard/tests/streams/hooks/use-case.phpt +++ b/ext/standard/tests/streams/hooks/use-case.phpt @@ -3,20 +3,24 @@ Stream hook: use case --FILE-- pollContext = new Context(); + } + public function run($main) { $this->ready[] = new Fiber($main); - while ($this->ready !== [] || $this->readFds !== [] || $this->writeFds !== []) { + while ($this->ready !== [] || $this->fds !== []) { $this->runReadyFibers(); $this->pollFds(); } @@ -32,38 +36,34 @@ class Scheduler public function pollFds() { - if ($this->readFds !== [] || $this->writeFds !== []) { - $read = $this->readFds; - $write = $this->writeFds; - $except = []; - stream_select($read, $write, $except, null); - foreach ($read as $fd) { - $id = (int)$fd; - array_push($this->ready, ...$this->readFibers[$id]); - unset($this->readFds[$id]); - unset($this->readFibers[$id]); - } - foreach ($write as $fd) { - $id = (int)$fd; - array_push($this->ready, ...$this->writeFibers[$id]); - unset($this->writeFds[$id]); - unset($this->writeFibers[$id]); + if ($this->fds !== []) { + + $watchers = $this->pollContext->wait(); + + foreach ($watchers as $watcher) { + $id = (int)$watcher->getHandle()->getStream(); + unset($this->fds[$id]); + + $this->ready[] = $watcher->getData(); + + $watcher->remove(); } } } - public function waitRead($fd) { + public function pollFd($fd, array $events) + { $id = (int)$fd; - $this->readFds[$id] = $fd; - $this->readFibers[$id][] = Fiber::getCurrent(); - Fiber::suspend(); - } + if (isset($this->fds[$id])) { + throw new Exception(); + } + + $this->fds[$id] = $id; + $this->pollContext->add(new StreamPollHandle($fd), $events, Fiber::getCurrent()); - public function waitWrite($fd) { - $id = (int)$fd; - $this->writeFds[$id] = $fd; - $this->writeFibers[$id][] = Fiber::getCurrent(); Fiber::suspend(); + + return StreamHookResult::Ready; } public function go(callable $fn) { @@ -75,16 +75,7 @@ class Scheduler $scheduler = new Scheduler(); -stream_set_hook(function ($fd, StreamOperation $operation) use ($scheduler) { - switch ($operation) { - case StreamOperation::Read: - $scheduler->waitRead($fd); - break; - case StreamOperation::Write: - $scheduler->waitWrite($fd); - break; - } -}); +stream_set_hook($scheduler->pollFd(...)); function go($fn) { global $scheduler; @@ -119,6 +110,7 @@ $scheduler->run(function () { fwrite($client, "HTTP/1.0 200 OK\r\n"); fwrite($client, "\r\n"); fwrite($client, "Hello world!\n"); + fclose($client); }); }); @@ -130,7 +122,6 @@ $scheduler->run(function () { while (!feof($fd)) { echo trim("< " . fgets($fd)) . "\n"; } - fclose($fd); }); }); diff --git a/main/network.c b/main/network.c index f652cf555ffb..6a2ae183c994 100644 --- a/main/network.c +++ b/main/network.c @@ -807,7 +807,8 @@ PHPAPI int php_network_get_sock_name(php_socket_t sock, * version of the address will be emalloc'd and returned. * */ -PHPAPI php_socket_t php_network_accept_incoming_ex(php_socket_t srvsock, +PHPAPI php_socket_t php_network_accept_incoming_ex(php_stream *stream, + php_socket_t srvsock, zend_string **textaddr, struct sockaddr **addr, socklen_t *addrlen, @@ -818,16 +819,17 @@ PHPAPI php_socket_t php_network_accept_incoming_ex(php_socket_t srvsock, ) { php_socket_t clisock = -1; - int error = 0, n; + int error = 0; + php_pollstream_result result; php_sockaddr_storage sa; socklen_t sl; - n = php_pollfd_for(srvsock, PHP_POLLREADABLE, timeout); + result = php_pollstream_for(stream, srvsock, PHP_POLLREADABLE, timeout); - if (n == 0) { + if (result == PHP_POLLSTREAM_TIMEOUT) { error = PHP_TIMEOUT_ERROR_VALUE; - } else if (n == -1) { - error = php_socket_errno(); + } else if (result == PHP_POLLSTREAM_ERROR) { + error = php_socket_errno(); // TODO } else { sl = sizeof(sa); @@ -866,7 +868,8 @@ PHPAPI php_socket_t php_network_accept_incoming_ex(php_socket_t srvsock, return clisock; } -PHPAPI php_socket_t php_network_accept_incoming(php_socket_t srvsock, +PHPAPI php_socket_t php_network_accept_incoming(php_stream *stream, + php_socket_t srvsock, zend_string **textaddr, struct sockaddr **addr, socklen_t *addrlen, @@ -878,7 +881,7 @@ PHPAPI php_socket_t php_network_accept_incoming(php_socket_t srvsock, { php_sockvals sockvals = { .mask = tcp_nodelay ? PHP_SOCKVAL_TCP_NODELAY : 0 }; - return php_network_accept_incoming_ex(srvsock, textaddr, addr, addrlen, timeout, error_string, + return php_network_accept_incoming_ex(stream, srvsock, textaddr, addr, addrlen, timeout, error_string, error_code, &sockvals); } diff --git a/main/php_network.h b/main/php_network.h index e6d3009a6c82..bf6fbd63d220 100644 --- a/main/php_network.h +++ b/main/php_network.h @@ -16,6 +16,7 @@ #define _PHP_NETWORK_H #include +#include "zend_enum.h" #ifndef PHP_WIN32 # undef closesocket @@ -212,6 +213,54 @@ static inline int php_pollfd_for_ms(php_socket_t fd, int events, int timeout) return n; } +// TODO: C23_ENUM() +typedef enum php_pollstream_result { + PHP_POLLSTREAM_ERROR = -1, + PHP_POLLSTREAM_TIMEOUT = 0, + PHP_POLLSTREAM_READY = 1, +} php_pollstream_result; + +static inline php_pollstream_result php_pollstream_for(php_stream *stream, php_socket_t fd, int events, struct timeval *timeouttv) +{ + zend_object *result = php_stream_call_hook(stream, events); + + if (result == NULL) { + int n = php_pollfd_for(fd, events, timeouttv); + if (n > 0) { + return PHP_POLLSTREAM_READY; + } + if (n == 0) { + return PHP_POLLSTREAM_TIMEOUT; + } + return PHP_POLLSTREAM_ERROR; + } + + const char *name = Z_STRVAL_P(zend_enum_fetch_case_name(result)); + OBJ_RELEASE(result); + + if (!strcmp(name, "Error")) { +#ifdef PHP_WIN32 + WSASetLastError(0); +#else + errno = 0; +#endif + return PHP_POLLSTREAM_ERROR; + } else if (!strcmp(name, "Timeout")) { + return PHP_POLLSTREAM_TIMEOUT; + } else { + return PHP_POLLSTREAM_READY; + } +} + +static inline php_pollstream_result php_pollstream_for_ms(php_stream *stream, php_socket_t fd, int events, int timeout) +{ + struct timeval timeouttv = { + .tv_usec = timeout, + }; + + return php_pollstream_for(stream, fd, events, &timeouttv); +} + /* emit warning and suggestion for unsafe select(2) usage */ PHPAPI void _php_emit_fd_setsize_warning(int max_fd); @@ -315,7 +364,8 @@ PHPAPI php_socket_t php_network_bind_socket_to_local_addr(const char *host, unsi int socktype, long sockopts, zend_string **error_string, int *error_code ); -PHPAPI php_socket_t php_network_accept_incoming_ex(php_socket_t srvsock, +PHPAPI php_socket_t php_network_accept_incoming_ex(php_stream *stream, + php_socket_t srvsock, zend_string **textaddr, struct sockaddr **addr, socklen_t *addrlen, @@ -325,7 +375,8 @@ PHPAPI php_socket_t php_network_accept_incoming_ex(php_socket_t srvsock, php_sockvals *sockvals ); -PHPAPI php_socket_t php_network_accept_incoming(php_socket_t srvsock, +PHPAPI php_socket_t php_network_accept_incoming(php_stream *stream, + php_socket_t srvsock, zend_string **textaddr, struct sockaddr **addr, socklen_t *addrlen, diff --git a/main/php_streams.h b/main/php_streams.h index 1c9807e5733c..7cb998b8d373 100644 --- a/main/php_streams.h +++ b/main/php_streams.h @@ -668,15 +668,10 @@ PHPAPI HashTable *_php_get_stream_filters_hash(void); PHPAPI HashTable *php_get_stream_filters_hash_global(void); extern const php_stream_wrapper_ops *php_stream_user_wrapper_ops; -PHPAPI extern zend_class_entry *php_stream_operation_ce; - -C23_ENUM(php_stream_hook_result, uint8_t) { - PHP_STREAM_HOOK_NO_HOOK, /* No hook was invoked */ - PHP_STREAM_HOOK_INVOKED, /* Hook was invoked */ -}; +PHPAPI extern zend_class_entry *php_stream_hook_result_ce; /* Call stream hook if any */ -PHPAPI php_stream_hook_result php_stream_call_hook(php_stream *stream, zend_enum_StreamOperation); +PHPAPI zend_object *php_stream_call_hook(php_stream *stream, int events); static inline bool php_is_stream_path(const char *filename) { diff --git a/main/streams/streams.c b/main/streams/streams.c index e87a6cf3f74f..7d77f5a339d1 100644 --- a/main/streams/streams.c +++ b/main/streams/streams.c @@ -26,12 +26,19 @@ #include "ext/standard/file.h" #include "ext/standard/basic_functions.h" /* for BG(CurrentStatFile) */ #include "ext/standard/php_string.h" /* for php_memnstr, used by php_stream_get_record() */ +#include "ext/standard/io_poll.h" #include "ext/uri/php_uri.h" #include #include #include "php_streams_int.h" #include "zend_enum.h" +#ifdef HAVE_POLL_H +#include +#elif HAVE_SYS_POLL_H +#include +#endif + /* {{{ resource and registration code */ /* Global wrapper hash, copied to FG(stream_wrappers) on registration of volatile wrapper */ static HashTable url_stream_wrappers_hash; @@ -563,18 +570,6 @@ PHPAPI zend_result _php_stream_fill_read_buffer(php_stream *stream, size_t size) php_stream_filter_status_t status = PSFS_ERR_FATAL; php_stream_filter *filter; - switch (php_stream_call_hook(stream, ZEND_ENUM_StreamOperation_Read)) { - case PHP_STREAM_HOOK_INVOKED: - // ???: Should polling mechanisms report streams as - // ready when read buffer is non-empty? - if (stream->eof || (stream->writepos - stream->readpos >= (zend_off_t)to_read_now)) { - goto done; - } - break; - case PHP_STREAM_HOOK_NO_HOOK: - break; - } - /* read a chunk into a bucket */ justread = stream->ops->read(stream, chunk_buf, stream->chunk_size); if (justread < 0 && stream->writepos == stream->readpos) { @@ -678,7 +673,6 @@ PHPAPI zend_result _php_stream_fill_read_buffer(php_stream *stream, size_t size) } } -done: efree(chunk_buf); return SUCCESS; } else { @@ -703,16 +697,6 @@ PHPAPI zend_result _php_stream_fill_read_buffer(php_stream *stream, size_t size) stream->is_persistent); } - switch (php_stream_call_hook(stream, ZEND_ENUM_StreamOperation_Read)) { - case PHP_STREAM_HOOK_INVOKED: - if (UNEXPECTED(stream->writepos - stream->readpos >= (zend_off_t)size)) { - return SUCCESS; - } - break; - case PHP_STREAM_HOOK_NO_HOOK: - break; - } - justread = stream->ops->read(stream, (char*)stream->readbuf + stream->writepos, stream->readbuflen - stream->writepos ); @@ -766,17 +750,6 @@ PHPAPI ssize_t _php_stream_read(php_stream *stream, char *buf, size_t size) } if (!stream->readfilters.head && ((stream->flags & PHP_STREAM_FLAG_NO_BUFFER) || stream->chunk_size == 1)) { - - switch (php_stream_call_hook(stream, ZEND_ENUM_StreamOperation_Read)) { - case PHP_STREAM_HOOK_INVOKED: - if (UNEXPECTED(stream->writepos > stream->readpos)) { - continue; - } - break; - case PHP_STREAM_HOOK_NO_HOOK: - break; - } - toread = stream->ops->read(stream, buf, size); if (toread < 0) { /* Report an error if the read failed and we did not read any data @@ -1208,7 +1181,6 @@ static ssize_t _php_stream_write_buffer(php_stream *stream, const char *buf, siz * current stream->position. This means invalidating the read buffer and then * performing a low-level seek */ if (stream->ops->seek && (stream->flags & PHP_STREAM_FLAG_NO_SEEK) == 0 && stream->readpos != stream->writepos) { -seek: stream->readpos = stream->writepos = 0; stream->ops->seek(stream, stream->position, SEEK_SET, &stream->position); @@ -1224,16 +1196,6 @@ static ssize_t _php_stream_write_buffer(php_stream *stream, const char *buf, siz } while (count > 0) { - switch (php_stream_call_hook(stream, ZEND_ENUM_StreamOperation_Write)) { - case PHP_STREAM_HOOK_INVOKED: - if (stream->ops->seek && (stream->flags & PHP_STREAM_FLAG_NO_SEEK) == 0 && stream->readpos != stream->writepos) { - goto seek; - } - break; - case PHP_STREAM_HOOK_NO_HOOK: - break; - } - ssize_t justwrote = stream->ops->write(stream, buf, MIN(chunk_size, count)); if (justwrote <= 0) { /* If we already successfully wrote some bytes and a write error occurred @@ -2654,34 +2616,81 @@ PHPAPI int _php_stream_scandir(const char *dirname, zend_string **namelist[], in } /* }}} */ -static zend_object *php_stream_operation_get_case(zend_enum_StreamOperation operation) +// TODO: move to io_poll.c +static void php_pollfd_events_to_io_poll_events(zend_array *dest, int events) { - switch (operation) { - case ZEND_ENUM_StreamOperation_Read: - return zend_enum_get_case_cstr(php_stream_operation_ce, "Read"); - case ZEND_ENUM_StreamOperation_Write: - return zend_enum_get_case_cstr(php_stream_operation_ce, "Write"); - default: - ZEND_UNREACHABLE(); + zval zv; + + ZEND_ASSERT(!(events & ~(POLLIN|POLLPRI|POLLOUT|POLLERR|POLLHUP))); + + if (events & POLLIN) { + ZVAL_OBJ_COPY(&zv, zend_enum_get_case_cstr(php_io_poll_event_class_entry, "Read")); + zend_hash_next_index_insert(dest, &zv); + } + if (events & POLLPRI) { + /* TODO: This event is set in a few places, but there is no equivalent in Io\Poll */ + } + if (events & POLLOUT) { + ZVAL_OBJ_COPY(&zv, zend_enum_get_case_cstr(php_io_poll_event_class_entry, "Write")); + zend_hash_next_index_insert(dest, &zv); + } + if (events & POLLERR) { + ZVAL_OBJ_COPY(&zv, zend_enum_get_case_cstr(php_io_poll_event_class_entry, "Error")); + zend_hash_next_index_insert(dest, &zv); + } + if (events & POLLHUP) { + ZVAL_OBJ_COPY(&zv, zend_enum_get_case_cstr(php_io_poll_event_class_entry, "HangUp")); + zend_hash_next_index_insert(dest, &zv); } } -PHPAPI php_stream_hook_result php_stream_call_hook(php_stream *stream, zend_enum_StreamOperation operation) +// TODO: return a ZEND_ENUM_ +PHPAPI zend_object *php_stream_call_hook(php_stream *stream, int events) { if (!ZEND_FCC_INITIALIZED(FG(hook_fcc))) { - return PHP_STREAM_HOOK_NO_HOOK; + return NULL; } uint32_t orig_no_fclose = stream->flags & PHP_STREAM_FLAG_NO_FCLOSE; stream->flags |= PHP_STREAM_FLAG_NO_FCLOSE; + zend_array *events_array = zend_new_array(0); + php_pollfd_events_to_io_poll_events(events_array, events); + + // TODO: timeout zval params[2]; ZVAL_RES(¶ms[0], stream->res); - ZVAL_OBJ(¶ms[1], php_stream_operation_get_case(operation)); - zend_call_known_fcc(&FG(hook_fcc), NULL, 2, params, NULL); + ZVAL_ARR(¶ms[1], events_array); + + zval return_value; + zend_call_known_fcc(&FG(hook_fcc), &return_value, 2, params, NULL); + + zend_array_release(events_array); + + if (EG(exception)) { + goto error; + } + if (UNEXPECTED(Z_TYPE(return_value) != IS_OBJECT + || Z_OBJCE(return_value) != php_stream_hook_result_ce)) { + zend_type_error("stream hook must return an instance of %s, %s returned", + ZSTR_VAL(php_stream_hook_result_ce->name), + zend_zval_type_name(&return_value)); + goto error; + } stream->flags &= ~PHP_STREAM_FLAG_NO_FCLOSE; stream->flags |= orig_no_fclose; - return PHP_STREAM_HOOK_INVOKED; + return Z_OBJ(return_value); + +error: + zval_ptr_dtor(&return_value); + + stream->flags &= ~PHP_STREAM_FLAG_NO_FCLOSE; + stream->flags |= orig_no_fclose; + + zend_object *result = zend_enum_get_case_cstr(php_stream_hook_result_ce, "Error"); + GC_ADDREF(result); + + return result; } diff --git a/main/streams/xp_socket.c b/main/streams/xp_socket.c index 77977a557fc6..2afd42361333 100644 --- a/main/streams/xp_socket.c +++ b/main/streams/xp_socket.c @@ -91,14 +91,14 @@ static ssize_t php_sockop_write(php_stream *stream, const char *buf, size_t coun sock->timeout_event = false; do { - retval = php_pollfd_for(sock->socket, POLLOUT, ptimeout); + retval = php_pollstream_for(stream, sock->socket, POLLOUT, ptimeout); - if (retval == 0) { + if (retval == PHP_POLLSTREAM_TIMEOUT) { sock->timeout_event = true; break; } - if (retval > 0) { + if (retval == PHP_POLLSTREAM_READY) { /* writable now; retry */ goto retry; } @@ -151,12 +151,12 @@ static void php_sock_stream_wait_for_data(php_stream *stream, php_netstream_data } while(1) { - retval = php_pollfd_for(sock->socket, PHP_POLLREADABLE, ptimeout); + retval = php_pollstream_for(stream, sock->socket, PHP_POLLREADABLE, ptimeout); - if (retval == 0) + if (retval == PHP_POLLSTREAM_TIMEOUT) sock->timeout_event = true; - if (retval >= 0) + if (retval == PHP_POLLSTREAM_READY) break; if (php_socket_errno() != EINTR) @@ -373,7 +373,7 @@ static int php_sockop_set_option(php_stream *stream, int option, int value, void !(stream->flags & PHP_STREAM_FLAG_NO_IO) && ((MSG_DONTWAIT != 0) || !sock->is_blocked) ) || - php_pollfd_for(sock->socket, PHP_POLLREADABLE|POLLPRI, &tv) > 0 + php_pollstream_for(stream, sock->socket, PHP_POLLREADABLE|POLLPRI, &tv) == PHP_POLLSTREAM_READY ) { /* the poll() call was skipped if the socket is non-blocking (or MSG_DONTWAIT is available) and if the timeout is zero */ #ifdef PHP_WIN32 @@ -979,7 +979,7 @@ static inline int php_tcp_sockop_accept(php_stream *stream, php_netstream_data_t } } - php_socket_t clisock = php_network_accept_incoming_ex(sock->socket, + php_socket_t clisock = php_network_accept_incoming_ex(stream, sock->socket, xparam->want_textaddr ? &xparam->outputs.textaddr : NULL, xparam->want_addr ? &xparam->outputs.addr : NULL, xparam->want_addr ? &xparam->outputs.addrlen : NULL, From cad056107282661d7d1f2055a162f59376ceaeb4 Mon Sep 17 00:00:00 2001 From: Arnaud Le Blanc Date: Fri, 5 Jun 2026 10:16:46 +0200 Subject: [PATCH 6/9] Cleanup --- ext/standard/basic_functions.c | 15 +- ext/standard/basic_functions.h | 1 + ext/standard/basic_functions.stub.php | 8 - ext/standard/basic_functions_arginfo.h | 21 +-- ext/standard/basic_functions_decl.h | 14 +- ext/standard/config.m4 | 1 + ext/standard/file.h | 8 +- ext/standard/io_hooks.c | 164 ++++++++++++++++++ ext/standard/io_hooks.h | 30 ++++ ext/standard/io_hooks.stub.php | 27 +++ ext/standard/io_hooks_arginfo.h | 74 ++++++++ ext/standard/io_poll.c | 16 +- ext/standard/io_poll.h | 8 +- ext/standard/streamsfuncs.c | 23 --- .../tests/streams/hooks/hook-close-fgets.phpt | 26 ++- .../streams/hooks/hook-close-fwrite.phpt | 16 -- .../tests/streams/hooks/use-case.phpt | 32 ++-- main/php_network.h | 12 +- main/php_streams.h | 5 - main/streams/streams.c | 86 --------- 20 files changed, 380 insertions(+), 207 deletions(-) create mode 100644 ext/standard/io_hooks.c create mode 100644 ext/standard/io_hooks.h create mode 100644 ext/standard/io_hooks.stub.php create mode 100644 ext/standard/io_hooks_arginfo.h delete mode 100644 ext/standard/tests/streams/hooks/hook-close-fwrite.phpt diff --git a/ext/standard/basic_functions.c b/ext/standard/basic_functions.c index ac1c904396c5..e940107d0d81 100644 --- a/ext/standard/basic_functions.c +++ b/ext/standard/basic_functions.c @@ -137,9 +137,6 @@ typedef struct { static void user_shutdown_function_dtor(zval *zv); static void user_tick_function_dtor(user_tick_function_entry *tick_function_entry); -// TODO: move elsewhere -PHPAPI zend_class_entry *php_stream_hook_result_ce; - static const zend_module_dep standard_deps[] = { /* {{{ */ ZEND_MOD_REQUIRED("random") ZEND_MOD_REQUIRED("uri") @@ -340,8 +337,7 @@ PHP_MINIT_FUNCTION(basic) /* {{{ */ BASIC_MINIT_SUBMODULE(exec) BASIC_MINIT_SUBMODULE(user_streams) - - php_stream_hook_result_ce = register_class_StreamHookResult(); + BASIC_MINIT_SUBMODULE(io_hooks) php_register_url_stream_wrapper("php", &php_stream_php_wrapper); php_register_url_stream_wrapper("file", &php_plain_files_wrapper); @@ -428,7 +424,8 @@ PHP_RINIT_FUNCTION(basic) /* {{{ */ /* Default to global filters only */ FG(stream_filters) = NULL; - FG(hook_fcc) = empty_fcall_info_cache; + ZVAL_UNDEF(&FG(io_hooks)); + FG(io_hooks_poll_fcc) = empty_fcall_info_cache; return SUCCESS; } @@ -491,8 +488,10 @@ PHP_RSHUTDOWN_FUNCTION(basic) /* {{{ */ BG(page_uid) = -1; BG(page_gid) = -1; - if (ZEND_FCC_INITIALIZED(FG(hook_fcc))) { - zend_fcc_dtor(&FG(hook_fcc)); + if (!Z_ISUNDEF(FG(io_hooks))) { + zval_ptr_dtor(&FG(io_hooks)); + ZVAL_UNDEF(&FG(io_hooks)); + zend_fcc_dtor(&FG(io_hooks_poll_fcc)); } return SUCCESS; diff --git a/ext/standard/basic_functions.h b/ext/standard/basic_functions.h index 4d5a4c02aec0..83b8e4d62ca3 100644 --- a/ext/standard/basic_functions.h +++ b/ext/standard/basic_functions.h @@ -43,6 +43,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(io_hooks); PHP_MINIT_FUNCTION(user_filters); PHP_RSHUTDOWN_FUNCTION(user_filters); PHP_RSHUTDOWN_FUNCTION(browscap); diff --git a/ext/standard/basic_functions.stub.php b/ext/standard/basic_functions.stub.php index 4d33df94626a..1999c9b92be1 100644 --- a/ext/standard/basic_functions.stub.php +++ b/ext/standard/basic_functions.stub.php @@ -3578,14 +3578,6 @@ function sapi_windows_vt100_support($stream, ?bool $enable = null): bool {} /** @param resource $stream */ function stream_set_chunk_size($stream, int $size): int {} -enum StreamHookResult { - case Error; - case Timeout; - case Ready; -}; - -function stream_set_hook(?callable $hook): ?callable {} - #if (defined(HAVE_SYS_TIME_H) || defined(PHP_WIN32)) /** @param resource $stream */ function stream_set_timeout($stream, int $seconds, int $microseconds = 0): bool {} diff --git a/ext/standard/basic_functions_arginfo.h b/ext/standard/basic_functions_arginfo.h index 949441f07fb2..e51a837ffa4d 100644 --- a/ext/standard/basic_functions_arginfo.h +++ b/ext/standard/basic_functions_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit basic_functions.stub.php instead. - * Stub hash: 5b3a836c82a9be012550252226392be28e805dce + * Stub hash: 36b71aa7bbfe478a5e4af400b2822a77067efa2f * Has decl header: yes */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_set_time_limit, 0, 1, _IS_BOOL, 0) @@ -2002,10 +2002,6 @@ ZEND_END_ARG_INFO() #define arginfo_stream_set_chunk_size arginfo_stream_set_write_buffer -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_stream_set_hook, 0, 1, IS_CALLABLE, 1) - ZEND_ARG_TYPE_INFO(0, hook, IS_CALLABLE, 1) -ZEND_END_ARG_INFO() - #if (defined(HAVE_SYS_TIME_H) || defined(PHP_WIN32)) ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_stream_set_timeout, 0, 2, _IS_BOOL, 0) ZEND_ARG_INFO(0, stream) @@ -2840,7 +2836,6 @@ ZEND_FUNCTION(stream_isatty); ZEND_FUNCTION(sapi_windows_vt100_support); #endif ZEND_FUNCTION(stream_set_chunk_size); -ZEND_FUNCTION(stream_set_hook); #if (defined(HAVE_SYS_TIME_H) || defined(PHP_WIN32)) ZEND_FUNCTION(stream_set_timeout); #endif @@ -3457,7 +3452,6 @@ static const zend_function_entry ext_functions[] = { ZEND_FE(sapi_windows_vt100_support, arginfo_sapi_windows_vt100_support) #endif ZEND_FE(stream_set_chunk_size, arginfo_stream_set_chunk_size) - ZEND_FE(stream_set_hook, arginfo_stream_set_hook) #if (defined(HAVE_SYS_TIME_H) || defined(PHP_WIN32)) ZEND_FE(stream_set_timeout, arginfo_stream_set_timeout) ZEND_RAW_FENTRY("socket_set_timeout", zif_stream_set_timeout, arginfo_socket_set_timeout, ZEND_ACC_DEPRECATED, NULL, NULL) @@ -4051,16 +4045,3 @@ static zend_class_entry *register_class_RoundingMode(void) return class_entry; } - -static zend_class_entry *register_class_StreamHookResult(void) -{ - zend_class_entry *class_entry = zend_register_internal_enum("StreamHookResult", IS_UNDEF, NULL); - - zend_enum_add_case_cstr(class_entry, "Error", NULL); - - zend_enum_add_case_cstr(class_entry, "Timeout", NULL); - - zend_enum_add_case_cstr(class_entry, "Ready", NULL); - - return class_entry; -} diff --git a/ext/standard/basic_functions_decl.h b/ext/standard/basic_functions_decl.h index 0f0768ec8ef1..b3eb25c5d988 100644 --- a/ext/standard/basic_functions_decl.h +++ b/ext/standard/basic_functions_decl.h @@ -1,8 +1,8 @@ /* This is a generated file, edit basic_functions.stub.php instead. - * Stub hash: 5b3a836c82a9be012550252226392be28e805dce */ + * Stub hash: 36b71aa7bbfe478a5e4af400b2822a77067efa2f */ -#ifndef ZEND_BASIC_FUNCTIONS_DECL_5b3a836c82a9be012550252226392be28e805dce_H -#define ZEND_BASIC_FUNCTIONS_DECL_5b3a836c82a9be012550252226392be28e805dce_H +#ifndef ZEND_BASIC_FUNCTIONS_DECL_36b71aa7bbfe478a5e4af400b2822a77067efa2f_H +#define ZEND_BASIC_FUNCTIONS_DECL_36b71aa7bbfe478a5e4af400b2822a77067efa2f_H typedef enum zend_enum_SortDirection { ZEND_ENUM_SortDirection_Ascending = 1, @@ -20,10 +20,4 @@ typedef enum zend_enum_RoundingMode { ZEND_ENUM_RoundingMode_PositiveInfinity = 8, } zend_enum_RoundingMode; -typedef enum zend_enum_StreamHookResult { - ZEND_ENUM_StreamHookResult_Error = 1, - ZEND_ENUM_StreamHookResult_Timeout = 2, - ZEND_ENUM_StreamHookResult_Ready = 3, -} zend_enum_StreamHookResult; - -#endif /* ZEND_BASIC_FUNCTIONS_DECL_5b3a836c82a9be012550252226392be28e805dce_H */ +#endif /* ZEND_BASIC_FUNCTIONS_DECL_36b71aa7bbfe478a5e4af400b2822a77067efa2f_H */ diff --git a/ext/standard/config.m4 b/ext/standard/config.m4 index 67c36b93ba34..2b6cb062d5cb 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_hooks.c io_poll.c iptc.c levenshtein.c diff --git a/ext/standard/file.h b/ext/standard/file.h index 874400eeff68..0407395130c0 100644 --- a/ext/standard/file.h +++ b/ext/standard/file.h @@ -15,7 +15,9 @@ #ifndef FILE_H #define FILE_H -#include "php_network.h" +#ifdef HAVE_GETHOSTBYNAME_R +# include +#endif PHP_MINIT_FUNCTION(file); PHP_MSHUTDOWN_FUNCTION(file); @@ -100,7 +102,8 @@ typedef struct { HashTable *stream_filters; /* per-request copy of stream_filters_hash */ HashTable *wrapper_errors; /* key: wrapper address; value: linked list of char* */ int pclose_wait; - zend_fcall_info_cache hook_fcc; + zval io_hooks; + zend_fcall_info_cache io_hooks_poll_fcc; #ifdef HAVE_GETHOSTBYNAME_R struct hostent tmp_host_info; char *tmp_host_buf; @@ -116,5 +119,6 @@ extern PHPAPI int file_globals_id; extern PHPAPI php_file_globals file_globals; #endif +#include "php_network.h" #endif /* FILE_H */ diff --git a/ext/standard/io_hooks.c b/ext/standard/io_hooks.c new file mode 100644 index 000000000000..c7dba492ede7 --- /dev/null +++ b/ext/standard/io_hooks.c @@ -0,0 +1,164 @@ +/* + +----------------------------------------------------------------------+ + | 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. | + +----------------------------------------------------------------------+ +*/ + +#include "php.h" +#include "zend_enum.h" +#include "ext/standard/file.h" +#include "ext/standard/io_poll.h" +#include "ext/standard/io_hooks.h" +#include "io_hooks_arginfo.h" + +#ifdef HAVE_POLL_H +#include +#elif HAVE_SYS_POLL_H +#include +#endif + +static zend_class_entry *php_io_hooks_poll_info_ce; +PHPAPI zend_class_entry *php_io_hooks_poll_result_ce; +static zend_class_entry *php_io_hooks_hooks_ce; + +static void php_pollfd_events_to_io_poll_events(zend_array *dest, int events) +{ + zval zv; + + if (events & POLLIN) { + ZVAL_OBJ_COPY(&zv, zend_enum_get_case_cstr(php_io_poll_event_class_entry, "Read")); + zend_hash_next_index_insert(dest, &zv); + } + if (events & POLLOUT) { + ZVAL_OBJ_COPY(&zv, zend_enum_get_case_cstr(php_io_poll_event_class_entry, "Write")); + zend_hash_next_index_insert(dest, &zv); + } + if (events & POLLERR) { + ZVAL_OBJ_COPY(&zv, zend_enum_get_case_cstr(php_io_poll_event_class_entry, "Error")); + zend_hash_next_index_insert(dest, &zv); + } + if (events & POLLHUP) { + ZVAL_OBJ_COPY(&zv, zend_enum_get_case_cstr(php_io_poll_event_class_entry, "HangUp")); + zend_hash_next_index_insert(dest, &zv); + } +} + +// TODO: return a ZEND_ENUM_* +PHPAPI zend_object *php_io_hooks_poll_stream(php_stream *stream, int events, const struct timeval *timeout) +{ + uint32_t orig_no_fclose = stream->flags & PHP_STREAM_FLAG_NO_FCLOSE; + stream->flags |= PHP_STREAM_FLAG_NO_FCLOSE; + + // TODO: optimize poll_info object creation+init. Maybe reuse it too (e.g. if RC=1) + zval poll_info; + object_init_ex(&poll_info, php_io_hooks_poll_info_ce); + + zval handle; + php_stream_poll_handle_from_stream(&handle, stream); + zend_update_property(php_io_hooks_poll_info_ce, Z_OBJ(poll_info), + "handle", sizeof("handle") - 1, &handle); + zval_ptr_dtor(&handle); + + zval events_arr; + array_init(&events_arr); + php_pollfd_events_to_io_poll_events(Z_ARRVAL(events_arr), events); + zend_update_property(php_io_hooks_poll_info_ce, Z_OBJ(poll_info), + "events", sizeof("events") - 1, &events_arr); + zval_ptr_dtor(&events_arr); + + zend_long timeout_ms; + if (timeout == NULL) { + timeout_ms = -1; + } else { + timeout_ms = (zend_long)timeout->tv_sec * 1000 + (zend_long)timeout->tv_usec / 1000; + } + zend_update_property_long(php_io_hooks_poll_info_ce, Z_OBJ(poll_info), + "timeout_ms", sizeof("timeout_ms") - 1, timeout_ms); + + zval retval; + ZVAL_UNDEF(&retval); + zend_call_known_fcc(&FG(io_hooks_poll_fcc), &retval, 1, &poll_info, NULL); + zval_ptr_dtor(&poll_info); + + if (EG(exception)) { + goto return_error; + } + + if (UNEXPECTED(Z_TYPE(retval) != IS_OBJECT || Z_OBJCE(retval) != php_io_hooks_poll_result_ce)) { + zend_type_error("%s::poll() must return %s, %s returned", + ZSTR_VAL(php_io_hooks_hooks_ce->name), + ZSTR_VAL(php_io_hooks_poll_result_ce->name), + zend_zval_type_name(&retval)); + goto return_error; + } + + stream->flags &= ~PHP_STREAM_FLAG_NO_FCLOSE; + stream->flags |= orig_no_fclose; + + return Z_OBJ(retval); + +return_error: + zval_ptr_dtor(&retval); + + stream->flags &= ~PHP_STREAM_FLAG_NO_FCLOSE; + stream->flags |= orig_no_fclose; + + zend_object *err = zend_enum_get_case_cstr(php_io_hooks_poll_result_ce, "Error"); + GC_ADDREF(err); + + return err; +} + +PHP_FUNCTION(Io_Hooks_set_hooks) +{ + zval *hooks_obj = NULL; + + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_OBJECT_OF_CLASS_OR_NULL(hooks_obj, php_io_hooks_hooks_ce) + ZEND_PARSE_PARAMETERS_END(); + + if (!Z_ISUNDEF(FG(io_hooks))) { + ZVAL_COPY(return_value, &FG(io_hooks)); + zval_ptr_dtor(&FG(io_hooks)); + zend_fcc_dtor(&FG(io_hooks_poll_fcc)); + } + + if (hooks_obj == NULL || Z_TYPE_P(hooks_obj) == IS_NULL) { + ZVAL_UNDEF(&FG(io_hooks)); + return; + } + + ZVAL_COPY(&FG(io_hooks), hooks_obj); + + zend_string *method_name = zend_string_init("poll", sizeof("poll") - 1, false); + zend_object *obj = Z_OBJ_P(hooks_obj); + zend_function *fn = obj->handlers->get_method(&obj, method_name, NULL); + zend_string_release(method_name); + ZEND_ASSERT(fn != NULL); + + FG(io_hooks_poll_fcc) = (zend_fcall_info_cache){ + .function_handler = fn, + .object = obj, + .called_scope = obj->ce, + }; + GC_ADDREF(obj); +} + +PHP_MINIT_FUNCTION(io_hooks) +{ + php_io_hooks_poll_info_ce = register_class_Io_Hooks_PollInfo(); + php_io_hooks_poll_result_ce = register_class_Io_Hooks_PollResult(); + php_io_hooks_hooks_ce = register_class_Io_Hooks_Hooks(); + + zend_register_functions(NULL, ext_functions, NULL, type); + + return SUCCESS; +} diff --git a/ext/standard/io_hooks.h b/ext/standard/io_hooks.h new file mode 100644 index 000000000000..55483870ac21 --- /dev/null +++ b/ext/standard/io_hooks.h @@ -0,0 +1,30 @@ +/* + +----------------------------------------------------------------------+ + | 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. | + +----------------------------------------------------------------------+ +*/ + +#ifndef PHP_IO_HOOKS_H +#define PHP_IO_HOOKS_H + +#include "main/php.h" +#include "Zend/zend_types.h" +#include "ext/standard/file.h" + +PHPAPI extern zend_class_entry *php_io_hooks_poll_result_ce; + +#define PHP_HAS_IO_POLL_HOOK() ZEND_FCC_INITIALIZED(FG(io_hooks_poll_fcc)) + +PHPAPI zend_object *php_io_hooks_poll_stream(php_stream *stream, int events, const struct timeval *timeout); + +PHP_MINIT_FUNCTION(io_hooks); + +#endif /* PHP_IO_HOOKS_H */ diff --git a/ext/standard/io_hooks.stub.php b/ext/standard/io_hooks.stub.php new file mode 100644 index 000000000000..e7e97df03e80 --- /dev/null +++ b/ext/standard/io_hooks.stub.php @@ -0,0 +1,27 @@ +stream = stream; + intern->handle_data = data; + + GC_ADDREF(stream->res); +} + /* Handle interface internal only */ static int php_stream_poll_handle_implement_interface(zend_class_entry *interface, zend_class_entry *implementor) { diff --git a/ext/standard/io_poll.h b/ext/standard/io_poll.h index 81d770de9db2..47b2a9fa4093 100644 --- a/ext/standard/io_poll.h +++ b/ext/standard/io_poll.h @@ -16,7 +16,13 @@ #define PHP_IO_POLL_H #include "Zend/zend_types.h" +#include "main/php.h" -extern zend_class_entry *php_io_poll_event_class_entry; +PHPAPI extern zend_class_entry *php_io_poll_event_class_entry; +PHPAPI extern zend_class_entry *php_stream_poll_handle_class_entry; + +PHPAPI zend_result php_io_poll_events_to_event_enums(uint32_t events, zval *event_enums); + +PHPAPI void php_stream_poll_handle_from_stream(zval *dest, php_stream *stream); #endif /* PHP_IO_POLL_H */ diff --git a/ext/standard/streamsfuncs.c b/ext/standard/streamsfuncs.c index 32e1b83b337c..0b708578442c 100644 --- a/ext/standard/streamsfuncs.c +++ b/ext/standard/streamsfuncs.c @@ -1704,26 +1704,3 @@ PHP_FUNCTION(stream_socket_shutdown) /* }}} */ #endif -PHP_FUNCTION(stream_set_hook) -{ - zend_fcall_info fci; - zend_fcall_info_cache fcc; - - ZEND_PARSE_PARAMETERS_START(1, 1) - Z_PARAM_FUNC_OR_NULL(fci, fcc) - ZEND_PARSE_PARAMETERS_END(); - - if (ZEND_FCC_INITIALIZED(FG(hook_fcc))) { - zend_get_callable_zval_from_fcc(&FG(hook_fcc), return_value); - zend_fcc_dtor(&FG(hook_fcc)); - } - - if (!ZEND_FCI_INITIALIZED(fci)) { - return; - } - - if (!ZEND_FCC_INITIALIZED(fcc)) { - zend_is_callable_ex(&fci.function_name, NULL, IS_CALLABLE_SUPPRESS_DEPRECATIONS, NULL, &fcc, NULL); - } - zend_fcc_dup(&FG(hook_fcc), &fcc); -} diff --git a/ext/standard/tests/streams/hooks/hook-close-fgets.phpt b/ext/standard/tests/streams/hooks/hook-close-fgets.phpt index e86600bc7d83..612b2c7c13f1 100644 --- a/ext/standard/tests/streams/hooks/hook-close-fgets.phpt +++ b/ext/standard/tests/streams/hooks/hook-close-fgets.phpt @@ -3,13 +3,27 @@ Stream hook: closed during read hook --FILE-- handle->getStream()); + Io\Hooks\set_hooks(null); + return PollResult::Ready; + } +} + +Io\Hooks\set_hooks(new CloseOnceHooks()); +var_dump(fgets($client)); ?> --EXPECTF-- Warning: fclose(): cannot close the provided stream, as it must not be manually closed in %s on line %d diff --git a/ext/standard/tests/streams/hooks/hook-close-fwrite.phpt b/ext/standard/tests/streams/hooks/hook-close-fwrite.phpt deleted file mode 100644 index c4a6f8213473..000000000000 --- a/ext/standard/tests/streams/hooks/hook-close-fwrite.phpt +++ /dev/null @@ -1,16 +0,0 @@ ---TEST-- -Stream hook: closed during write hook ---FILE-- - ---EXPECTF-- -Warning: fclose(): cannot close the provided stream, as it must not be manually closed in %s on line %d -int(1) diff --git a/ext/standard/tests/streams/hooks/use-case.phpt b/ext/standard/tests/streams/hooks/use-case.phpt index 79ab26fd9a3d..854c2952e049 100644 --- a/ext/standard/tests/streams/hooks/use-case.phpt +++ b/ext/standard/tests/streams/hooks/use-case.phpt @@ -3,20 +3,21 @@ Stream hook: use case --FILE-- pollContext = new Context(); } - public function run($main) + public function run($main): void { $this->ready[] = new Fiber($main); @@ -26,7 +27,7 @@ class Scheduler } } - public function runReadyFibers() + public function runReadyFibers(): void { while ($this->ready !== []) { $fiber = array_shift($this->ready); @@ -34,10 +35,9 @@ class Scheduler } } - public function pollFds() + public function pollFds(): void { if ($this->fds !== []) { - $watchers = $this->pollContext->wait(); foreach ($watchers as $watcher) { @@ -51,22 +51,24 @@ class Scheduler } } - public function pollFd($fd, array $events) + public function poll(PollInfo $info): PollResult { - $id = (int)$fd; + $stream = $info->handle->getStream(); + $id = (int)$stream; if (isset($this->fds[$id])) { throw new Exception(); } $this->fds[$id] = $id; - $this->pollContext->add(new StreamPollHandle($fd), $events, Fiber::getCurrent()); + $this->pollContext->add($info->handle, $info->events, Fiber::getCurrent()); Fiber::suspend(); - return StreamHookResult::Ready; + return PollResult::Ready; } - public function go(callable $fn) { + public function go(callable $fn): void + { $this->ready[] = new Fiber($fn); $this->ready[] = Fiber::getCurrent(); Fiber::suspend(); @@ -75,7 +77,7 @@ class Scheduler $scheduler = new Scheduler(); -stream_set_hook($scheduler->pollFd(...)); +Io\Hooks\set_hooks($scheduler); function go($fn) { global $scheduler; diff --git a/main/php_network.h b/main/php_network.h index bf6fbd63d220..43fd33842779 100644 --- a/main/php_network.h +++ b/main/php_network.h @@ -17,6 +17,7 @@ #include #include "zend_enum.h" +#include "ext/standard/io_hooks.h" #ifndef PHP_WIN32 # undef closesocket @@ -222,9 +223,7 @@ typedef enum php_pollstream_result { static inline php_pollstream_result php_pollstream_for(php_stream *stream, php_socket_t fd, int events, struct timeval *timeouttv) { - zend_object *result = php_stream_call_hook(stream, events); - - if (result == NULL) { + if (!PHP_HAS_IO_POLL_HOOK()) { int n = php_pollfd_for(fd, events, timeouttv); if (n > 0) { return PHP_POLLSTREAM_READY; @@ -235,6 +234,8 @@ static inline php_pollstream_result php_pollstream_for(php_stream *stream, php_s return PHP_POLLSTREAM_ERROR; } + zend_object *result = php_io_hooks_poll_stream(stream, events, timeouttv); + const char *name = Z_STRVAL_P(zend_enum_fetch_case_name(result)); OBJ_RELEASE(result); @@ -252,10 +253,11 @@ static inline php_pollstream_result php_pollstream_for(php_stream *stream, php_s } } -static inline php_pollstream_result php_pollstream_for_ms(php_stream *stream, php_socket_t fd, int events, int timeout) +static inline php_pollstream_result php_pollstream_for_ms(php_stream *stream, php_socket_t fd, int events, int timeout_ms) { struct timeval timeouttv = { - .tv_usec = timeout, + .tv_sec = timeout_ms / 1000, + .tv_usec = (timeout_ms % 1000) * 1000, }; return php_pollstream_for(stream, fd, events, &timeouttv); diff --git a/main/php_streams.h b/main/php_streams.h index 7cb998b8d373..15be5f9f235d 100644 --- a/main/php_streams.h +++ b/main/php_streams.h @@ -22,7 +22,6 @@ #include #include "zend.h" #include "zend_stream.h" -#include "ext/standard/basic_functions_decl.h" BEGIN_EXTERN_C() PHPAPI int php_file_le_stream(void); @@ -668,10 +667,6 @@ PHPAPI HashTable *_php_get_stream_filters_hash(void); PHPAPI HashTable *php_get_stream_filters_hash_global(void); extern const php_stream_wrapper_ops *php_stream_user_wrapper_ops; -PHPAPI extern zend_class_entry *php_stream_hook_result_ce; - -/* Call stream hook if any */ -PHPAPI zend_object *php_stream_call_hook(php_stream *stream, int events); static inline bool php_is_stream_path(const char *filename) { diff --git a/main/streams/streams.c b/main/streams/streams.c index 7d77f5a339d1..074a512a879c 100644 --- a/main/streams/streams.c +++ b/main/streams/streams.c @@ -26,18 +26,10 @@ #include "ext/standard/file.h" #include "ext/standard/basic_functions.h" /* for BG(CurrentStatFile) */ #include "ext/standard/php_string.h" /* for php_memnstr, used by php_stream_get_record() */ -#include "ext/standard/io_poll.h" #include "ext/uri/php_uri.h" #include #include #include "php_streams_int.h" -#include "zend_enum.h" - -#ifdef HAVE_POLL_H -#include -#elif HAVE_SYS_POLL_H -#include -#endif /* {{{ resource and registration code */ /* Global wrapper hash, copied to FG(stream_wrappers) on registration of volatile wrapper */ @@ -2616,81 +2608,3 @@ PHPAPI int _php_stream_scandir(const char *dirname, zend_string **namelist[], in } /* }}} */ -// TODO: move to io_poll.c -static void php_pollfd_events_to_io_poll_events(zend_array *dest, int events) -{ - zval zv; - - ZEND_ASSERT(!(events & ~(POLLIN|POLLPRI|POLLOUT|POLLERR|POLLHUP))); - - if (events & POLLIN) { - ZVAL_OBJ_COPY(&zv, zend_enum_get_case_cstr(php_io_poll_event_class_entry, "Read")); - zend_hash_next_index_insert(dest, &zv); - } - if (events & POLLPRI) { - /* TODO: This event is set in a few places, but there is no equivalent in Io\Poll */ - } - if (events & POLLOUT) { - ZVAL_OBJ_COPY(&zv, zend_enum_get_case_cstr(php_io_poll_event_class_entry, "Write")); - zend_hash_next_index_insert(dest, &zv); - } - if (events & POLLERR) { - ZVAL_OBJ_COPY(&zv, zend_enum_get_case_cstr(php_io_poll_event_class_entry, "Error")); - zend_hash_next_index_insert(dest, &zv); - } - if (events & POLLHUP) { - ZVAL_OBJ_COPY(&zv, zend_enum_get_case_cstr(php_io_poll_event_class_entry, "HangUp")); - zend_hash_next_index_insert(dest, &zv); - } -} - -// TODO: return a ZEND_ENUM_ -PHPAPI zend_object *php_stream_call_hook(php_stream *stream, int events) -{ - if (!ZEND_FCC_INITIALIZED(FG(hook_fcc))) { - return NULL; - } - - uint32_t orig_no_fclose = stream->flags & PHP_STREAM_FLAG_NO_FCLOSE; - stream->flags |= PHP_STREAM_FLAG_NO_FCLOSE; - - zend_array *events_array = zend_new_array(0); - php_pollfd_events_to_io_poll_events(events_array, events); - - // TODO: timeout - zval params[2]; - ZVAL_RES(¶ms[0], stream->res); - ZVAL_ARR(¶ms[1], events_array); - - zval return_value; - zend_call_known_fcc(&FG(hook_fcc), &return_value, 2, params, NULL); - - zend_array_release(events_array); - - if (EG(exception)) { - goto error; - } - if (UNEXPECTED(Z_TYPE(return_value) != IS_OBJECT - || Z_OBJCE(return_value) != php_stream_hook_result_ce)) { - zend_type_error("stream hook must return an instance of %s, %s returned", - ZSTR_VAL(php_stream_hook_result_ce->name), - zend_zval_type_name(&return_value)); - goto error; - } - - stream->flags &= ~PHP_STREAM_FLAG_NO_FCLOSE; - stream->flags |= orig_no_fclose; - - return Z_OBJ(return_value); - -error: - zval_ptr_dtor(&return_value); - - stream->flags &= ~PHP_STREAM_FLAG_NO_FCLOSE; - stream->flags |= orig_no_fclose; - - zend_object *result = zend_enum_get_case_cstr(php_stream_hook_result_ce, "Error"); - GC_ADDREF(result); - - return result; -} From 85075bb62114627a99116911b71cfcb7c9370290 Mon Sep 17 00:00:00 2001 From: Arnaud Le Blanc Date: Mon, 8 Jun 2026 10:54:19 +0200 Subject: [PATCH 7/9] Prepare API for curl: Watch multiple handlers at once --- ext/standard/io_hooks.c | 7 +-- ext/standard/io_hooks.stub.php | 11 ++-- .../tests/streams/hooks/hook-close-fgets.phpt | 10 +++- .../tests/streams/hooks/use-case.phpt | 57 ++++++++++++------- main/php_network.h | 20 +++---- 5 files changed, 60 insertions(+), 45 deletions(-) diff --git a/ext/standard/io_hooks.c b/ext/standard/io_hooks.c index c7dba492ede7..40d937309135 100644 --- a/ext/standard/io_hooks.c +++ b/ext/standard/io_hooks.c @@ -51,7 +51,6 @@ static void php_pollfd_events_to_io_poll_events(zend_array *dest, int events) } } -// TODO: return a ZEND_ENUM_* PHPAPI zend_object *php_io_hooks_poll_stream(php_stream *stream, int events, const struct timeval *timeout) { uint32_t orig_no_fclose = stream->flags & PHP_STREAM_FLAG_NO_FCLOSE; @@ -106,15 +105,13 @@ PHPAPI zend_object *php_io_hooks_poll_stream(php_stream *stream, int events, con return Z_OBJ(retval); return_error: + ZEND_ASSERT(EG(exception)); zval_ptr_dtor(&retval); stream->flags &= ~PHP_STREAM_FLAG_NO_FCLOSE; stream->flags |= orig_no_fclose; - zend_object *err = zend_enum_get_case_cstr(php_io_hooks_poll_result_ce, "Error"); - GC_ADDREF(err); - - return err; + return NULL; } PHP_FUNCTION(Io_Hooks_set_hooks) diff --git a/ext/standard/io_hooks.stub.php b/ext/standard/io_hooks.stub.php index e7e97df03e80..94bd2d072249 100644 --- a/ext/standard/io_hooks.stub.php +++ b/ext/standard/io_hooks.stub.php @@ -13,14 +13,15 @@ final class PollInfo { public int $timeout_ms; } - enum PollResult { - case Error; - case Timeout; - case Ready; + final class PollResult { + public Handle $handle; + /* @var Io\Poll\Event[] */ + public array $events; + public bool $timeout; } interface Hooks { - public function poll(PollInfo $info): PollResult; + public function poll(PollInfo ...$info): PollResult; } function set_hooks(?Hooks $hooks): ?Hooks {} diff --git a/ext/standard/tests/streams/hooks/hook-close-fgets.phpt b/ext/standard/tests/streams/hooks/hook-close-fgets.phpt index 612b2c7c13f1..d4d74d61d254 100644 --- a/ext/standard/tests/streams/hooks/hook-close-fgets.phpt +++ b/ext/standard/tests/streams/hooks/hook-close-fgets.phpt @@ -15,10 +15,14 @@ fclose($conn); fclose($server); class CloseOnceHooks implements Hooks { - public function poll(PollInfo $info): PollResult { - fclose($info->handle->getStream()); + public function poll(PollInfo ...$infos): PollResult { + fclose($infos[0]->handle->getStream()); Io\Hooks\set_hooks(null); - return PollResult::Ready; + $result = new PollResult(); + $result->handle = $infos[0]->handle; + $result->events = $infos[0]->events; + $result->timeout = false; + return $result; } } diff --git a/ext/standard/tests/streams/hooks/use-case.phpt b/ext/standard/tests/streams/hooks/use-case.phpt index 854c2952e049..3d1ea63e8775 100644 --- a/ext/standard/tests/streams/hooks/use-case.phpt +++ b/ext/standard/tests/streams/hooks/use-case.phpt @@ -11,6 +11,7 @@ class Scheduler implements Hooks private Context $pollContext; private array $fds = []; private array $ready = []; + private array $fiberWatchers = []; // spl_object_id(fiber) => Watcher[] public function __construct() { @@ -19,7 +20,7 @@ class Scheduler implements Hooks public function run($main): void { - $this->ready[] = new Fiber($main); + $this->ready[] = [new Fiber($main), null]; while ($this->ready !== [] || $this->fds !== []) { $this->runReadyFibers(); @@ -30,8 +31,8 @@ class Scheduler implements Hooks public function runReadyFibers(): void { while ($this->ready !== []) { - $fiber = array_shift($this->ready); - $fiber->isSuspended() ? $fiber->resume() : $fiber->start(); + [$fiber, $resumeValue] = array_shift($this->ready); + $fiber->isSuspended() ? $fiber->resume($resumeValue) : $fiber->start(); } } @@ -41,36 +42,54 @@ class Scheduler implements Hooks $watchers = $this->pollContext->wait(); foreach ($watchers as $watcher) { - $id = (int)$watcher->getHandle()->getStream(); - unset($this->fds[$id]); + [$fiber] = $watcher->getData(); + $fiberId = spl_object_id($fiber); - $this->ready[] = $watcher->getData(); + if (!isset($this->fiberWatchers[$fiberId])) { + continue; // another watcher from the same poll() already handled this fiber + } + + // Remove all watchers registered for this fiber (including the one that fired) + foreach ($this->fiberWatchers[$fiberId] as $w) { + $id = (int)$w->getHandle()->getStream(); + unset($this->fds[$id]); + $w->remove(); + } + unset($this->fiberWatchers[$fiberId]); - $watcher->remove(); + $this->ready[] = [$fiber, [$watcher->getHandle(), $watcher->getTriggeredEvents()]]; } } } - public function poll(PollInfo $info): PollResult + public function poll(PollInfo ...$infos): PollResult { - $stream = $info->handle->getStream(); - $id = (int)$stream; - if (isset($this->fds[$id])) { - throw new Exception(); + $fiber = Fiber::getCurrent(); + $fiberId = spl_object_id($fiber); + $this->fiberWatchers[$fiberId] = []; + + foreach ($infos as $info) { + $id = (int)$info->handle->getStream(); + if (isset($this->fds[$id])) { + throw new Exception(); + } + $this->fds[$id] = $id; + $this->fiberWatchers[$fiberId][] = $this->pollContext->add($info->handle, $info->events, [$fiber]); } - $this->fds[$id] = $id; - $this->pollContext->add($info->handle, $info->events, Fiber::getCurrent()); - - Fiber::suspend(); + [$readyHandle, $readyEvents] = Fiber::suspend(); - return PollResult::Ready; + $result = new PollResult(); + $result->handle = $readyHandle; + $result->events = $readyEvents; + $result->timeout = false; + return $result; } public function go(callable $fn): void { - $this->ready[] = new Fiber($fn); - $this->ready[] = Fiber::getCurrent(); + $this->ready[] = [new Fiber($fn), null]; + $this->ready[] = [Fiber::getCurrent(), null]; Fiber::suspend(); } } diff --git a/main/php_network.h b/main/php_network.h index 43fd33842779..ce5c47330096 100644 --- a/main/php_network.h +++ b/main/php_network.h @@ -235,22 +235,16 @@ static inline php_pollstream_result php_pollstream_for(php_stream *stream, php_s } zend_object *result = php_io_hooks_poll_stream(stream, events, timeouttv); + if (result == NULL) { + return PHP_POLLSTREAM_ERROR; + } - const char *name = Z_STRVAL_P(zend_enum_fetch_case_name(result)); + zval rv; + zval *timeout_prop = zend_read_property(php_io_hooks_poll_result_ce, result, "timeout", sizeof("timeout") - 1, /* silent */ 1, &rv); + bool timed_out = zend_is_true(timeout_prop); OBJ_RELEASE(result); - if (!strcmp(name, "Error")) { -#ifdef PHP_WIN32 - WSASetLastError(0); -#else - errno = 0; -#endif - return PHP_POLLSTREAM_ERROR; - } else if (!strcmp(name, "Timeout")) { - return PHP_POLLSTREAM_TIMEOUT; - } else { - return PHP_POLLSTREAM_READY; - } + return timed_out ? PHP_POLLSTREAM_TIMEOUT : PHP_POLLSTREAM_READY; } static inline php_pollstream_result php_pollstream_for_ms(php_stream *stream, php_socket_t fd, int events, int timeout_ms) From a662242d269e48201f2bc852c897fbd7679f5378 Mon Sep 17 00:00:00 2001 From: Arnaud Le Blanc Date: Mon, 8 Jun 2026 11:34:58 +0200 Subject: [PATCH 8/9] Timeout support and simplify poll() again --- ext/standard/io_hooks.stub.php | 4 +- .../tests/streams/hooks/hook-close-fgets.phpt | 9 +- .../tests/streams/hooks/use-case.phpt | 116 +++++++++++++----- 3 files changed, 92 insertions(+), 37 deletions(-) diff --git a/ext/standard/io_hooks.stub.php b/ext/standard/io_hooks.stub.php index 94bd2d072249..baddbc18bc41 100644 --- a/ext/standard/io_hooks.stub.php +++ b/ext/standard/io_hooks.stub.php @@ -21,7 +21,9 @@ final class PollResult { } interface Hooks { - public function poll(PollInfo ...$info): PollResult; + public function poll(PollInfo $info): PollResult; + /* @return PollResult[] Empty when $timeout_ms is exceeded */ + public function poll_multi(?int $timeout_ms, PollInfo ...$info): array; } function set_hooks(?Hooks $hooks): ?Hooks {} diff --git a/ext/standard/tests/streams/hooks/hook-close-fgets.phpt b/ext/standard/tests/streams/hooks/hook-close-fgets.phpt index d4d74d61d254..913cf2be8430 100644 --- a/ext/standard/tests/streams/hooks/hook-close-fgets.phpt +++ b/ext/standard/tests/streams/hooks/hook-close-fgets.phpt @@ -15,15 +15,16 @@ fclose($conn); fclose($server); class CloseOnceHooks implements Hooks { - public function poll(PollInfo ...$infos): PollResult { - fclose($infos[0]->handle->getStream()); + public function poll(PollInfo $info): PollResult { + fclose($info->handle->getStream()); Io\Hooks\set_hooks(null); $result = new PollResult(); - $result->handle = $infos[0]->handle; - $result->events = $infos[0]->events; + $result->handle = $info->handle; + $result->events = $info->events; $result->timeout = false; return $result; } + public function poll_multi(?int $timeout_ms, PollInfo ...$info): array { throw new \Exception("poll_multi not implemented"); } } Io\Hooks\set_hooks(new CloseOnceHooks()); diff --git a/ext/standard/tests/streams/hooks/use-case.phpt b/ext/standard/tests/streams/hooks/use-case.phpt index 3d1ea63e8775..42a69c6e697e 100644 --- a/ext/standard/tests/streams/hooks/use-case.phpt +++ b/ext/standard/tests/streams/hooks/use-case.phpt @@ -11,7 +11,8 @@ class Scheduler implements Hooks private Context $pollContext; private array $fds = []; private array $ready = []; - private array $fiberWatchers = []; // spl_object_id(fiber) => Watcher[] + private array $fiberWatchers = []; // fiberId => Watcher[] + private array $fiberDeadlines = []; // fiberId => [deadline_ns, fiber, PollInfo] public function __construct() { @@ -22,7 +23,7 @@ class Scheduler implements Hooks { $this->ready[] = [new Fiber($main), null]; - while ($this->ready !== [] || $this->fds !== []) { + while ($this->ready !== [] || $this->fds !== [] || $this->fiberDeadlines !== []) { $this->runReadyFibers(); $this->pollFds(); } @@ -38,54 +39,105 @@ class Scheduler implements Hooks public function pollFds(): void { - if ($this->fds !== []) { - $watchers = $this->pollContext->wait(); + if ($this->fds === [] && $this->fiberDeadlines === []) { + return; + } - foreach ($watchers as $watcher) { - [$fiber] = $watcher->getData(); - $fiberId = spl_object_id($fiber); + // Compute wait() timeout from the nearest deadline + $timeoutSec = null; + $timeoutUsec = 0; + $now = hrtime(true); + foreach ($this->fiberDeadlines as [$deadline]) { + $remaining = $deadline - $now; + if ($remaining <= 0) { + $timeoutSec = 0; + $timeoutUsec = 0; + break; + } + $remainingUsec = intdiv($remaining, 1_000); + if ($timeoutSec === null || $remainingUsec < $timeoutSec * 1_000_000 + $timeoutUsec) { + $timeoutSec = intdiv($remainingUsec, 1_000_000); + $timeoutUsec = $remainingUsec % 1_000_000; + } + } - if (!isset($this->fiberWatchers[$fiberId])) { - continue; // another watcher from the same poll() already handled this fiber - } + $watchers = $this->pollContext->wait($timeoutSec, $timeoutUsec); - // Remove all watchers registered for this fiber (including the one that fired) - foreach ($this->fiberWatchers[$fiberId] as $w) { - $id = (int)$w->getHandle()->getStream(); - unset($this->fds[$id]); - $w->remove(); - } - unset($this->fiberWatchers[$fiberId]); + // Handle ready handles + foreach ($watchers as $watcher) { + [$fiber] = $watcher->getData(); + $fiberId = spl_object_id($fiber); + + if (!isset($this->fiberWatchers[$fiberId])) { + continue; + } + + $this->removeAllFiberWatchers($fiberId); + unset($this->fiberDeadlines[$fiberId]); + + $result = new PollResult(); + $result->handle = $watcher->getHandle(); + $result->events = $watcher->getTriggeredEvents(); + $result->timeout = false; + + $this->ready[] = [$fiber, $result]; + } - $this->ready[] = [$fiber, [$watcher->getHandle(), $watcher->getTriggeredEvents()]]; + // Handle expired deadlines + $now = hrtime(true); + foreach ($this->fiberDeadlines as $fiberId => [$deadline, $fiber, $info]) { + if ($deadline > $now) { + continue; } + + $this->removeAllFiberWatchers($fiberId); + unset($this->fiberDeadlines[$fiberId]); + + $result = new PollResult(); + $result->handle = $info->handle; + $result->events = $info->events; + $result->timeout = true; + + $this->ready[] = [$fiber, $result]; + } + } + + private function removeAllFiberWatchers(int $fiberId): void + { + foreach ($this->fiberWatchers[$fiberId] as $w) { + unset($this->fds[(int)$w->getHandle()->getStream()]); + $w->remove(); } + unset($this->fiberWatchers[$fiberId]); } - public function poll(PollInfo ...$infos): PollResult + public function poll(PollInfo $info): PollResult { $fiber = Fiber::getCurrent(); $fiberId = spl_object_id($fiber); - $this->fiberWatchers[$fiberId] = []; - foreach ($infos as $info) { - $id = (int)$info->handle->getStream(); - if (isset($this->fds[$id])) { - throw new Exception(); - } - $this->fds[$id] = $id; - $this->fiberWatchers[$fiberId][] = $this->pollContext->add($info->handle, $info->events, [$fiber]); + if ($info->timeout_ms >= 0) { + $deadline = hrtime(true) + $info->timeout_ms * 1_000_000; + $this->fiberDeadlines[$fiberId] = [$deadline, $fiber, $info]; } - [$readyHandle, $readyEvents] = Fiber::suspend(); + $id = (int)$info->handle->getStream(); + if (isset($this->fds[$id])) { + throw new \Exception("Handle already registered"); + } + $this->fds[$id] = $id; + $this->fiberWatchers[$fiberId] = [$this->pollContext->add($info->handle, $info->events, [$fiber])]; - $result = new PollResult(); - $result->handle = $readyHandle; - $result->events = $readyEvents; - $result->timeout = false; + /** @var PollResult $result */ + $result = Fiber::suspend(); return $result; } + public function poll_multi(?int $timeout_ms, PollInfo ...$infos): array + { + throw new \Exception("poll_multi not implemented"); + } + public function go(callable $fn): void { $this->ready[] = [new Fiber($fn), null]; From b6eabf61fd0d68eb2c79bf3785ea5b1a96bb9746 Mon Sep 17 00:00:00 2001 From: Arnaud Le Blanc Date: Mon, 8 Jun 2026 13:11:01 +0200 Subject: [PATCH 9/9] curl_exec() support --- ext/curl/curl_private.h | 3 + ext/curl/curl_socket_handle.stub.php | 14 + ext/curl/curl_socket_handle_arginfo.h | 13 + ext/curl/interface.c | 313 +++++++++++++++++- ext/curl/tests/curl_exec_io_hook.phpt | 51 +++ ext/standard/basic_functions.c | 2 + ext/standard/file.h | 1 + ext/standard/io_hooks.c | 25 +- ext/standard/io_hooks.h | 1 + ext/standard/io_hooks_arginfo.h | 32 +- ext/standard/io_poll.c | 2 +- ext/standard/io_poll.h | 1 + .../tests/streams/hooks/scheduler.inc | 182 ++++++++++ .../tests/streams/hooks/use-case.phpt | 147 +------- 14 files changed, 629 insertions(+), 158 deletions(-) create mode 100644 ext/curl/curl_socket_handle.stub.php create mode 100644 ext/curl/curl_socket_handle_arginfo.h create mode 100644 ext/curl/tests/curl_exec_io_hook.phpt create mode 100644 ext/standard/tests/streams/hooks/scheduler.inc diff --git a/ext/curl/curl_private.h b/ext/curl/curl_private.h index 7058e9df9241..13942c384799 100644 --- a/ext/curl/curl_private.h +++ b/ext/curl/curl_private.h @@ -114,6 +114,9 @@ typedef struct { zval private_data; /* CurlShareHandle object set using CURLOPT_SHARE. */ struct _php_curlsh *share; + /* Socket/timer state during curl_exec (NULL outside exec) */ + HashTable *io_sockets; /* curl_socket_t -> int events */ + long io_timer_ms; /* timer value from TIMERFUNCTION, -1 = disabled */ zend_object std; } php_curl; diff --git a/ext/curl/curl_socket_handle.stub.php b/ext/curl/curl_socket_handle.stub.php new file mode 100644 index 000000000000..68469010cfd2 --- /dev/null +++ b/ext/curl/curl_socket_handle.stub.php @@ -0,0 +1,14 @@ +create_object = php_curl_socket_handle_create_object; + memcpy(&php_curl_socket_handle_object_handlers, &std_object_handlers, + sizeof(zend_object_handlers)); + php_curl_socket_handle_object_handlers.offset = XtOffsetOf(php_poll_handle_object, std); + php_curl_socket_handle_object_handlers.free_obj = php_poll_handle_object_free; + php_curl_socket_handle_ce->default_object_handlers = &php_curl_socket_handle_object_handlers; + return SUCCESS; } /* }}} */ @@ -1058,6 +1080,8 @@ void init_curl_handle(php_curl *ch) zend_hash_init(&ch->to_free->slist, 4, NULL, curl_free_slist, 0); ZVAL_UNDEF(&ch->postfields); + ch->io_sockets = NULL; + ch->io_timer_ms = -1; } /* }}} */ @@ -2309,6 +2333,291 @@ PHP_FUNCTION(curl_setopt_array) } /* }}} */ +/* Io\Curl\SocketHandle: wraps a curl_socket_t as an Io\Poll\Handle */ + +typedef struct { + curl_socket_t socket; +} php_curl_socket_handle_data; + +static php_socket_t php_curl_socket_handle_get_fd(php_poll_handle_object *handle) +{ + return (php_socket_t)((php_curl_socket_handle_data *)handle->handle_data)->socket; +} + +static int php_curl_socket_handle_is_valid(php_poll_handle_object *handle) +{ + return handle->handle_data != NULL && + ((php_curl_socket_handle_data *)handle->handle_data)->socket != CURL_SOCKET_BAD; +} + +static void php_curl_socket_handle_cleanup(php_poll_handle_object *handle) +{ + efree(handle->handle_data); + handle->handle_data = NULL; +} + +static php_poll_handle_ops php_curl_socket_handle_ops = { + .get_fd = php_curl_socket_handle_get_fd, + .is_valid = php_curl_socket_handle_is_valid, + .cleanup = php_curl_socket_handle_cleanup, +}; + +static zend_object *php_curl_socket_handle_create_object(zend_class_entry *ce) +{ + php_poll_handle_object *intern = php_poll_handle_object_create( + sizeof(php_poll_handle_object), ce, &php_curl_socket_handle_ops); + intern->std.handlers = &php_curl_socket_handle_object_handlers; + return &intern->std; +} + +static void php_curl_socket_handle_from_fd(zval *dest, curl_socket_t s) +{ + object_init_ex(dest, php_curl_socket_handle_ce); + php_poll_handle_object *h = PHP_POLL_HANDLE_OBJ_FROM_ZV(dest); + php_curl_socket_handle_data *data = emalloc(sizeof(php_curl_socket_handle_data)); + data->socket = s; + h->handle_data = data; +} + +/* CURLMOPT_SOCKETFUNCTION: log socket+events in ch->io_sockets */ +static int php_curl_socket_callback(CURL *easy, curl_socket_t s, int what, void *userp, void *socketp) +{ + php_curl *ch = (php_curl *)userp; + + if (what == CURL_POLL_REMOVE) { + if (ch->io_sockets) { + zend_hash_index_del(ch->io_sockets, (zend_ulong)s); + } + } else { + if (!ch->io_sockets) { + ch->io_sockets = emalloc(sizeof(HashTable)); + zend_hash_init(ch->io_sockets, 4, NULL, NULL, 0); + } + zval zv; + ZVAL_LONG(&zv, what); + zend_hash_index_update(ch->io_sockets, (zend_ulong)s, &zv); + } + return 0; +} + +/* CURLMOPT_TIMERFUNCTION: record the requested timeout */ +static int php_curl_timer_callback(CURLM *multi, long timeout_ms, void *userp) +{ + php_curl *ch = (php_curl *)userp; + ch->io_timer_ms = timeout_ms; + return 0; +} + +/* Translate CURL_POLL_* to Io\Poll\Event[] array */ +static void php_curl_poll_events_to_zval(int what, zval *dest) +{ + array_init(dest); + if (what & CURL_POLL_IN) { + zval zv; + ZVAL_OBJ_COPY(&zv, zend_enum_get_case_cstr(php_io_poll_event_class_entry, "Read")); + zend_hash_next_index_insert(Z_ARRVAL_P(dest), &zv); + } + if (what & CURL_POLL_OUT) { + zval zv; + ZVAL_OBJ_COPY(&zv, zend_enum_get_case_cstr(php_io_poll_event_class_entry, "Write")); + zend_hash_next_index_insert(Z_ARRVAL_P(dest), &zv); + } +} + +/* Translate PollResult::$events back to CURL_CSELECT_* flags */ +static int php_curl_poll_result_to_curl_events(zval *events_arr) +{ + int curl_events = 0; + zval *event; + ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(events_arr), event) { + if (Z_TYPE_P(event) != IS_OBJECT) continue; + zend_string *name = Z_STR_P(zend_enum_fetch_case_name(Z_OBJ_P(event))); + if (zend_string_equals_literal(name, "Read")) curl_events |= CURL_CSELECT_IN; + if (zend_string_equals_literal(name, "Write")) curl_events |= CURL_CSELECT_OUT; + if (zend_string_equals_literal(name, "Error")) curl_events |= CURL_CSELECT_ERR; + } ZEND_HASH_FOREACH_END(); + return curl_events; +} + +/* Main multi-based exec loop */ +static CURLcode php_curl_exec_multi(php_curl *ch) +{ + CURLcode result = CURLE_OK; + CURLM *multi = curl_multi_init(); + curl_multi_setopt(multi, CURLMOPT_SOCKETFUNCTION, php_curl_socket_callback); + curl_multi_setopt(multi, CURLMOPT_SOCKETDATA, ch); + curl_multi_setopt(multi, CURLMOPT_TIMERFUNCTION, php_curl_timer_callback); + curl_multi_setopt(multi, CURLMOPT_TIMERDATA, ch); + + curl_multi_add_handle(multi, ch->cp); + + int still_running; + curl_multi_socket_action(multi, CURL_SOCKET_TIMEOUT, 0, &still_running); + + while (still_running) { + /* If timer is zero, fire immediately again without waiting */ + if (ch->io_timer_ms == 0) { + curl_multi_socket_action(multi, CURL_SOCKET_TIMEOUT, 0, &still_running); + continue; + } + + if (PHP_HAS_IO_POLL_HOOK()) { + /* Count active sockets */ + uint32_t n = ch->io_sockets ? zend_hash_num_elements(ch->io_sockets) : 0; + + /* Build argv: [timeout_ms_or_null, PollInfo...] */ + zval *params = safe_emalloc(n, sizeof(zval), sizeof(zval)); + + if (ch->io_timer_ms < 0) { + ZVAL_NULL(¶ms[0]); + } else { + ZVAL_LONG(¶ms[0], ch->io_timer_ms); + } + + uint32_t i = 1; + if (n > 0) { + zend_ulong sock_ulong; + zval *events_zv; + ZEND_HASH_FOREACH_NUM_KEY_VAL(ch->io_sockets, sock_ulong, events_zv) { + zval *poll_info = ¶ms[i++]; + object_init_ex(poll_info, php_io_hooks_poll_info_ce); + + zval handle; + php_curl_socket_handle_from_fd(&handle, (curl_socket_t)sock_ulong); + zend_update_property(php_io_hooks_poll_info_ce, Z_OBJ_P(poll_info), + "handle", sizeof("handle") - 1, &handle); + zval_ptr_dtor(&handle); + + zval evts; + php_curl_poll_events_to_zval((int)Z_LVAL_P(events_zv), &evts); + zend_update_property(php_io_hooks_poll_info_ce, Z_OBJ_P(poll_info), + "events", sizeof("events") - 1, &evts); + zval_ptr_dtor(&evts); + + /* timeout_ms per PollInfo: -1 (we use global timeout via params[0]) */ + zend_update_property_long(php_io_hooks_poll_info_ce, Z_OBJ_P(poll_info), + "timeout_ms", sizeof("timeout_ms") - 1, -1); + } ZEND_HASH_FOREACH_END(); + } + + zval retval; + ZVAL_UNDEF(&retval); + zend_call_known_fcc(&FG(io_hooks_poll_multi_fcc), &retval, 1 + n, params, NULL); + + for (uint32_t j = 0; j < 1 + n; j++) { + zval_ptr_dtor(¶ms[j]); + } + efree(params); + + if (EG(exception)) { + zval_ptr_dtor(&retval); + break; + } + + if (zend_hash_num_elements(Z_ARRVAL(retval)) > 0) { + /* Drive ready sockets */ + zval *result; + ZEND_HASH_FOREACH_VAL(Z_ARRVAL(retval), result) { + if (Z_TYPE_P(result) != IS_OBJECT || + Z_OBJCE_P(result) != php_io_hooks_poll_result_ce) continue; + + zval rv; + zval *handle_prop = zend_read_property(php_io_hooks_poll_result_ce, + Z_OBJ_P(result), "handle", sizeof("handle") - 1, 1, &rv); + if (Z_TYPE_P(handle_prop) != IS_OBJECT) continue; + + php_poll_handle_object *hobj = + PHP_POLL_HANDLE_OBJ_FROM_ZOBJ(Z_OBJ_P(handle_prop)); + php_socket_t fd = php_poll_handle_get_fd(hobj); + + zval *events_prop = zend_read_property(php_io_hooks_poll_result_ce, + Z_OBJ_P(result), "events", sizeof("events") - 1, 1, &rv); + int curl_events = (Z_TYPE_P(events_prop) == IS_ARRAY) + ? php_curl_poll_result_to_curl_events(events_prop) : 0; + + curl_multi_socket_action(multi, (curl_socket_t)fd, curl_events, &still_running); + } ZEND_HASH_FOREACH_END(); + } else { + /* Empty array = timeout */ + curl_multi_socket_action(multi, CURL_SOCKET_TIMEOUT, 0, &still_running); + } + zval_ptr_dtor(&retval); + } else { + /* No hook: select() on the sockets logged by the socket callback */ + fd_set rfds, wfds, efds; + FD_ZERO(&rfds); FD_ZERO(&wfds); FD_ZERO(&efds); + php_socket_t max_fd = 0; + bool have_fds = false; + + if (ch->io_sockets) { + zend_ulong sock_ulong; + zval *ev; + ZEND_HASH_FOREACH_NUM_KEY_VAL(ch->io_sockets, sock_ulong, ev) { + curl_socket_t s = (curl_socket_t)sock_ulong; + int what = (int)Z_LVAL_P(ev); + if (what & CURL_POLL_IN) FD_SET(s, &rfds); + if (what & CURL_POLL_OUT) FD_SET(s, &wfds); + FD_SET(s, &efds); + if ((php_socket_t)s > max_fd) max_fd = (php_socket_t)s; + have_fds = true; + } ZEND_HASH_FOREACH_END(); + } + + long wait_ms = ch->io_timer_ms >= 0 ? ch->io_timer_ms : 1000; + struct timeval tv = { + .tv_sec = wait_ms / 1000, + .tv_usec = (wait_ms % 1000) * 1000, + }; + + int n = have_fds ? select((int)max_fd + 1, &rfds, &wfds, &efds, &tv) : 0; + + if (n > 0 && ch->io_sockets) { + /* Collect ready sockets before calling socket_action (avoids hash re-entry) */ + curl_socket_t ready_s[16]; + int ready_ev[16]; + int ready_n = 0; + zend_ulong sock_ulong; + zval *ev; + ZEND_HASH_FOREACH_NUM_KEY_VAL(ch->io_sockets, sock_ulong, ev) { + curl_socket_t s = (curl_socket_t)sock_ulong; + int ce = 0; + if (FD_ISSET(s, &rfds)) ce |= CURL_CSELECT_IN; + if (FD_ISSET(s, &wfds)) ce |= CURL_CSELECT_OUT; + if (FD_ISSET(s, &efds)) ce |= CURL_CSELECT_ERR; + if (ce && ready_n < 16) { ready_s[ready_n] = s; ready_ev[ready_n++] = ce; } + } ZEND_HASH_FOREACH_END(); + for (int k = 0; k < ready_n; k++) { + curl_multi_socket_action(multi, ready_s[k], ready_ev[k], &still_running); + } + } else { + curl_multi_socket_action(multi, CURL_SOCKET_TIMEOUT, 0, &still_running); + } + } + + /* Check for completed messages */ + CURLMsg *msg; + int msgs_in_queue; + while ((msg = curl_multi_info_read(multi, &msgs_in_queue)) != NULL) { + if (msg->msg == CURLMSG_DONE && msg->easy_handle == ch->cp) { + result = msg->data.result; + still_running = 0; + } + } + } + + curl_multi_remove_handle(multi, ch->cp); + curl_multi_cleanup(multi); + + if (ch->io_sockets) { + zend_hash_destroy(ch->io_sockets); + efree(ch->io_sockets); + ch->io_sockets = NULL; + } + ch->io_timer_ms = -1; + + return result; +} + /* {{{ _php_curl_cleanup_handle(ch) Cleanup an execution phase */ void _php_curl_cleanup_handle(php_curl *ch) @@ -2341,7 +2650,7 @@ PHP_FUNCTION(curl_exec) _php_curl_cleanup_handle(ch); - error = curl_easy_perform(ch->cp); + error = php_curl_exec_multi(ch); SAVE_CURL_ERROR(ch, error); if (error != CURLE_OK) { diff --git a/ext/curl/tests/curl_exec_io_hook.phpt b/ext/curl/tests/curl_exec_io_hook.phpt new file mode 100644 index 000000000000..6bf6f4466f05 --- /dev/null +++ b/ext/curl/tests/curl_exec_io_hook.phpt @@ -0,0 +1,51 @@ +--TEST-- +curl_exec() integrates with io_hooks scheduler +--EXTENSIONS-- +curl +--FILE-- +go($fn); +} + +$scheduler->run(function () { + $server = stream_socket_server('tcp://127.0.0.1:0'); + $addr = stream_socket_get_name($server, false); + + go(function () use ($server) { + $conn = stream_socket_accept($server, 5); + $request = ''; + while (!str_ends_with($request, "\r\n\r\n")) { + $chunk = fread($conn, 1024); + if ($chunk === false || $chunk === '') break; + $request .= $chunk; + } + fwrite($conn, "HTTP/1.0 200 OK\r\nContent-Length: 12\r\n\r\nHello, curl!"); + fclose($conn); + }); + + go(function () use ($addr) { + $ch = curl_init("http://$addr/"); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + $body = curl_exec($ch); + $errno = curl_errno($ch); + if ($errno === 0) { + echo "OK: $body\n"; + } else { + echo "curl error $errno: " . curl_error($ch) . "\n"; + } + }); +}); + +?> +--EXPECT-- +OK: Hello, curl! diff --git a/ext/standard/basic_functions.c b/ext/standard/basic_functions.c index e940107d0d81..98241d368f30 100644 --- a/ext/standard/basic_functions.c +++ b/ext/standard/basic_functions.c @@ -426,6 +426,7 @@ PHP_RINIT_FUNCTION(basic) /* {{{ */ ZVAL_UNDEF(&FG(io_hooks)); FG(io_hooks_poll_fcc) = empty_fcall_info_cache; + FG(io_hooks_poll_multi_fcc) = empty_fcall_info_cache; return SUCCESS; } @@ -492,6 +493,7 @@ PHP_RSHUTDOWN_FUNCTION(basic) /* {{{ */ zval_ptr_dtor(&FG(io_hooks)); ZVAL_UNDEF(&FG(io_hooks)); zend_fcc_dtor(&FG(io_hooks_poll_fcc)); + zend_fcc_dtor(&FG(io_hooks_poll_multi_fcc)); } return SUCCESS; diff --git a/ext/standard/file.h b/ext/standard/file.h index 0407395130c0..4d7d03fc1a83 100644 --- a/ext/standard/file.h +++ b/ext/standard/file.h @@ -104,6 +104,7 @@ typedef struct { int pclose_wait; zval io_hooks; zend_fcall_info_cache io_hooks_poll_fcc; + zend_fcall_info_cache io_hooks_poll_multi_fcc; #ifdef HAVE_GETHOSTBYNAME_R struct hostent tmp_host_info; char *tmp_host_buf; diff --git a/ext/standard/io_hooks.c b/ext/standard/io_hooks.c index 40d937309135..d2942cffe25d 100644 --- a/ext/standard/io_hooks.c +++ b/ext/standard/io_hooks.c @@ -25,7 +25,7 @@ #include #endif -static zend_class_entry *php_io_hooks_poll_info_ce; +PHPAPI zend_class_entry *php_io_hooks_poll_info_ce; PHPAPI zend_class_entry *php_io_hooks_poll_result_ce; static zend_class_entry *php_io_hooks_hooks_ce; @@ -135,14 +135,27 @@ PHP_FUNCTION(Io_Hooks_set_hooks) ZVAL_COPY(&FG(io_hooks), hooks_obj); - zend_string *method_name = zend_string_init("poll", sizeof("poll") - 1, false); zend_object *obj = Z_OBJ_P(hooks_obj); - zend_function *fn = obj->handlers->get_method(&obj, method_name, NULL); - zend_string_release(method_name); - ZEND_ASSERT(fn != NULL); + + zend_string *poll_name = zend_string_init("poll", sizeof("poll") - 1, false); + zend_function *poll_fn = obj->handlers->get_method(&obj, poll_name, NULL); + zend_string_release(poll_name); + ZEND_ASSERT(poll_fn != NULL); FG(io_hooks_poll_fcc) = (zend_fcall_info_cache){ - .function_handler = fn, + .function_handler = poll_fn, + .object = obj, + .called_scope = obj->ce, + }; + GC_ADDREF(obj); + + zend_string *poll_multi_name = zend_string_init("poll_multi", sizeof("poll_multi") - 1, false); + zend_function *poll_multi_fn = obj->handlers->get_method(&obj, poll_multi_name, NULL); + zend_string_release(poll_multi_name); + ZEND_ASSERT(poll_multi_fn != NULL); + + FG(io_hooks_poll_multi_fcc) = (zend_fcall_info_cache){ + .function_handler = poll_multi_fn, .object = obj, .called_scope = obj->ce, }; diff --git a/ext/standard/io_hooks.h b/ext/standard/io_hooks.h index 55483870ac21..04f2f23396b3 100644 --- a/ext/standard/io_hooks.h +++ b/ext/standard/io_hooks.h @@ -19,6 +19,7 @@ #include "Zend/zend_types.h" #include "ext/standard/file.h" +PHPAPI extern zend_class_entry *php_io_hooks_poll_info_ce; PHPAPI extern zend_class_entry *php_io_hooks_poll_result_ce; #define PHP_HAS_IO_POLL_HOOK() ZEND_FCC_INITIALIZED(FG(io_hooks_poll_fcc)) diff --git a/ext/standard/io_hooks_arginfo.h b/ext/standard/io_hooks_arginfo.h index cc40ad9e9e8d..d0514b619a14 100644 --- a/ext/standard/io_hooks_arginfo.h +++ b/ext/standard/io_hooks_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit io_hooks.stub.php instead. - * Stub hash: 454316a49190e58ab640e337fd8292eb949a2c5e */ + * Stub hash: 86abce168edd66424958da19478649fc38f059e6 */ ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_Io_Hooks_set_hooks, 0, 1, Io\\Hooks\\Hooks, 1) ZEND_ARG_OBJ_INFO(0, hooks, Io\\Hooks\\Hooks, 1) @@ -9,6 +9,11 @@ ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Io_Hooks_Hooks_poll, 0, 1, ZEND_ARG_OBJ_INFO(0, info, Io\\Hooks\\PollInfo, 0) ZEND_END_ARG_INFO() +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Io_Hooks_Hooks_poll_multi, 0, 1, IS_ARRAY, 0) + ZEND_ARG_TYPE_INFO(0, timeout_ms, IS_LONG, 1) + ZEND_ARG_VARIADIC_OBJ_INFO(0, info, Io\\Hooks\\PollInfo, 0) +ZEND_END_ARG_INFO() + ZEND_FUNCTION(Io_Hooks_set_hooks); static const zend_function_entry ext_functions[] = { @@ -18,6 +23,7 @@ static const zend_function_entry ext_functions[] = { static const zend_function_entry class_Io_Hooks_Hooks_methods[] = { ZEND_RAW_FENTRY("poll", NULL, arginfo_class_Io_Hooks_Hooks_poll, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("poll_multi", NULL, arginfo_class_Io_Hooks_Hooks_poll_multi, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) ZEND_FE_END }; @@ -52,13 +58,29 @@ static zend_class_entry *register_class_Io_Hooks_PollInfo(void) static zend_class_entry *register_class_Io_Hooks_PollResult(void) { - zend_class_entry *class_entry = zend_register_internal_enum("Io\\Hooks\\PollResult", IS_UNDEF, NULL); + zend_class_entry ce, *class_entry; + + INIT_NS_CLASS_ENTRY(ce, "Io\\Hooks", "PollResult", NULL); + class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL); - zend_enum_add_case_cstr(class_entry, "Error", NULL); + zval property_handle_default_value; + ZVAL_UNDEF(&property_handle_default_value); + zend_string *property_handle_name = zend_string_init("handle", sizeof("handle") - 1, true); + zend_string *property_handle_class_Io_Poll_Handle = zend_string_init("Io\\Poll\\Handle", sizeof("Io\\Poll\\Handle")-1, 1); + zend_declare_typed_property(class_entry, property_handle_name, &property_handle_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_CLASS(property_handle_class_Io_Poll_Handle, 0, 0)); + zend_string_release_ex(property_handle_name, true); - zend_enum_add_case_cstr(class_entry, "Timeout", NULL); + zval property_events_default_value; + ZVAL_UNDEF(&property_events_default_value); + zend_string *property_events_name = zend_string_init("events", sizeof("events") - 1, true); + zend_declare_typed_property(class_entry, property_events_name, &property_events_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_ARRAY)); + zend_string_release_ex(property_events_name, true); - zend_enum_add_case_cstr(class_entry, "Ready", NULL); + zval property_timeout_default_value; + ZVAL_UNDEF(&property_timeout_default_value); + zend_string *property_timeout_name = zend_string_init("timeout", sizeof("timeout") - 1, true); + zend_declare_typed_property(class_entry, property_timeout_name, &property_timeout_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_BOOL)); + zend_string_release_ex(property_timeout_name, true); return class_entry; } diff --git a/ext/standard/io_poll.c b/ext/standard/io_poll.c index 1467825083b1..3b5cb2aa6c04 100644 --- a/ext/standard/io_poll.c +++ b/ext/standard/io_poll.c @@ -26,7 +26,7 @@ static zend_class_entry *php_io_poll_backend_class_entry; 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; +PHPAPI 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; diff --git a/ext/standard/io_poll.h b/ext/standard/io_poll.h index 47b2a9fa4093..1d9e19d918ec 100644 --- a/ext/standard/io_poll.h +++ b/ext/standard/io_poll.h @@ -19,6 +19,7 @@ #include "main/php.h" PHPAPI extern zend_class_entry *php_io_poll_event_class_entry; +PHPAPI extern zend_class_entry *php_io_poll_handle_class_entry; PHPAPI extern zend_class_entry *php_stream_poll_handle_class_entry; PHPAPI zend_result php_io_poll_events_to_event_enums(uint32_t events, zval *event_enums); diff --git a/ext/standard/tests/streams/hooks/scheduler.inc b/ext/standard/tests/streams/hooks/scheduler.inc new file mode 100644 index 000000000000..b4037a13022f --- /dev/null +++ b/ext/standard/tests/streams/hooks/scheduler.inc @@ -0,0 +1,182 @@ + spl_object_id(Handle) (for poll() handles only) + private array $fiberWatchers = []; // fiberId -> [[Watcher, fdKey|null], ...] + private array $fiberDeadlines = []; // fiberId -> [deadline_ns, fiber, PollInfo|null] + private array $fiberIsMulti = []; // fiberId -> true (for poll_multi fibers) + + public function __construct() + { + $this->pollContext = new Context(); + } + + public function run(callable $main): void + { + $this->ready[] = [new Fiber($main), null]; + + while ($this->ready !== [] || $this->fiberWatchers !== [] || $this->fiberDeadlines !== []) { + $this->runReadyFibers(); + $this->pollFds(); + } + } + + public function runReadyFibers(): void + { + while ($this->ready !== []) { + [$fiber, $resumeValue] = array_shift($this->ready); + $fiber->isSuspended() ? $fiber->resume($resumeValue) : $fiber->start(); + } + } + + public function pollFds(): void + { + if ($this->fiberWatchers === [] && $this->fiberDeadlines === []) { + return; + } + + // Compute wait() timeout from the nearest deadline + $timeoutSec = null; + $timeoutUsec = 0; + $now = hrtime(true); + foreach ($this->fiberDeadlines as [$deadline]) { + $remaining = $deadline - $now; + if ($remaining <= 0) { + $timeoutSec = 0; + $timeoutUsec = 0; + break; + } + $remainingUsec = intdiv($remaining, 1_000); + if ($timeoutSec === null || $remainingUsec < $timeoutSec * 1_000_000 + $timeoutUsec) { + $timeoutSec = intdiv($remainingUsec, 1_000_000); + $timeoutUsec = $remainingUsec % 1_000_000; + } + } + + $watchers = $this->pollContext->wait($timeoutSec, $timeoutUsec); + + // Group ready PollResults by fiber before resuming anyone + $fiberResults = []; + foreach ($watchers as $watcher) { + [$fiber] = $watcher->getData(); + $fiberId = spl_object_id($fiber); + if (!isset($this->fiberWatchers[$fiberId])) { + continue; + } + $result = new PollResult(); + $result->handle = $watcher->getHandle(); + $result->events = $watcher->getTriggeredEvents(); + $result->timeout = false; + $fiberResults[$fiberId] ??= [$fiber, []]; + $fiberResults[$fiberId][1][] = $result; + } + + foreach ($fiberResults as $fiberId => [$fiber, $results]) { + if (!isset($this->fiberWatchers[$fiberId])) { + continue; + } + $this->removeAllFiberWatchers($fiberId); + unset($this->fiberDeadlines[$fiberId]); + + if ($this->fiberIsMulti[$fiberId] ?? false) { + unset($this->fiberIsMulti[$fiberId]); + $this->ready[] = [$fiber, $results]; // array of PollResult + } else { + $this->ready[] = [$fiber, $results[0]]; // single PollResult + } + } + + // Handle expired deadlines + $now = hrtime(true); + foreach ($this->fiberDeadlines as $fiberId => [$deadline, $fiber, $info]) { + if ($deadline > $now) { + continue; + } + $this->removeAllFiberWatchers($fiberId); + unset($this->fiberDeadlines[$fiberId]); + + if ($this->fiberIsMulti[$fiberId] ?? false) { + unset($this->fiberIsMulti[$fiberId]); + $this->ready[] = [$fiber, []]; // empty array = timeout for poll_multi + } else { + $result = new PollResult(); + $result->handle = $info->handle; + $result->events = $info->events; + $result->timeout = true; + $this->ready[] = [$fiber, $result]; + } + } + } + + private function removeAllFiberWatchers(int $fiberId): void + { + foreach ($this->fiberWatchers[$fiberId] as [$w, $fdKey]) { + if ($fdKey !== null) { + unset($this->handles[$fdKey]); + } + $w->remove(); + } + unset($this->fiberWatchers[$fiberId]); + } + + public function poll(PollInfo $info): PollResult + { + $fiber = Fiber::getCurrent(); + $fiberId = spl_object_id($fiber); + + if ($info->timeout_ms >= 0) { + $deadline = hrtime(true) + $info->timeout_ms * 1_000_000; + $this->fiberDeadlines[$fiberId] = [$deadline, $fiber, $info]; + } + + $id = spl_object_id($info->handle); + if (isset($this->handles[$id])) { + throw new \Exception("Handle already registered"); + } + $this->handles[$id] = $id; + $this->fiberWatchers[$fiberId] = [ + [$this->pollContext->add($info->handle, $info->events, [$fiber]), $id], + ]; + + /** @var PollResult $result */ + $result = Fiber::suspend(); + return $result; + } + + public function poll_multi(?int $timeout_ms, PollInfo ...$infos): array + { + $fiber = Fiber::getCurrent(); + $fiberId = spl_object_id($fiber); + $this->fiberWatchers[$fiberId] = []; + $this->fiberIsMulti[$fiberId] = true; + + if ($timeout_ms !== null && $timeout_ms >= 0) { + $deadline = hrtime(true) + $timeout_ms * 1_000_000; + $this->fiberDeadlines[$fiberId] = [$deadline, $fiber, null]; + } + + foreach ($infos as $info) { + $this->fiberWatchers[$fiberId][] = [ + $this->pollContext->add($info->handle, $info->events, [$fiber]), + null, + ]; + } + + /** @var PollResult[] $results */ + $results = Fiber::suspend(); + return $results ?? []; + } + + public function go(callable $fn): void + { + $this->ready[] = [new Fiber($fn), null]; + $this->ready[] = [Fiber::getCurrent(), null]; + Fiber::suspend(); + } +} diff --git a/ext/standard/tests/streams/hooks/use-case.phpt b/ext/standard/tests/streams/hooks/use-case.phpt index 42a69c6e697e..fabcc1a330b6 100644 --- a/ext/standard/tests/streams/hooks/use-case.phpt +++ b/ext/standard/tests/streams/hooks/use-case.phpt @@ -3,154 +3,13 @@ Stream hook: use case --FILE-- Watcher[] - private array $fiberDeadlines = []; // fiberId => [deadline_ns, fiber, PollInfo] - - public function __construct() - { - $this->pollContext = new Context(); - } - - public function run($main): void - { - $this->ready[] = [new Fiber($main), null]; - - while ($this->ready !== [] || $this->fds !== [] || $this->fiberDeadlines !== []) { - $this->runReadyFibers(); - $this->pollFds(); - } - } - - public function runReadyFibers(): void - { - while ($this->ready !== []) { - [$fiber, $resumeValue] = array_shift($this->ready); - $fiber->isSuspended() ? $fiber->resume($resumeValue) : $fiber->start(); - } - } - - public function pollFds(): void - { - if ($this->fds === [] && $this->fiberDeadlines === []) { - return; - } - - // Compute wait() timeout from the nearest deadline - $timeoutSec = null; - $timeoutUsec = 0; - $now = hrtime(true); - foreach ($this->fiberDeadlines as [$deadline]) { - $remaining = $deadline - $now; - if ($remaining <= 0) { - $timeoutSec = 0; - $timeoutUsec = 0; - break; - } - $remainingUsec = intdiv($remaining, 1_000); - if ($timeoutSec === null || $remainingUsec < $timeoutSec * 1_000_000 + $timeoutUsec) { - $timeoutSec = intdiv($remainingUsec, 1_000_000); - $timeoutUsec = $remainingUsec % 1_000_000; - } - } - - $watchers = $this->pollContext->wait($timeoutSec, $timeoutUsec); - - // Handle ready handles - foreach ($watchers as $watcher) { - [$fiber] = $watcher->getData(); - $fiberId = spl_object_id($fiber); - - if (!isset($this->fiberWatchers[$fiberId])) { - continue; - } - - $this->removeAllFiberWatchers($fiberId); - unset($this->fiberDeadlines[$fiberId]); - - $result = new PollResult(); - $result->handle = $watcher->getHandle(); - $result->events = $watcher->getTriggeredEvents(); - $result->timeout = false; - - $this->ready[] = [$fiber, $result]; - } - - // Handle expired deadlines - $now = hrtime(true); - foreach ($this->fiberDeadlines as $fiberId => [$deadline, $fiber, $info]) { - if ($deadline > $now) { - continue; - } - - $this->removeAllFiberWatchers($fiberId); - unset($this->fiberDeadlines[$fiberId]); - - $result = new PollResult(); - $result->handle = $info->handle; - $result->events = $info->events; - $result->timeout = true; - - $this->ready[] = [$fiber, $result]; - } - } - - private function removeAllFiberWatchers(int $fiberId): void - { - foreach ($this->fiberWatchers[$fiberId] as $w) { - unset($this->fds[(int)$w->getHandle()->getStream()]); - $w->remove(); - } - unset($this->fiberWatchers[$fiberId]); - } - - public function poll(PollInfo $info): PollResult - { - $fiber = Fiber::getCurrent(); - $fiberId = spl_object_id($fiber); - - if ($info->timeout_ms >= 0) { - $deadline = hrtime(true) + $info->timeout_ms * 1_000_000; - $this->fiberDeadlines[$fiberId] = [$deadline, $fiber, $info]; - } - - $id = (int)$info->handle->getStream(); - if (isset($this->fds[$id])) { - throw new \Exception("Handle already registered"); - } - $this->fds[$id] = $id; - $this->fiberWatchers[$fiberId] = [$this->pollContext->add($info->handle, $info->events, [$fiber])]; - - /** @var PollResult $result */ - $result = Fiber::suspend(); - return $result; - } - - public function poll_multi(?int $timeout_ms, PollInfo ...$infos): array - { - throw new \Exception("poll_multi not implemented"); - } - - public function go(callable $fn): void - { - $this->ready[] = [new Fiber($fn), null]; - $this->ready[] = [Fiber::getCurrent(), null]; - Fiber::suspend(); - } -} +include __DIR__ . '/scheduler.inc'; $scheduler = new Scheduler(); - Io\Hooks\set_hooks($scheduler); -function go($fn) { +function go(callable $fn): void +{ global $scheduler; $scheduler->go($fn); }