diff --git a/.github/workflows/debug-shutdown.yml b/.github/workflows/debug-shutdown.yml new file mode 100644 index 00000000..48bb582f --- /dev/null +++ b/.github/workflows/debug-shutdown.yml @@ -0,0 +1,200 @@ +name: Debug Shutdown Tests + +on: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + services: + mysql: + image: mysql:8.3 + env: + MYSQL_ROOT_PASSWORD: '' + MYSQL_ALLOW_EMPTY_PASSWORD: yes + MYSQL_DATABASE: test + ports: ['3306:3306'] + options: >- + --health-cmd="mysqladmin ping --silent" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: test + ports: ['5432:5432'] + options: >- + --health-cmd="pg_isready" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + steps: + - name: Checkout php-async repo + uses: actions/checkout@v4 + with: + path: async + + - name: Clone php-src (true-async) + run: | + git clone --depth=1 --branch=true-async https://github.com/true-async/php-src php-src + + - name: Copy php-async extension into php-src + run: | + mkdir -p php-src/ext/async + cp -r async/* php-src/ext/async/ + + # Cache APT packages to speed up dependency installation + - name: Cache APT packages + uses: actions/cache@v4 + with: + path: /var/cache/apt/archives + key: ${{ runner.os }}-apt-packages-${{ hashFiles('.github/workflows/debug-shutdown.yml') }} + restore-keys: | + ${{ runner.os }}-apt-packages- + + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + gcc g++ autoconf bison re2c \ + libgmp-dev libicu-dev libtidy-dev libsasl2-dev \ + libzip-dev libbz2-dev libsqlite3-dev libonig-dev libcurl4-openssl-dev \ + libxml2-dev libxslt1-dev libpq-dev libreadline-dev libldap2-dev libsodium-dev \ + libargon2-dev \ + firebird-dev \ + valgrind cmake + + # Cache LibUV 1.45.0 installation to avoid rebuilding + - name: Cache LibUV 1.45.0 + id: cache-libuv + uses: actions/cache@v4 + with: + path: | + /usr/local/lib/libuv* + /usr/local/include/uv* + /usr/local/lib/pkgconfig/libuv.pc + key: ${{ runner.os }}-libuv-1.45.0-release + + - name: Install LibUV >= 1.45.0 + run: | + # Check if we have cached LibUV 1.45.0 + if [ "${{ steps.cache-libuv.outputs.cache-hit }}" == "true" ]; then + echo "Using cached LibUV 1.45.0 installation" + sudo ldconfig + echo "LibUV version: $(pkg-config --modversion libuv)" + # Check if system libuv meets requirements + elif pkg-config --exists libuv && pkg-config --atleast-version=1.45.0 libuv; then + echo "System libuv version: $(pkg-config --modversion libuv)" + sudo apt-get install -y libuv1-dev + else + echo "Installing LibUV 1.45.0 from source" + wget https://github.com/libuv/libuv/archive/v1.45.0.tar.gz + tar -xzf v1.45.0.tar.gz + cd libuv-1.45.0 + mkdir build && cd build + cmake .. -DCMAKE_BUILD_TYPE=Release + make -j$(nproc) + sudo make install + sudo ldconfig + cd ../.. + echo "LibUV 1.45.0 compiled and installed" + fi + + - name: Configure PHP + working-directory: php-src + run: | + ./buildconf -f + ./configure \ + --enable-zts \ + --enable-fpm \ + --with-pdo-mysql=mysqlnd \ + --with-mysqli=mysqlnd \ + --with-pgsql \ + --with-pdo-pgsql \ + --with-pdo-sqlite \ + --enable-intl \ + --without-pear \ + --with-zip \ + --with-zlib \ + --enable-soap \ + --enable-xmlreader \ + --with-xsl \ + --with-tidy \ + --enable-sysvsem \ + --enable-sysvshm \ + --enable-shmop \ + --enable-pcntl \ + --with-readline \ + --enable-mbstring \ + --with-curl \ + --with-gettext \ + --enable-sockets \ + --with-bz2 \ + --with-openssl \ + --with-gmp \ + --enable-bcmath \ + --enable-calendar \ + --enable-ftp \ + --enable-sysvmsg \ + --with-ffi \ + --enable-zend-test \ + --enable-dl-test=shared \ + --with-ldap \ + --with-ldap-sasl \ + --with-password-argon2 \ + --with-mhash \ + --with-sodium \ + --enable-dba \ + --with-cdb \ + --enable-flatfile \ + --enable-inifile \ + --with-config-file-path=/etc \ + --with-config-file-scan-dir=/etc/php.d \ + --with-pdo-firebird \ + --enable-address-sanitizer \ + --enable-async + + - name: Build PHP + working-directory: php-src + run: | + make -j"$(nproc)" + sudo make install + sudo mkdir -p /etc/php.d + sudo chmod 777 /etc/php.d + { + echo "opcache.enable_cli=1" + echo "opcache.protect_memory=1" + } > /etc/php.d/opcache.ini + + - name: Run failing tests + working-directory: php-src/ext/async + run: | + /usr/local/bin/php -v + /usr/local/bin/php ../../run-tests.php \ + -d opcache.enable_cli=1 \ + -d opcache.jit_buffer_size=64M \ + -d opcache.jit=tracing \ + -d zend_test.observer.enabled=1 \ + -d zend_test.observer.show_output=0 \ + -P -q -x -j4 \ + -g FAIL,BORK,LEAK,XLEAK \ + --no-progress \ + --offline \ + --show-diff \ + --show-slow 4000 \ + --set-timeout 120 \ + --repeat 2 \ + tests/bailout/009-memory-exhaustion-during-await.phpt \ + tests/bailout/010-memory-exhaustion-during-awaitAllOrFail.phpt \ + tests/bailout/011-stack-overflow-during-await.phpt \ + tests/bailout/012-memory-exhaustion-awaiting-process.phpt \ + tests/exec/017-exec_unicode_binary.phpt \ + tests/exec/018-exec_long_lines.phpt \ + tests/exec/022-exec_chdir_cwd.phpt \ + tests/include/009-require_double_include_redeclare.phpt \ + tests/include/010-require_in_coroutine_then_main_redeclare.phpt diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bc3eb1e..b991d661 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.6.0] ### Fixed +- **Async file IO position tracking**: Replaced bare `lseek`/`_lseeki64` with `zend_lseek` across reactor. Rewrote `libuv_io_seek` to accept `whence` and return position, eliminating double lseek in `php_stdiop_seek`. Fixed append-mode offset init and fseek behavior. On Windows, append writes now query real EOF via `lseek(SEEK_END)` before dispatch to avoid stale cached offsets. +- **Windows concurrent append (XFAIL)**: On Windows, `WriteFile` via libuv ignores CRT `_O_APPEND` because `FILE_WRITE_DATA` coexists with `FILE_APPEND_DATA` on the HANDLE. Removing `FILE_WRITE_DATA` would fix atomic append but breaks `ftruncate`/`SetEndOfFile`. Concurrent append from multiple coroutines remains a known limitation (test 069 marked XFAIL). +- **Reactor deadlock on pending file I/O requests**: `uv_fs_read`, `uv_fs_write`, `uv_fs_fsync`, and `uv_fs_fstat` are libuv requests (not handles) that keep `uv_loop_alive()` true but were invisible to `ZEND_ASYNC_ACTIVE_EVENT_COUNT`. The reactor loop exited prematurely (`has_handles && active_event_count > 0` → false) while file I/O callbacks were still pending, causing deadlocks in async file writes (e.g. `CURLOPT_FILE` with async I/O). Fixed by adding `ZEND_ASYNC_INCREASE_EVENT_COUNT` after successful `uv_fs_*` submission and `ZEND_ASYNC_DECREASE_EVENT_COUNT` in their completion callbacks (`io_file_read_cb`, `io_file_write_cb`, `io_file_flush_cb`, `io_file_stat_cb`). - **Generator segfault in fiber-coroutine mode**: Generators running inside fiber coroutines were not marked with `ZEND_GENERATOR_IN_FIBER` because `EG(active_fiber)` is not set in coroutine mode. This caused shutdown destructors to close generators while the coroutine was still suspended, leading to a NULL `execute_data` dereference in `zend_generator_resume`. Fixed by also checking `ZEND_ASYNC_CURRENT_COROUTINE` with `ZEND_COROUTINE_IS_FIBER` when setting the `IN_FIBER` flag on generators. ### Added @@ -43,6 +46,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`Async\iterate()` function**: Iterates over an iterable, calling the callback for each element with optional concurrency limit. Supports `cancelPending` parameter (default: `true`) that controls whether coroutines spawned inside the callback are cancelled or awaited after iteration completes. - **`Async\FileSystemWatcher` class**: Persistent filesystem watcher with `foreach` iteration support, suspend/resume on new events, two storage modes (coalesce with HashTable deduplication, raw with circular buffer), `close()`/`isClosed()` lifecycle, and `Awaitable` interface via `ZEND_ASYNC_EVENT_REF_FIELDS` pattern. Replaces the one-shot `Async\watch_filesystem()` function. - **`Async\signal()` function**: One-shot signal handler that returns a `Future` resolved when the specified signal is received. Supports optional `Cancellation` for early cancellation. +- **Acting coroutine for error context** (`zend_async_globals_t.acting_coroutine`): New field in async globals that allows scheduler-context code to attribute errors to a suspended coroutine. When set, `zend_get_executed_filename_ex()`, `zend_get_executed_lineno()`, and `get_active_function_name()` in `Zend/zend_execute_API.c` fall back to the coroutine's suspended `execute_data` for file, line, and function name. Zero-cost: the execute_data is only read when an error actually occurs. Macros: `ZEND_ASYNC_ACTING_COROUTINE`, `ZEND_ASYNC_ACT_AS_START(coroutine)`, `ZEND_ASYNC_ACT_AS_END()`. ### Changed - **Bailout handling**: Added `ZEND_ASYNC_EVENT_F_BAILOUT` flag (bit 11) on `zend_async_event_t`. During bailout (e.g. OOM), PHP-level handlers are no longer called — finally handlers on coroutines and scopes are destroyed without execution, scope exception handlers (`try_to_handle_exception`) are skipped. C-level callbacks (`ZEND_ASYNC_CALLBACKS_NOTIFY`) continue to work normally. Convenience macros: `ZEND_COROUTINE_SET_BAILOUT`/`ZEND_COROUTINE_IS_BAILOUT`, `ZEND_ASYNC_SCOPE_SET_BAILOUT`/`ZEND_ASYNC_SCOPE_IS_BAILOUT`. diff --git a/CURL-INTEGRATION.md b/CURL-INTEGRATION.md new file mode 100644 index 00000000..7e671eba --- /dev/null +++ b/CURL-INTEGRATION.md @@ -0,0 +1,153 @@ +# cURL Async Integration — Known Issues & Architecture + +This document covers important technical details about the async cURL integration, +including known libcurl bugs and the workarounds applied. + +--- + +## Overview + +The async cURL integration uses libcurl's `multi_socket` API combined with +the PAUSE/unpause pattern for non-blocking I/O: + +- **File uploads** (`CURLFile`): `curl_mime_data_cb()` with a read callback that + returns `CURL_READFUNC_PAUSE` while async file I/O is in progress. +- **File downloads** (`CURLOPT_FILE`): write callback returns `CURL_WRITEFUNC_PAUSE` + while async file write is in progress. +- **User callbacks** (`CURLOPT_WRITEFUNCTION`): write callback pauses, spawns a + high-priority coroutine to run the PHP callback, then unpauses. + +After async I/O completes, the transfer is unpaused via `curl_easy_pause(CURLPAUSE_CONT)` +and driven forward with `curl_multi_socket_action(CURL_SOCKET_TIMEOUT)`. + +--- + +## Minimum libcurl Version + +**Recommended: libcurl >= 8.11.1** for fully async file upload support. + +On older versions, file uploads (`CURLFile`) fall back to synchronous `read()` +inside the read callback. This is safe for local files but blocks the event loop +briefly during each read. Downloads and user write callbacks work correctly on +all versions. + +--- + +## The PAUSE/unpause Bug (libcurl < 8.11.1) + +### Symptom + +Intermittent timeout on file uploads (~20% failure rate): +``` +Operation timed out after 5000 milliseconds with 0 bytes received +``` + +### Root Cause + +Multiple bugs in libcurl's PAUSE/unpause mechanism prevent the transfer from +being driven after `curl_easy_pause(CURLPAUSE_CONT)`: + +1. **`timer_lastcall` / `last_expire_ts` optimization** — `Curl_update_timer()` + skips the timer callback when the new expire timestamp matches the cached one. + Fixed in [curl#15627](https://github.com/curl/curl/pull/15627) (8.11.1), + but the fix was removed during intermediate refactors (present in 8.5.0, + absent in 8.6–8.10, re-added in 8.11.1). + +2. **`tempcount` guard on `cselect_bits`** — In `curl_easy_pause()`, + `data->conn->cselect_bits` is only set when `data->state.tempcount == 0`. + If any response data arrived while the transfer was paused, `tempcount > 0` + and `cselect_bits` is never set. Without `cselect_bits`, the transfer is + not processed even when timeouts fire correctly. Fixed in 8.11.1+ where + `cselect_bits` was replaced with `data->state.select_bits` (always set, + no `tempcount` guard). + +3. **`CURLINFO_ACTIVESOCKET` unreliable during transfer** — Returns + `CURL_SOCKET_BAD` (-1) in the `multi_socket` API because `lastconnect_id` + is not set until the transfer completes. Cannot be used to drive the socket + directly. + +### Workarounds Tested (all insufficient for < 8.11.1) + +| Approach | Result | +|---------------------------------------------------------------|----------------------------------------------------------| +| `curl_multi_socket_action(CURL_SOCKET_TIMEOUT)` after unpause | ~80% pass | +| Manual `curl_timer_cb(multi, 0, NULL)` to force 0ms timer | ~94% pass | +| `CURLINFO_ACTIVESOCKET` + `CURL_CSELECT_IN\|OUT` | ~92% pass (socket sometimes BAD) | +| Track socket via `curl_socket_cb` + direct socket action | ~82–92% pass (socket removed from sockhash during pause) | +| `curl_multi_perform()` | ~74% pass (must not mix with multi_socket API) | + +### Solution Applied + +For libcurl < 8.11.1, the file upload read callback (`curl_async_read_cb`) +uses **synchronous `read()`** instead of the async PAUSE/unpause pattern. +This completely avoids the bug — no PAUSE means no broken unpause. + +```c +#if LIBCURL_VERSION_NUM < 0x080B01 + // Synchronous read — safe for local files + const ssize_t n = read(fd, buffer, requested); +#else + // Async PAUSE/unpause pattern + return CURL_READFUNC_PAUSE; +#endif +``` + +Compile-time check via `LIBCURL_VERSION_NUM`. Zero runtime overhead. +With libcurl >= 8.11.1, the async path is used and works 100% reliably. + +--- + +## Timer Callback Regression (libcurl 8.10.x) + +### Symptom + +`curl_exec()` hangs indefinitely when connecting to unreachable hosts (e.g. `192.0.2.1`), +even with `CURLOPT_TIMEOUT` and `CURLOPT_CONNECTTIMEOUT` set. The timeout never fires +in the `multi_socket` API. + +### Root Cause + +A regression introduced in libcurl 8.10.0 causes `CURLMOPT_TIMERFUNCTION` to not be +called again after `curl_multi_socket_action(CURL_SOCKET_TIMEOUT)`. The sequence: + +1. curl registers a connect socket and requests a timeout timer (e.g. 2000ms) +2. Timer fires → `curl_multi_socket_action(CURL_SOCKET_TIMEOUT)` is called +3. curl returns `running_handles=1` — does **not** complete the transfer +4. curl does **not** call `CURLMOPT_TIMERFUNCTION` to request a new timer +5. The socket never becomes writable (host unreachable) → deadlock + +In working versions (e.g. 8.5.0), step 4 correctly re-arms the timer with a short +interval (~35ms), which fires again and completes the transfer with `CURLE_OPERATION_TIMEDOUT`. + +### Affected Versions + +- **Broken**: libcurl 8.10.0 – 8.11.0 +- **Working**: libcurl ≤ 8.9.1, libcurl ≥ 8.11.1 +- Related: [curl#15154](https://github.com/curl/curl/issues/15154) — "curl_multi still breaks in 8.10.1 with libev" +- Related: [curl#15639](https://github.com/curl/curl/issues/15639) — Regression in multi-curl async calls (8.9.1 → 8.10.0/8.11.0) +- Fix: [curl#15627](https://github.com/curl/curl/pull/15627) — merged into curl 8.11.1 + +### Recommendation + +Avoid libcurl 8.10.x for any `multi_socket`-based integration. Use libcurl 8.5.0 +(as in CI) or >= 8.11.1. + +--- + +## References + +- [curl#15627](https://github.com/curl/curl/pull/15627) — Fix for `CURLMOPT_TIMERFUNCTION` not being called (merged Nov 2024, curl 8.11.1) +- [curl#15154](https://github.com/curl/curl/issues/15154) — curl_multi breaks in 8.10.1 with libev +- [curl#15639](https://github.com/curl/curl/issues/15639) — Regression in multi-curl async calls (8.9.1 → 8.10.0/8.11.0) +- [curl#5299](https://github.com/curl/curl/issues/5299) — `CURLINFO_ACTIVESOCKET` reliability issues +- libcurl `multi_socket` API: https://curl.se/libcurl/c/libcurl-multi.html +- libcurl pause/unpause: https://curl.se/libcurl/c/curl_easy_pause.html + +--- + +## Files + +- `ext/curl/curl_async.c` — Async cURL implementation (read/write callbacks, unpause logic) +- `ext/curl/curl_async.h` — Struct definitions and public API +- `ext/curl/interface.c` — `build_mime_structure_from_hash()` registers the async read callback +- `ext/curl/curl_private.h` — `mime_data_cb_arg_t` struct with async state diff --git a/README.md b/README.md index db5a8d5b..7563f9a4 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ PhpStorm stubs for autocompletion and inline docs are available in [`ide-stubs/` - **[Documentation](https://true-async.github.io/)** — full reference and guides - **[Supported Functions](https://true-async.github.io/docs/reference/supported-functions.html)** — complete list of async-aware PHP functions - **[Download & Installation](https://true-async.github.io/download.html)** — packages and build instructions +- **[cURL Integration Notes](CURL-INTEGRATION.md)** — known libcurl bugs, minimum version requirements, and architecture details --- diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..5cea4609 --- /dev/null +++ b/TODO.md @@ -0,0 +1,89 @@ +# TrueAsync TODO + +## Windows: stream_select() does not support pipes + +### Problem + +Test `ext/standard/tests/streams/proc_open_bug64438.phpt` fails on Windows. + +The test creates pipes via `proc_open`, calls `stream_set_blocking(false)`, then uses +`stream_select()` + `fread()` in a loop. Expected: 2 entries per pipe: +`[Read 4097 bytes, Closing pipe]`. Actual: 3 entries: `[Read 0 bytes, Read 4097 bytes, Closing pipe]`. + +### Root cause + +`select()` on Windows works **only with Winsock sockets**, not pipes. +`stream_select()` on pipes returns a false positive — reports "readable" when no data is available. + +Before async this was masked: `stream_set_blocking(false)` on Windows pipes **always failed** +(returned -1 in `plain_wrapper.c`), so `is_blocked` stayed `1`. `fread()` entered the +`PeekNamedPipe` wait loop (up to 32 sec) and waited for data — compensating for unreliable `stream_select`. + +With async IO, `stream_set_blocking(false)` now works (`plain_wrapper.c:1082-1086` sets +`is_blocked=0` when `async_io != NULL`). Now `fread()` with `PeekNamedPipe` sees 0 available +bytes and returns 0 immediately (correct non-blocking behavior), exposing the `stream_select` bug. + +### Conclusion + +This is **not an async IO bug** — it is a Windows `select()` limitation. Our code correctly +implements non-blocking reads. The test relied on `stream_set_blocking(false)` silently failing, +which hid the `stream_select` pipe incompatibility. + +### Possible solutions + +1. Mark the test as `XFAIL` on Windows with async (minimal change) +2. Implement `stream_select` for Windows pipes via `PeekNamedPipe`/`WaitForMultipleObjects` + instead of `select()` (proper fix, but significant work) + +--- + +## Windows: bug51056 — TCP timing issue + +### Problem + +Test `ext/standard/tests/streams/bug51056.phpt` fails on Windows. + +Server writes 8 bytes, `usleep(50000)`, 301 bytes, `usleep(50000)`, 8 bytes. +Client reads with `fread($fp, 256)`. Expected 4 reads: 8, 256, 45, 8. +Actual: 3 reads: 8, 256, 53 (last 45+8 merged). + +### Root cause + +The test uses `fsockopen()` → `php_stream_socket_ops` → `php_sockop_read`. +There is **no async IO integration** in `xp_socket.c`. This is a TCP socket, not a pipe. + +The issue is TCP timing: 50ms `usleep` between writes is not enough to separate TCP segments +(Nagle's algorithm). The last two writes arrive as a single TCP segment on the client side. + +**Not an async IO bug.** + +--- + +## Analyze all PHP_STREAM_AS_STDIO call sites + +### Problem + +When async IO is active, `php_stdiop_cast(PHP_STREAM_AS_STDIO)` creates a `FILE*` via +`fdopen()`. Since the fd is owned by libuv, we currently `dup()` the fd before `fdopen()` +to avoid dual ownership (see `plain_wrapper.c`, `php_stdiop_cast`, marked as TEMPORARY). + +This is a workaround. A proper solution requires analyzing **all** code paths that call +`php_stream_cast(PHP_STREAM_AS_STDIO)` to understand how the resulting `FILE*` is used: + +- Is `fwrite(fp)` used, or does the caller go through `php_stream_write()`? +- Does the caller close the `FILE*` independently? +- Can the caller work with async IO directly instead of requiring a `FILE*`? + +### Known call sites to audit + +- `ext/curl/interface.c` — `curl_setopt(CURLOPT_FILE, ...)` casts stream to `FILE*`, + but async curl write path uses `php_stream_write()`, not `fwrite(fp)`. +- Any extension using `php_stream_cast(PHP_STREAM_AS_STDIO)` in combination with + C library functions that expect `FILE*`. + +### Goal + +Eliminate the `dup()` workaround by ensuring async IO streams either: +1. Never need `PHP_STREAM_AS_STDIO` cast (preferred), or +2. Have a well-defined ownership model for the `FILE*` copy. + diff --git a/async_API.c b/async_API.c index 7c948326..a40e7537 100644 --- a/async_API.c +++ b/async_API.c @@ -103,6 +103,11 @@ zend_coroutine_t *spawn(zend_async_scope_t *scope, zend_object *scope_provider, return NULL; } + if (UNEXPECTED(ZEND_ASYNC_SCHEDULER != NULL && scope == ZEND_ASYNC_SCHEDULER->scope)) { + async_throw_error("You cannot use the Scheduler scope to create coroutines"); + return NULL; + } + async_coroutine_t *coroutine = (async_coroutine_t *) async_new_coroutine(scope); if (UNEXPECTED(coroutine == NULL)) { return NULL; diff --git a/coroutine.c b/coroutine.c index 42665df2..7fbd4b49 100644 --- a/coroutine.c +++ b/coroutine.c @@ -832,6 +832,8 @@ bool async_coroutine_cancel(zend_coroutine_t *zend_coroutine, { transfer_error = error != NULL ? transfer_error : false; + ZEND_ASSERT(zend_coroutine != ZEND_ASYNC_SCHEDULER && "A Scheduler coroutine cannot be canceled"); + // If the coroutine finished, do nothing. if (ZEND_COROUTINE_IS_FINISHED(zend_coroutine)) { if (transfer_error && error != NULL) { diff --git a/docs/shutdown-lifecycle-repeat.md b/docs/shutdown-lifecycle-repeat.md new file mode 100644 index 00000000..1114a037 --- /dev/null +++ b/docs/shutdown-lifecycle-repeat.md @@ -0,0 +1,173 @@ +# Shutdown Lifecycle with `--repeat 2` and Fatal Error + +## Overview + +When PHP CLI runs with `--repeat 2`, `do_cli()` executes the script twice +in the same process via `goto do_repeat`. Each iteration does +`php_request_startup()` → script execution → `php_request_shutdown()`. + +When a Fatal Error (e.g. memory exhaustion) occurs during async execution, +the bailout propagation interacts with the scheduler/reactor lifecycle and +can lead to a SEGV in `executor_globals_dtor` during module shutdown. + +## ROUND 1 + +``` +main() → do_cli() + ├── do_repeat: (php_cli.c:871) + ├── php_request_startup() (php_cli.c:917) + │ └── PHP_RINIT(async) → ASYNC_G(reactor_started) = false + │ + ├── [zend_try] (php_cli.c:1005) + │ └── php_execute_script() (main.c:2640) + │ ├── [zend_try] (main.c:2672) + │ │ ├── zend_execute_script() → Async\spawn() + │ │ │ ├── async_scheduler_launch() → ZEND_ASYNC_ACTIVATE + │ │ │ └── libuv_reactor_startup() → reactor_started = true + │ │ │ └── STDOUT/STDERR/STDIN get async_io attached + │ │ │ + │ │ ├── Fatal Error → zend_bailout() ──longjmp──┐ + │ │ │ │ + │ │ └── ZEND_ASYNC_RUN_SCHEDULER_AFTER_MAIN(false) [skipped] + │ │ │ + │ ├── [zend_catch] (main.c:2672) ◄───────────────────┘ + │ │ └── ZEND_ASYNC_RUN_SCHEDULER_AFTER_MAIN(true) + │ │ = async_scheduler_main_coroutine_suspend(true) + │ │ ├── start_graceful_shutdown() + │ │ ├── switch_to_scheduler_with_bailout() + │ │ ├── ZEND_ASYNC_DEACTIVATE (scheduler.c:1193) + │ │ └── zend_bailout() (scheduler.c:1216) ─longjmp─┐ + │ │ │ + │ └── [zend_end_try] (main.c:2676) │ + │ │ + ├── [zend_end_try] (php_cli.c:1149) ◄──────────────────────────────────────┘ + │ + ├── out: (php_cli.c:1151) + ├── php_request_shutdown() (php_cli.c:1156) + │ ├── php_call_shutdown_functions() → "Shutdown function called" + │ ├── zend_call_destructors() + │ ├── [zend_try] ZEND_ASYNC_RUN_SCHEDULER_AFTER_MAIN(false) + │ ├── ZEND_ASYNC_REACTOR_DETACH_IO() → nulls async_io on streams + │ ├── ZEND_ASYNC_DEACTIVATE → state = OFF + │ ├── ... flush output, deactivate modules (RSHUTDOWN) ... + │ └── zend_deactivate() + │ ├── shutdown_executor() + │ │ └── zend_shutdown_executor_values(fast_shutdown=1) + │ │ └── zend_close_rsrc_list() → sets type = -1 + │ ├── ENGINE_SHUTDOWN() → REACTOR_SHUTDOWN() + │ │ ├── uv_loop_close() + │ │ ├── reactor_started = false + │ │ └── zend_hash_destroy(active_io_handles) + │ └── zend_destroy_rsrc_list() + │ + ├── request_started = 0 + ├── --num_repeats → still > 0 + └── goto do_repeat ──────────────────────────────────────────────┐ + │ +``` + +## ROUND 2 + +``` + ├── do_repeat: (php_cli.c:871) ◄──────────────────────────────────┘ + ├── php_request_startup() (php_cli.c:917) + │ └── PHP_RINIT(async) → reactor_started = false + │ + ├── [zend_try] (php_cli.c:1005) + │ └── php_execute_script() + │ ├── [zend_try] (main.c:2672) + │ │ ├── Async\spawn() → reactor_startup() → reactor_started = true + │ │ │ └── STDOUT/STDERR/STDIN get NEW async_io attached + │ │ ├── Fatal Error → zend_bailout() ──longjmp──┐ + │ │ │ │ + │ ├── [zend_catch] (main.c:2672) ◄───────────────────┘ + │ │ └── ZEND_ASYNC_RUN_SCHEDULER_AFTER_MAIN(true) + │ │ = async_scheduler_main_coroutine_suspend(true) + │ │ ├── ZEND_ASYNC_DEACTIVATE (scheduler.c:1193) + │ │ └── zend_bailout() (scheduler.c:1216) ─longjmp─┐ + │ │ │ + ├── [zend_end_try] (php_cli.c:1149) ◄──────────────────────────────────────┘ + │ + ├── out: (php_cli.c:1151) + ├── php_request_shutdown() (php_cli.c:1156) + │ ├── php_call_shutdown_functions() → ??? + │ ├── [zend_try] ZEND_ASYNC_RUN_SCHEDULER_AFTER_MAIN(false) + │ │ └── state == OFF (set by scheduler.c:1193) → what happens? + │ ├── ZEND_ASYNC_REACTOR_DETACH_IO() + │ │ └── reactor_started == true, but state == OFF → ??? + │ ├── ZEND_ASYNC_DEACTIVATE → state = OFF (already OFF) + │ ├── zend_deactivate() + │ │ ├── shutdown_executor() → zend_close_rsrc_list() → type=-1 ??? + │ │ ├── ENGINE_SHUTDOWN() → REACTOR_SHUTDOWN() + │ │ └── zend_destroy_rsrc_list() + │ └── ... + │ + ├── --num_repeats → 0, return + │ +``` + +## MODULE SHUTDOWN (crash path) + +``` +main() continues after do_cli() returns: + ├── php_module_shutdown() (php_cli.c:1373) + │ └── zend_shutdown() (zend.c:1209) + │ └── ts_free_id(executor_globals_id) + │ └── executor_globals_dtor() + │ └── zend_hash_destroy(zend_constants) + │ └── free_zend_constant(STDOUT) + │ └── zval_ptr_dtor_nogc + │ └── zend_hash_index_del(regular_list) + │ └── list_entry_destructor + │ type=2 (NOT -1!) → dtor runs + │ → zend_resource_dtor + │ → stream_resource_regular_dtor + │ → php_stdiop_close + │ → libuv_io_close + │ → ASYNC_G(reactor_started) + │ ↑ TSRM slot already freed + │ address = 0x4c8 (NULL + offset) + │ 💥 SEGV +``` + +## Key Observations + +1. **`ZEND_ASYNC_DEACTIVATE` in scheduler.c:1193** sets `state = OFF` BEFORE + `php_request_shutdown` runs. This may cause `REACTOR_DETACH_IO` or other + shutdown steps to be skipped (they check `ZEND_ASYNC_IS_OFF`). + +2. **`zend_bailout()` in scheduler.c:1216** propagates the bailout from + `php_execute_script`'s `zend_catch` up to `do_cli()`'s `zend_end_try`. + This is expected — but the state left behind may be inconsistent. + +3. **Resources have `type=2`** in `executor_globals_dtor`, meaning + `zend_close_rsrc_list()` never ran for them. This means `shutdown_executor()` + was either skipped or did not complete during Round 2's `php_request_shutdown`. + +4. **The crash only happens with `--repeat >= 2`** because it requires a second + request cycle where the reactor/scheduler state from Round 1's cleanup + interacts with Round 2's lifecycle. + +5. **`--repeat` is passed by `run-tests.php`** to the PHP CLI binary. The CLI + handles it via `goto do_repeat` loop in `do_cli()` (php_cli.c:1168→871). + +## Relevant Files + +| File | Key code | +|------|----------| +| `sapi/cli/php_cli.c:871` | `do_repeat:` label — start of repeat loop | +| `sapi/cli/php_cli.c:917` | `php_request_startup()` | +| `sapi/cli/php_cli.c:1149` | `zend_end_try()` — catches bailout from script | +| `sapi/cli/php_cli.c:1156` | `php_request_shutdown()` | +| `sapi/cli/php_cli.c:1168` | `goto do_repeat` — repeat decision | +| `main/main.c:1992` | `ZEND_ASYNC_RUN_SCHEDULER_AFTER_MAIN(false)` in request shutdown | +| `main/main.c:1997` | `ZEND_ASYNC_REACTOR_DETACH_IO()` in request shutdown | +| `main/main.c:2674` | `ZEND_ASYNC_RUN_SCHEDULER_AFTER_MAIN(true)` in bailout catch | +| `ext/async/scheduler.c:1193` | `ZEND_ASYNC_DEACTIVATE` — sets state=OFF early | +| `ext/async/scheduler.c:1216` | `zend_bailout()` — re-throws bailout | +| `ext/async/libuv_reactor.c:298` | `libuv_reactor_detach_io()` | +| `ext/async/libuv_reactor.c:326` | `libuv_reactor_shutdown()` | +| `ext/async/libuv_reactor.c:3895` | `libuv_io_close()` — crash site | +| `Zend/zend.c:864` | `executor_globals_dtor()` — triggers crash | +| `Zend/zend.c:1356` | `shutdown_executor()` in `zend_deactivate()` | +| `Zend/zend.c:1359` | `ENGINE_SHUTDOWN()` in `zend_deactivate()` | diff --git a/future.c b/future.c index 5220168b..52ef9a31 100644 --- a/future.c +++ b/future.c @@ -1015,7 +1015,7 @@ FUTURE_METHOD(completed) if (value != NULL) { ZEND_FUTURE_COMPLETE(future, value); } else { - zval null_val; + zval null_val = {0}; ZVAL_NULL(&null_val); ZEND_FUTURE_COMPLETE(future, &null_val); } diff --git a/libuv_reactor.c b/libuv_reactor.c index e3c2a55e..e19f3e0e 100644 --- a/libuv_reactor.c +++ b/libuv_reactor.c @@ -22,6 +22,7 @@ #ifdef PHP_WIN32 #include "win32/unistd.h" +#include "win32/codepage.h" #else #include #include @@ -278,6 +279,7 @@ bool libuv_reactor_startup(void) } uv_loop_set_data(UVLOOP, ASYNC_GLOBALS); + zend_hash_init(&ASYNC_G(active_io_handles), 16, NULL, NULL, 0); ASYNC_G(reactor_started) = true; return true; } @@ -292,23 +294,53 @@ static void libuv_reactor_stop_with_exception(void) /* }}} */ +/* {{{ libuv_reactor_detach_io */ +void libuv_reactor_detach_io(void) +{ + if (!ASYNC_G(reactor_started)) { + return; + } + + /* Detach all outstanding IO handles from their owners (e.g. php_stream) + * and close/free them. Preserve the original fd so the stream can + * continue working synchronously after detach. */ + zend_async_io_t *io_handle; + ZEND_HASH_FOREACH_PTR(&ASYNC_G(active_io_handles), io_handle) { + if (io_handle->on_detach != NULL) { + io_handle->on_detach(io_handle, io_handle->on_detach_arg); + io_handle->on_detach = NULL; + } + ((async_io_t *) io_handle)->orig_fd = -1; + ZEND_ASYNC_IO_CLOSE(io_handle); + io_handle->event.dispose(&io_handle->event); + } ZEND_HASH_FOREACH_END(); + + if (uv_loop_alive(UVLOOP) != 0) { + uv_run(UVLOOP, UV_RUN_ONCE); + } +} + +/* }}} */ + /* {{{ libuv_reactor_shutdown */ bool libuv_reactor_shutdown(void) { if (EXPECTED(ASYNC_G(reactor_started))) { - if (uv_loop_alive(UVLOOP) != 0) { - // need to finish handlers - uv_run(UVLOOP, UV_RUN_ONCE); - } - // Cleanup global signal management structures libuv_cleanup_signal_handlers(); libuv_cleanup_signal_events(); libuv_cleanup_process_events(); + /* Drain pending uv_close callbacks (e.g. poll events disposed + * during shutdown_executor via curl free_obj). */ + if (uv_loop_alive(UVLOOP) != 0) { + uv_run(UVLOOP, UV_RUN_NOWAIT); + } + uv_loop_close(UVLOOP); ASYNC_G(reactor_started) = false; + zend_hash_destroy(&ASYNC_G(active_io_handles)); } return true; } @@ -534,8 +566,13 @@ static bool libuv_poll_dispose(zend_async_event_t *event) poll->proxies = NULL; } - /* Use poll-specific callback for poll events that may need descriptor cleanup */ - uv_close((uv_handle_t *) &poll->uv_handle, libuv_close_poll_handle_cb); + /* Use poll-specific callback for poll events that may need descriptor cleanup. + * If the reactor is already shut down, uv_close cannot run — free directly. */ + if (UNEXPECTED(!ASYNC_G(reactor_started))) { + pefree(poll, 0); + } else { + uv_close((uv_handle_t *) &poll->uv_handle, libuv_close_poll_handle_cb); + } return true; } @@ -2632,6 +2669,23 @@ static void exec_read_cb(uv_stream_t *stream, ssize_t nread, const uv_buf_t *buf zend_async_exec_event_t *exec = &event->event; if (nread > 0) { +#ifdef PHP_WIN32 + /* shell_exec uses VCWD_POPEN("rt") — text mode — so CRT strips \r\n → \n. + * Replicate that for the async path. + * passthru is raw binary — never modify data. + * exec/system/exec_array use VCWD_POPEN("rb") and strip \r via + * exec_strip_trailing_ws per line, so no pre-conversion needed. */ + if (exec->exec_mode == ZEND_ASYNC_EXEC_MODE_SHELL_EXEC) { + ssize_t j = 0; + for (ssize_t i = 0; i < nread; i++) { + if (buf->base[i] == '\r' && i + 1 < nread && buf->base[i + 1] == '\n') { + continue; + } + buf->base[j++] = buf->base[i]; + } + nread = j; + } +#endif switch (exec->exec_mode) { case ZEND_ASYNC_EXEC_MODE_EXEC: case ZEND_ASYNC_EXEC_MODE_EXEC_ARRAY: @@ -2639,12 +2693,12 @@ static void exec_read_cb(uv_stream_t *stream, ssize_t nread, const uv_buf_t *buf break; case ZEND_ASYNC_EXEC_MODE_SYSTEM: - PHPWRITE(buf->base, nread); + PHPWRITE_CORO(buf->base, nread, event->coroutine); exec_process_chunk(event, buf->base, nread); break; case ZEND_ASYNC_EXEC_MODE_PASSTHRU: - PHPWRITE(buf->base, nread); + PHPWRITE_CORO(buf->base, nread, event->coroutine); break; case ZEND_ASYNC_EXEC_MODE_SHELL_EXEC: @@ -2799,13 +2853,6 @@ static bool libuv_exec_dispose(zend_async_event_t *event) uv_close(handle, libuv_close_handle_cb); } -#ifdef PHP_WIN32 - if (exec->quoted_cmd != NULL) { - efree(exec->quoted_cmd); - exec->quoted_cmd = NULL; - } -#endif - // Free the event itself pefree(event, 0); return true; @@ -2841,39 +2888,63 @@ static zend_async_exec_event_t *libuv_new_exec_event(zend_async_exec_mode exec_m exec->process = pecalloc(sizeof(uv_process_t), 1, 0); exec->stdout_pipe = pecalloc(sizeof(uv_pipe_t), 1, 0); - exec->stderr_pipe = pecalloc(sizeof(uv_pipe_t), 1, 0); exec->process->data = exec; exec->stdout_pipe->data = exec; - exec->stderr_pipe->data = exec; uv_pipe_init(UVLOOP, exec->stdout_pipe, 0); - uv_pipe_init(UVLOOP, exec->stderr_pipe, 0); + + if (std_error != NULL) { + exec->stderr_pipe = pecalloc(sizeof(uv_pipe_t), 1, 0); + exec->stderr_pipe->data = exec; + uv_pipe_init(UVLOOP, exec->stderr_pipe, 0); + } options->exit_cb = exec_on_exit; #ifdef PHP_WIN32 options->flags = UV_PROCESS_WINDOWS_VERBATIM_ARGUMENTS; options->file = "cmd.exe"; - size_t cmd_buffer_size = strlen(cmd) + 2; - exec->quoted_cmd = emalloc(cmd_buffer_size); - snprintf(exec->quoted_cmd, cmd_buffer_size, "\"%s\"", cmd); - options->args = (char *[]){ "cmd.exe", "/s", "/c", exec->quoted_cmd, NULL }; + + /* uv_spawn expects UTF-8 strings. Convert cmd from the current code page + * (which may be e.g. CP1251) to UTF-8 via UTF-16 intermediate. */ + wchar_t *cmd_w = php_win32_cp_any_to_w(cmd); + char *utf8_cmd = cmd_w ? php_win32_cp_w_to_utf8(cmd_w) : NULL; + free(cmd_w); + + const char *spawn_cmd = utf8_cmd ? utf8_cmd : cmd; + const size_t cmd_buffer_size = strlen(spawn_cmd) + 3; + char *quoted_cmd = emalloc(cmd_buffer_size); + snprintf(quoted_cmd, cmd_buffer_size, "\"%s\"", spawn_cmd); + options->args = (char *[]){ "cmd.exe", "/s", "/c", quoted_cmd, NULL }; + + /* Convert cwd to UTF-8 as well. */ + char *utf8_cwd = NULL; + if (cwd != NULL && cwd[0] != '\0') { + wchar_t *cwd_w = php_win32_cp_any_to_w(cwd); + utf8_cwd = cwd_w ? php_win32_cp_w_to_utf8(cwd_w) : NULL; + free(cwd_w); + options->cwd = utf8_cwd ? utf8_cwd : cwd; + } #else options->file = "/bin/sh"; options->args = (char *[]){ "sh", "-c", (char *) cmd, NULL }; -#endif - - options->stdio = (uv_stdio_container_t[]){ - { .flags = UV_IGNORE, .data = { .stream = NULL } }, - { .data.stream = (uv_stream_t *) exec->stdout_pipe, .flags = UV_CREATE_PIPE | UV_WRITABLE_PIPE }, - { .data.stream = (uv_stream_t *) exec->stderr_pipe, .flags = UV_CREATE_PIPE | UV_WRITABLE_PIPE } - }; - - options->stdio_count = 3; if (cwd != NULL && cwd[0] != '\0') { options->cwd = cwd; } +#endif + + uv_stdio_container_t stdio[3]; + stdio[0] = (uv_stdio_container_t){ .flags = UV_IGNORE, .data = { .stream = NULL } }; + stdio[1] = (uv_stdio_container_t){ .data.stream = (uv_stream_t *) exec->stdout_pipe, .flags = UV_CREATE_PIPE | UV_WRITABLE_PIPE }; + if (exec->stderr_pipe != NULL) { + stdio[2] = (uv_stdio_container_t){ .data.stream = (uv_stream_t *) exec->stderr_pipe, .flags = UV_CREATE_PIPE | UV_WRITABLE_PIPE }; + } else { + stdio[2] = (uv_stdio_container_t){ .flags = UV_INHERIT_FD, .data = { .fd = 2 } }; + } + options->stdio = stdio; + + options->stdio_count = 3; if (env != NULL) { options->env = (char **) env; @@ -2881,6 +2952,12 @@ static zend_async_exec_event_t *libuv_new_exec_event(zend_async_exec_mode exec_m const int result = uv_spawn(UVLOOP, exec->process, options); +#ifdef PHP_WIN32 + efree(quoted_cmd); + free(utf8_cmd); + free(utf8_cwd); +#endif + if (result) { php_error_docref(NULL, E_WARNING, "Failed to spawn process: %s", uv_strerror(result)); uv_close((uv_handle_t *) exec->stdout_pipe, libuv_close_handle_cb); @@ -2891,7 +2968,9 @@ static zend_async_exec_event_t *libuv_new_exec_event(zend_async_exec_mode exec_m } uv_read_start((uv_stream_t *) exec->stdout_pipe, exec_alloc_cb, exec_read_cb); - uv_read_start((uv_stream_t *) exec->stderr_pipe, exec_std_err_alloc_cb, exec_std_err_read_cb); + if (exec->stderr_pipe != NULL) { + uv_read_start((uv_stream_t *) exec->stderr_pipe, exec_std_err_alloc_cb, exec_std_err_read_cb); + } ZEND_ASYNC_INCREASE_EVENT_COUNT(&exec->event.base); @@ -2942,10 +3021,13 @@ static int libuv_exec(zend_async_exec_mode exec_mode, cwd, env); - if (UNEXPECTED(EG(exception))) { + if (UNEXPECTED(exec_event == NULL)) { return -1; } + /* Store coroutine for PHPWRITE_CORO in exec_read_cb */ + ((async_exec_event_t *) exec_event)->coroutine = coroutine; + ZEND_ASYNC_WAKER_NEW(coroutine); if (UNEXPECTED(EG(exception))) { return -1; @@ -3150,6 +3232,7 @@ zend_async_trigger_event_t *libuv_new_trigger_event(size_t extra_size) /// Async IO API ///////////////////////////////////////////////////////////////////////////////// +static bool libuv_io_close(zend_async_io_t *io_base); static void io_close_cb(uv_handle_t *pipe_handle); /* {{{ IO event methods */ @@ -3168,9 +3251,8 @@ static bool libuv_io_event_stop(zend_async_event_t *event) async_io_t *io = (async_io_t *) event; - /* Cancel pending pipe/TTY read if any */ - if (io->active_req != NULL && - (io->base.type == ZEND_ASYNC_IO_TYPE_PIPE || io->base.type == ZEND_ASYNC_IO_TYPE_TTY)) { + /* Cancel pending stream read if any */ + if (io->active_req != NULL && ZEND_ASYNC_IO_IS_STREAM(io->base.type)) { uv_read_stop(&io->handle.stream); io->active_req = NULL; } @@ -3187,6 +3269,22 @@ static bool libuv_io_event_dispose(zend_async_event_t *event) return true; } + async_io_t *io = (async_io_t *) event; + + /* Notify the owner (e.g. plain_wrapper) so it can clear its pointer. */ + if (io->base.on_detach != NULL) { + io->base.on_detach(&io->base, io->base.on_detach_arg); + io->base.on_detach = NULL; + } + + /* Remove from the active IO tracking table. */ + zend_hash_index_del(&ASYNC_G(active_io_handles), async_ptr_to_index(io)); + + /* Close the IO handle if not already closed. */ + if (!(io->base.state & ZEND_ASYNC_IO_CLOSED)) { + libuv_io_close(&io->base); + } + if (event->loop_ref_count > 0) { event->loop_ref_count = 1; event->stop(event); @@ -3194,27 +3292,7 @@ static bool libuv_io_event_dispose(zend_async_event_t *event) zend_async_callbacks_free(event); - async_io_t *io = (async_io_t *) event; - - if (io->base.type == ZEND_ASYNC_IO_TYPE_PIPE && !(io->base.state & ZEND_ASYNC_IO_CLOSED)) { - io->base.state |= ZEND_ASYNC_IO_CLOSED; - io->handle.pipe.data = io; - uv_close((uv_handle_t *) &io->handle.pipe, io_close_cb); - } else if (io->base.type == ZEND_ASYNC_IO_TYPE_TTY && !(io->base.state & ZEND_ASYNC_IO_CLOSED)) { - io->base.state |= ZEND_ASYNC_IO_CLOSED; - io->handle.tty.data = io; - uv_close((uv_handle_t *) &io->handle.tty, io_close_cb); - } else if (io->base.type == ZEND_ASYNC_IO_TYPE_TCP && !(io->base.state & ZEND_ASYNC_IO_CLOSED)) { - io->base.state |= ZEND_ASYNC_IO_CLOSED; - io->handle.tcp.data = io; - uv_close((uv_handle_t *) &io->handle.tcp, io_close_cb); - } else if (io->base.type == ZEND_ASYNC_IO_TYPE_UDP && !(io->base.state & ZEND_ASYNC_IO_CLOSED)) { - io->base.state |= ZEND_ASYNC_IO_CLOSED; - io->handle.udp.data = io; - uv_close((uv_handle_t *) &io->handle.udp, io_close_cb); - } else if (io->base.type == ZEND_ASYNC_IO_TYPE_FILE) { - pefree(io, 0); - } + pefree(io, 0); return true; } @@ -3226,7 +3304,7 @@ static void libuv_io_req_dispose(zend_async_io_req_t *base_req) { async_io_req_t *req = (async_io_req_t *) base_req; - if (req->base.buf != NULL) { + if (req->buf_owned && req->base.buf != NULL) { pefree(req->base.buf, 0); } @@ -3307,7 +3385,8 @@ static void io_pipe_write_cb(uv_write_t *write_request, int status) static void io_close_cb(uv_handle_t *pipe_handle) { - pefree(pipe_handle->data, 0); + async_io_t *io = (async_io_t *) pipe_handle->data; + io->base.event.dispose(&io->base.event); } /* }}} */ @@ -3320,10 +3399,14 @@ static void io_file_read_cb(uv_fs_t *fs_request) if (fs_request->result >= 0) { req->base.transferred = (ssize_t) fs_request->result; - if (fs_request->result == 0) { - io->base.state |= ZEND_ASYNC_IO_EOF; - } else { - io->handle.file.offset += fs_request->result; + if (fs_request->result > 0) { + /* Update tracked offset from kernel position. */ + const zend_off_t pos = zend_lseek(io->crt_fd, 0, SEEK_CUR); + if (pos >= 0) { + io->handle.file.offset = pos; + } else { + io->handle.file.offset += fs_request->result; + } } } else { req->base.transferred = -1; @@ -3333,6 +3416,7 @@ static void io_file_read_cb(uv_fs_t *fs_request) req->base.completed = true; uv_fs_req_cleanup(fs_request); + ZEND_ASYNC_DECREASE_EVENT_COUNT(&io->base.event); ZEND_ASYNC_CALLBACKS_NOTIFY(&io->base.event, &req->base, req->base.exception); IF_EXCEPTION_STOP_REACTOR; } @@ -3344,7 +3428,19 @@ static void io_file_write_cb(uv_fs_t *fs_request) if (fs_request->result >= 0) { req->base.transferred = (ssize_t) fs_request->result; - io->handle.file.offset += fs_request->result; + +#ifdef PHP_WIN32 + /* When an explicit offset was passed to uv_fs_write (append mode), + * WriteFile+OVERLAPPED does not advance the kernel file position, + * so zend_lseek(SEEK_CUR) would return a stale value. */ + if (io->base.state & ZEND_ASYNC_IO_APPEND) { + io->handle.file.offset += fs_request->result; + } else +#endif + { + const zend_off_t pos = zend_lseek(io->crt_fd, 0, SEEK_CUR); + io->handle.file.offset = (pos >= 0) ? pos : io->handle.file.offset + fs_request->result; + } } else { req->base.transferred = -1; req->base.exception = async_new_exception( @@ -3353,6 +3449,7 @@ static void io_file_write_cb(uv_fs_t *fs_request) req->base.completed = true; uv_fs_req_cleanup(fs_request); + ZEND_ASYNC_DECREASE_EVENT_COUNT(&io->base.event); ZEND_ASYNC_CALLBACKS_NOTIFY(&io->base.event, &req->base, req->base.exception); IF_EXCEPTION_STOP_REACTOR; } @@ -3372,6 +3469,7 @@ static void io_file_flush_cb(uv_fs_t *fs_request) req->base.completed = true; uv_fs_req_cleanup(fs_request); + ZEND_ASYNC_DECREASE_EVENT_COUNT(&io->base.event); ZEND_ASYNC_CALLBACKS_NOTIFY(&io->base.event, &req->base, req->base.exception); IF_EXCEPTION_STOP_REACTOR; } @@ -3395,6 +3493,7 @@ static void io_file_stat_cb(uv_fs_t *fs_request) req->base.completed = true; uv_fs_req_cleanup(fs_request); + ZEND_ASYNC_DECREASE_EVENT_COUNT(&io->base.event); ZEND_ASYNC_CALLBACKS_NOTIFY(&io->base.event, &req->base, req->base.exception); IF_EXCEPTION_STOP_REACTOR; } @@ -3415,8 +3514,7 @@ libuv_io_create(const zend_file_descriptor_t fd, const zend_async_io_type type, /* Unix only: libuv asserts fd > STDERR_FILENO in uv__close(). * Dup stdio fds so libuv can safely close the copy. */ zend_file_descriptor_t io_fd = fd; - if ((type == ZEND_ASYNC_IO_TYPE_PIPE || type == ZEND_ASYNC_IO_TYPE_TTY) - && fd >= 0 && fd <= STDERR_FILENO) { + if (ZEND_ASYNC_IO_IS_STREAM(type) && fd >= 0 && fd <= STDERR_FILENO) { io_fd = dup(fd); if (io_fd < 0) { pefree(io, 0); @@ -3469,8 +3567,8 @@ libuv_io_create(const zend_file_descriptor_t fd, const zend_async_io_type type, io->handle.pipe.data = io; } else if (type == ZEND_ASYNC_IO_TYPE_TTY) { - int readable = (state & ZEND_ASYNC_IO_READABLE) ? 1 : 0; - int error = uv_tty_init(UVLOOP, &io->handle.tty, io->crt_fd, readable); + const int readable = (state & ZEND_ASYNC_IO_READABLE) ? 1 : 0; + const int error = uv_tty_init(UVLOOP, &io->handle.tty, io->crt_fd, readable); if (UNEXPECTED(error < 0)) { async_throw_error("Failed to initialize TTY handle: %s", uv_strerror(error)); @@ -3488,9 +3586,23 @@ libuv_io_create(const zend_file_descriptor_t fd, const zend_async_io_type type, return NULL; } +#ifdef PHP_WIN32 + const uv_os_sock_t sock = (uv_os_sock_t) _get_osfhandle(io_fd); +#else + const uv_os_sock_t sock = (uv_os_sock_t) io_fd; +#endif + error = uv_tcp_open(&io->handle.tcp, sock); + + if (UNEXPECTED(error < 0)) { + async_throw_error("Failed to open TCP handle: %s", uv_strerror(error)); + io->handle.tcp.data = io; + uv_close((uv_handle_t *) &io->handle.tcp, io_close_cb); + return NULL; + } + io->handle.tcp.data = io; } else if (type == ZEND_ASYNC_IO_TYPE_UDP) { - int error = uv_udp_init(UVLOOP, &io->handle.udp); + const int error = uv_udp_init(UVLOOP, &io->handle.udp); if (UNEXPECTED(error < 0)) { async_throw_error("Failed to initialize UDP handle: %s", uv_strerror(error)); @@ -3500,34 +3612,45 @@ libuv_io_create(const zend_file_descriptor_t fd, const zend_async_io_type type, io->handle.udp.data = io; } else { - /* FILE type */ + /* FILE type: initialise the tracked offset. + * For append mode we need to know the current EOF so that + * uv_fs_write can target it explicitly (on Windows _O_APPEND + * is a CRT flag invisible to WriteFile). After reading EOF + * we restore the fd to 0 so that ftell() returns 0 on open, + * matching POSIX O_APPEND semantics. */ if (state & ZEND_ASYNC_IO_APPEND) { - const zend_off_t end = zend_lseek(io->crt_fd, 0, SEEK_END); - io->handle.file.offset = (end >= 0) ? end : 0; + const zend_off_t eof = zend_lseek(io->crt_fd, 0, SEEK_END); + io->handle.file.offset = (eof >= 0) ? eof : 0; + zend_lseek(io->crt_fd, 0, SEEK_SET); } else { const zend_off_t pos = zend_lseek(io->crt_fd, 0, SEEK_CUR); io->handle.file.offset = (pos >= 0) ? pos : 0; } } + zend_hash_index_add_new_ptr(&ASYNC_G(active_io_handles), async_ptr_to_index(io), io); + return &io->base; } /* }}} */ +#ifdef PHP_WIN32 /* {{{ libuv_can_use_sync_io * Returns true when there is at most one coroutine and no active events * in the reactor, meaning async I/O would only add overhead with no - * concurrency benefit. */ + * concurrency benefit. Used on Windows where libuv async I/O goes through + * a helper process, making synchronous calls much cheaper. */ static inline bool libuv_can_use_sync_io(void) { return zend_hash_num_elements(&ASYNC_G(coroutines)) <= 1 && !libuv_reactor_loop_alive(); } +#endif /* }}} */ /* {{{ libuv_io_read */ -static zend_async_io_req_t *libuv_io_read(zend_async_io_t *io_base, const size_t max_size) +static zend_async_io_req_t *libuv_io_read(zend_async_io_t *io_base, char *buf, size_t max_size) { async_io_t *io = (async_io_t *) io_base; @@ -3536,6 +3659,12 @@ static zend_async_io_req_t *libuv_io_read(zend_async_io_t *io_base, const size_t return NULL; } + /* _read() and uv_buf_t.len are 32-bit on Windows; cap to INT_MAX so the + * caller's loop (e.g. _php_stream_write_buffer) can retry the remainder. */ + if (max_size > INT_MAX) { + max_size = INT_MAX; + } + async_io_req_t *req = pecalloc(1, sizeof(async_io_req_t), 0); req->base.dispose = libuv_io_req_dispose; req->io = io; @@ -3547,40 +3676,36 @@ static zend_async_io_req_t *libuv_io_read(zend_async_io_t *io_base, const size_t return &req->base; } - req->base.buf = pemalloc(max_size, 0); - -#ifndef PHP_WIN32 - /* Sync fallback: use blocking I/O when there is at most one coroutine - * and no active events in the reactor. */ - const bool sync_io = libuv_can_use_sync_io(); -#else - const bool sync_io = false; -#endif + if (buf != NULL) { + req->base.buf = buf; + req->buf_owned = false; + } else { + req->base.buf = pemalloc(max_size, 0); + req->buf_owned = true; + } - if (io->base.type == ZEND_ASYNC_IO_TYPE_PIPE || io->base.type == ZEND_ASYNC_IO_TYPE_TTY) { -#ifndef PHP_WIN32 - if (sync_io) { - ssize_t result; - do { - result = read(io->crt_fd, req->base.buf, max_size); - } while (result == -1 && errno == EINTR); + if (ZEND_ASYNC_IO_IS_STREAM(io->base.type)) { +#ifdef PHP_WIN32 + /* Sync fallback: on Windows libuv async I/O goes through a helper + * process, so a direct blocking call is much cheaper when there is + * at most one coroutine and no active reactor events. */ + if (libuv_can_use_sync_io()) { + const int result = _read(io->crt_fd, req->base.buf, (unsigned int) max_size); if (result > 0) { req->base.transferred = result; - req->base.completed = true; - return &req->base; - } else if (result == 0) { + } else if (result == 0 || errno == EBADF) { + /* EBADF on pipes: treat as EOF (e.g. proc_open pipe + * where child never writes to this descriptor). */ req->base.transferred = 0; io->base.state |= ZEND_ASYNC_IO_EOF; - req->base.completed = true; - return &req->base; - } else if (errno != EAGAIN) { + } else { req->base.transferred = -1; req->base.exception = async_new_exception(async_ce_input_output_exception, "Stream read error: %s", strerror(errno)); - req->base.completed = true; - return &req->base; } + req->base.completed = true; + return &req->base; } #endif @@ -3599,19 +3724,19 @@ static zend_async_io_req_t *libuv_io_read(zend_async_io_t *io_base, const size_t } /* FILE path */ -#ifndef PHP_WIN32 - if (sync_io) { - ssize_t result; - do { - result = pread(io->crt_fd, req->base.buf, max_size, io->handle.file.offset); - } while (result == -1 && errno == EINTR); +#ifdef PHP_WIN32 + if (libuv_can_use_sync_io()) { + /* Read at the current kernel position — do NOT seek to the tracked + * offset, because dup'd fds share the kernel position and another + * fd may have advanced it. */ + const int result = _read(io->crt_fd, req->base.buf, (unsigned int) max_size); if (result > 0) { req->base.transferred = result; - io->handle.file.offset += result; + const zend_off_t pos = zend_lseek(io->crt_fd, 0, SEEK_CUR); + io->handle.file.offset = (pos >= 0) ? pos : io->handle.file.offset + result; } else if (result == 0) { req->base.transferred = 0; - io->base.state |= ZEND_ASYNC_IO_EOF; } else { req->base.transferred = -1; req->base.exception = @@ -3626,8 +3751,10 @@ static zend_async_io_req_t *libuv_io_read(zend_async_io_t *io_base, const size_t const uv_buf_t read_buffer = uv_buf_init(req->base.buf, (unsigned int) max_size); req->fs_req.data = req; + /* Use offset=-1 so libuv calls read() instead of pread(). + * This moves the kernel file offset, keeping dup'd fds in sync. */ const int error = - uv_fs_read(UVLOOP, &req->fs_req, io->crt_fd, &read_buffer, 1, io->handle.file.offset, io_file_read_cb); + uv_fs_read(UVLOOP, &req->fs_req, io->crt_fd, &read_buffer, 1, -1, io_file_read_cb); if (UNEXPECTED(error < 0)) { async_throw_error("Failed to start file read: %s", uv_strerror(error)); @@ -3635,6 +3762,7 @@ static zend_async_io_req_t *libuv_io_read(zend_async_io_t *io_base, const size_t return NULL; } + ZEND_ASYNC_INCREASE_EVENT_COUNT(&io->base.event); return &req->base; } @@ -3655,38 +3783,28 @@ static zend_async_io_req_t *libuv_io_write(zend_async_io_t *io_base, const char req->io = io; req->max_size = count; -#ifndef PHP_WIN32 - /* Sync fallback: use blocking I/O when there is at most one coroutine - * and no active events in the reactor. */ - const bool sync_io = libuv_can_use_sync_io(); -#else - const bool sync_io = false; -#endif - - if (io->base.type == ZEND_ASYNC_IO_TYPE_PIPE || io->base.type == ZEND_ASYNC_IO_TYPE_TTY) { -#ifndef PHP_WIN32 - if (sync_io) { - ssize_t result; - do { - result = write(io->crt_fd, buf, count); - } while (result == -1 && errno == EINTR); - - if (result >= 0 || errno != EAGAIN) { - if (result >= 0) { - req->base.transferred = result; - } else { - req->base.transferred = -1; - req->base.exception = async_new_exception( - async_ce_input_output_exception, "Stream write error: %s", strerror(errno)); - } - req->base.completed = true; - return &req->base; + if (ZEND_ASYNC_IO_IS_STREAM(io->base.type)) { +#ifdef PHP_WIN32 + /* Sync fallback: on Windows libuv async I/O goes through a helper + * process, so a direct blocking call is much cheaper when there is + * at most one coroutine and no active reactor events. */ + if (libuv_can_use_sync_io()) { + const unsigned int write_size = (count > INT_MAX) ? (unsigned int) INT_MAX : (unsigned int) count; + const int result = _write(io->crt_fd, buf, write_size); + + if (result >= 0) { + req->base.transferred = result; + } else { + req->base.transferred = -1; + req->base.exception = async_new_exception( + async_ce_input_output_exception, "Stream write error: %s", strerror(errno)); } - /* EAGAIN/EWOULDBLOCK on non-blocking fd: fall through to async path */ + req->base.completed = true; + return &req->base; } #endif - const uv_buf_t write_buffer = uv_buf_init((char *) buf, (unsigned int) count); + const uv_buf_t write_buffer = uv_buf_init((char *) buf, (unsigned int) (count > INT_MAX ? INT_MAX : count)); req->write_req.data = req; const int error = uv_write(&req->write_req, &io->handle.stream, &write_buffer, 1, io_pipe_write_cb); @@ -3701,16 +3819,19 @@ static zend_async_io_req_t *libuv_io_write(zend_async_io_t *io_base, const char } /* FILE path */ -#ifndef PHP_WIN32 - if (sync_io) { - ssize_t result; - do { - result = pwrite(io->crt_fd, buf, count, io->handle.file.offset); - } while (result == -1 && errno == EINTR); +#ifdef PHP_WIN32 + if (libuv_can_use_sync_io()) { + /* Write at the current kernel position — do NOT seek to the tracked + * offset, because dup'd fds share the kernel position and another + * fd may have advanced it. _write() with _O_APPEND handles + * append mode automatically. */ + const unsigned int write_size = (count > INT_MAX) ? (unsigned int) INT_MAX : (unsigned int) count; + const int result = _write(io->crt_fd, buf, write_size); if (result >= 0) { req->base.transferred = result; - io->handle.file.offset += result; + const zend_off_t pos = zend_lseek(io->crt_fd, 0, SEEK_CUR); + io->handle.file.offset = (pos >= 0) ? pos : io->handle.file.offset + result; } else { req->base.transferred = -1; req->base.exception = @@ -3722,11 +3843,28 @@ static zend_async_io_req_t *libuv_io_write(zend_async_io_t *io_base, const char } #endif - const uv_buf_t write_buffer = uv_buf_init((char *) buf, (unsigned int) count); + const uv_buf_t write_buffer = uv_buf_init((char *) buf, (unsigned int) (count > INT_MAX ? INT_MAX : count)); req->fs_req.data = req; + /* offset=-1 tells libuv to use write() instead of pwrite(), which + * advances the kernel file offset — important for dup'd descriptors. + * + * On Windows, WriteFile via libuv ignores CRT _O_APPEND, so we must + * query the real EOF right before submitting the write request. + * This is safe because the event loop is single-threaded — no other + * coroutine can interleave between lseek and uv_fs_write dispatch. */ +#ifdef PHP_WIN32 + int64_t offset = -1; + if (io->base.state & ZEND_ASYNC_IO_APPEND) { + const zend_off_t eof = zend_lseek(io->crt_fd, 0, SEEK_END); + offset = (eof >= 0) ? (int64_t) eof : (int64_t) io->handle.file.offset; + } +#else + const int64_t offset = -1; +#endif + const int error = - uv_fs_write(UVLOOP, &req->fs_req, io->crt_fd, &write_buffer, 1, io->handle.file.offset, io_file_write_cb); + uv_fs_write(UVLOOP, &req->fs_req, io->crt_fd, &write_buffer, 1, offset, io_file_write_cb); if (UNEXPECTED(error < 0)) { async_throw_error("Failed to start file write: %s", uv_strerror(error)); @@ -3734,41 +3872,63 @@ static zend_async_io_req_t *libuv_io_write(zend_async_io_t *io_base, const char return NULL; } + ZEND_ASYNC_INCREASE_EVENT_COUNT(&io->base.event); return &req->base; } /* }}} */ /* {{{ libuv_io_close */ -static int libuv_io_close(zend_async_io_t *io_base) +static bool libuv_io_close(zend_async_io_t *io_base) { async_io_t *io = (async_io_t *) io_base; if (io->base.state & ZEND_ASYNC_IO_CLOSED) { - return 0; + return true; } io->base.state |= ZEND_ASYNC_IO_CLOSED; + bool need_close_handle = false; + + /* If the reactor is already shut down (e.g. bailout during memory + * exhaustion followed by executor_globals_dtor), skip libuv calls. */ + if (UNEXPECTED(!ASYNC_G(reactor_started))) { + goto close_orig_fd; + } - if (io->base.type == ZEND_ASYNC_IO_TYPE_PIPE || io->base.type == ZEND_ASYNC_IO_TYPE_TTY) { + if (ZEND_ASYNC_IO_IS_STREAM(io->base.type)) { uv_read_stop(&io->handle.stream); - zend_async_callbacks_free(&io->base.event); io->handle.stream.data = io; + ZEND_ASYNC_EVENT_ADD_REF(&io->base.event); uv_close((uv_handle_t *) &io->handle.stream, io_close_cb); + need_close_handle = true; + } else if (io->base.type == ZEND_ASYNC_IO_TYPE_UDP) { + io->handle.udp.data = io; + ZEND_ASYNC_EVENT_ADD_REF(&io->base.event); + uv_close((uv_handle_t *) &io->handle.udp, io_close_cb); + need_close_handle = true; + } + /* FILE type: no uv handle to close. */ - /* Close the original stdio fd that was dup'd in libuv_io_create */ - if (io->orig_fd >= 0) { - close(io->orig_fd); - io->orig_fd = -1; - } +close_orig_fd: + /* Close the original stdio fd that was dup'd in libuv_io_create, + * unless PRESERVE_FD is set (e.g. stdout/stderr kept open for shutdown output). */ + if (io->orig_fd >= 0 && !(io->base.state & ZEND_ASYNC_IO_PRESERVE_FD)) { +#ifdef PHP_WIN32 + _close(io->orig_fd); +#else + close(io->orig_fd); +#endif + } + io->orig_fd = -1; - return 1; + if (need_close_handle && false == ZEND_ASYNC_IS_SCHEDULER_CONTEXT) { + ZEND_ASYNC_SCHEDULER_CONTEXT = true; + libuv_reactor_execute(true); + ZEND_ASYNC_SCHEDULER_CONTEXT = false; } - /* FILE: libuv does not own the fd */ - zend_async_callbacks_free(&io->base.event); - pefree(io, 0); - return 0; + return true; } /* }}} */ @@ -3800,7 +3960,7 @@ static zend_async_io_req_t *libuv_io_flush(zend_async_io_t *io_base) } /* Pipes and TTYs have no disk buffer to flush — return instant success */ - if (io->base.type == ZEND_ASYNC_IO_TYPE_PIPE || io->base.type == ZEND_ASYNC_IO_TYPE_TTY) { + if (ZEND_ASYNC_IO_IS_STREAM(io->base.type)) { async_io_req_t *req = pecalloc(1, sizeof(async_io_req_t), 0); req->base.dispose = libuv_io_req_dispose; req->io = io; @@ -3822,20 +3982,34 @@ static zend_async_io_req_t *libuv_io_flush(zend_async_io_t *io_base) return NULL; } + ZEND_ASYNC_INCREASE_EVENT_COUNT(&io->base.event); return &req->base; } /* }}} */ /* {{{ libuv_io_seek */ -static void libuv_io_seek(zend_async_io_t *io_base, const zend_off_t offset) +static zend_off_t libuv_io_seek(zend_async_io_t *io_base, const zend_off_t offset, const int whence) { async_io_t *io = (async_io_t *) io_base; - if (io->base.type == ZEND_ASYNC_IO_TYPE_FILE) { - io->handle.file.offset = offset; - io->base.state &= ~ZEND_ASYNC_IO_EOF; + if (io->base.type != ZEND_ASYNC_IO_TYPE_FILE) { + return (zend_off_t)-1; } + + io->base.state &= ~ZEND_ASYNC_IO_EOF; + + const zend_off_t result = zend_lseek(io->crt_fd, offset, whence); + + /* For append-mode files the write offset is managed exclusively + * by write completions (always EOF); seeks only reposition reads. + * Updating the write offset here would corrupt subsequent appends + * (e.g. after fseek(0) the next write would go to position 0). */ + if (EXPECTED(!(io->base.state & ZEND_ASYNC_IO_APPEND))) { + io->handle.file.offset = result; + } + + return result; } /* }}} */ @@ -3878,7 +4052,7 @@ static zend_async_io_req_t *libuv_io_stat(zend_async_io_t *io_base, zend_stat_t } /* Pipes and TTYs: synchronous fstat — return instant result */ - if (io->base.type == ZEND_ASYNC_IO_TYPE_PIPE || io->base.type == ZEND_ASYNC_IO_TYPE_TTY) { + if (ZEND_ASYNC_IO_IS_STREAM(io->base.type)) { async_io_req_t *req = pecalloc(1, sizeof(async_io_req_t), 0); req->base.dispose = libuv_io_req_dispose; req->io = io; @@ -3905,6 +4079,7 @@ static zend_async_io_req_t *libuv_io_stat(zend_async_io_t *io_base, zend_stat_t return NULL; } + ZEND_ASYNC_INCREASE_EVENT_COUNT(&io->base.event); return &req->base; } @@ -4356,6 +4531,7 @@ void async_libuv_reactor_register(void) false, libuv_reactor_startup, libuv_reactor_shutdown, + libuv_reactor_detach_io, libuv_reactor_execute, libuv_reactor_loop_alive, libuv_new_socket_event, diff --git a/libuv_reactor.h b/libuv_reactor.h index 391934df..eb371e1f 100644 --- a/libuv_reactor.h +++ b/libuv_reactor.h @@ -105,13 +105,13 @@ struct _async_exec_event_t uv_pipe_t *stdout_pipe; uv_pipe_t *stderr_pipe; uv_process_options_t options; + /* Coroutine that initiated the exec — needed for PHPWRITE_CORO + * so that PASSTHRU/SYSTEM output goes through the correct OB stack. */ + zend_coroutine_t *coroutine; /* Line parser state: pending incomplete line between chunks. */ char *line_buf; size_t line_buf_len; /* bytes used */ size_t line_buf_cap; /* allocated capacity */ -#ifdef PHP_WIN32 - char *quoted_cmd; -#endif }; struct _async_trigger_event_t @@ -153,6 +153,7 @@ struct _async_io_req_t zend_async_io_req_t base; async_io_t *io; size_t max_size; + bool buf_owned; union { diff --git a/php_async.h b/php_async.h index 61532e1d..e2a4bc8a 100644 --- a/php_async.h +++ b/php_async.h @@ -99,6 +99,7 @@ circular_buffer_t fiber_context_pool; /* The reactor */ uv_loop_t uvloop; bool reactor_started; +HashTable active_io_handles; /* tracks all IO handles issued by the reactor */ /* Global signal management for all platforms */ HashTable *signal_handlers; /* signum -> uv_signal_t* */ diff --git a/run-tests.sh b/run-tests.sh index 27f69edd..ad766b68 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -16,4 +16,4 @@ else TEST_PATH="$BASE_PATH/$1" fi -"$PHP_EXECUTABLE" "$RUN_TESTS_PATH" --show-diff -m -p "$PHP_EXECUTABLE" "$TEST_PATH" +"$PHP_EXECUTABLE" "$RUN_TESTS_PATH" --show-diff -p "$PHP_EXECUTABLE" "$TEST_PATH" diff --git a/scheduler.c b/scheduler.c index 0d4a33a7..a086d989 100644 --- a/scheduler.c +++ b/scheduler.c @@ -307,6 +307,16 @@ static zend_always_inline bool switch_to_scheduler(zend_fiber_transfer *transfer } } +static zend_always_inline bool switch_to_scheduler_with_bailout(void) +{ + async_coroutine_t *async_coroutine = (async_coroutine_t *) ZEND_ASYNC_SCHEDULER; + ZEND_ASSERT(async_coroutine != NULL && "Scheduler coroutine is not initialized"); + + fiber_context_update_before_suspend(); + ZEND_ASYNC_CURRENT_COROUTINE = &async_coroutine->coroutine; + return fiber_switch_context_ex(async_coroutine, ZEND_FIBER_FLAG_BAILOUT); +} + static zend_always_inline void return_to_main(zend_fiber_transfer *transfer) { transfer->context = ASYNC_G(main_transfer)->context; @@ -742,6 +752,8 @@ static void cancel_queued_coroutines(void) zend_object *cancellation_exception = async_new_exception(async_ce_cancellation_exception, "Graceful shutdown"); + ZEND_ASYNC_SCHEDULER_CONTEXT = true; + ZEND_HASH_FOREACH_VAL(&ASYNC_G(coroutines), current) { zend_coroutine_t *coroutine = Z_PTR_P(current); @@ -767,7 +779,9 @@ static void cancel_queued_coroutines(void) } ZEND_HASH_FOREACH_END(); + ZEND_ASYNC_SCHEDULER_CONTEXT = false; OBJ_RELEASE(cancellation_exception); + process_resumed_coroutines(); zend_exception_restore_fast(exception, prev_exception); } @@ -922,7 +936,6 @@ static void async_scheduler_dtor(void) zend_hash_destroy(&ASYNC_G(coroutines)); zend_hash_init(&ASYNC_G(coroutines), 0, NULL, NULL, 0); - ZEND_ASYNC_GRACEFUL_SHUTDOWN = false; ZEND_ASYNC_SCHEDULER_CONTEXT = false; } @@ -984,6 +997,8 @@ bool async_scheduler_launch(void) fiber_pool_init(); + ZEND_ASYNC_GRACEFUL_SHUTDOWN = false; + if (UNEXPECTED(EG(exception) != NULL)) { return false; } @@ -1160,7 +1175,12 @@ bool async_scheduler_main_coroutine_suspend(const bool with_bailout) // Destroy main Fiber context. efree(async_fiber_context); - switch_to_scheduler(NULL); + if (UNEXPECTED(with_bailout)) { + start_graceful_shutdown(); + switch_to_scheduler_with_bailout(); + } else { + switch_to_scheduler(NULL); + } } zend_catch { @@ -1170,7 +1190,7 @@ bool async_scheduler_main_coroutine_suspend(const bool with_bailout) ZEND_ASYNC_CURRENT_COROUTINE = NULL; ZEND_ASSERT(ZEND_ASYNC_ACTIVE_COROUTINE_COUNT == 0 && "The active coroutine counter must be 0 at this point"); - ZEND_ASYNC_DEACTIVATE; + ZEND_ASYNC_INITIALIZE; if (ASYNC_G(main_transfer)) { efree(ASYNC_G(main_transfer)); @@ -1226,6 +1246,8 @@ bool async_scheduler_coroutine_enqueue(zend_coroutine_t *coroutine) ZEND_ASSERT(coroutine != NULL && "The current coroutine must be initialized"); } + ZEND_ASSERT(coroutine != ZEND_ASYNC_SCHEDULER && "A Scheduler coroutine cannot be enqueued"); + // If the transfer is NULL, it means that the coroutine is being resumed // That's why we're adding it to the queue. // coroutine->waker->status != ZEND_ASYNC_WAKER_QUEUED means not need to add to queue twice @@ -1302,8 +1324,10 @@ static zend_always_inline bool scheduler_next_tick(void) { zend_fiber_transfer *transfer = NULL; bool *in_scheduler_context = &ZEND_ASYNC_SCHEDULER_CONTEXT; + zend_coroutine_t **acting_coroutine = &ZEND_ASYNC_ACTING_COROUTINE; *in_scheduler_context = true; + *acting_coroutine = NULL; zend_object **exception_ptr = &EG(exception); zend_object *prev_exception = NULL; @@ -1402,6 +1426,11 @@ bool async_scheduler_coroutine_suspend(void) } } + if (UNEXPECTED(ZEND_ASYNC_IS_SCHEDULER_CONTEXT)) { + async_throw_error("A coroutine cannot be stopped from the Scheduler context"); + return false; + } + zend_coroutine_t *coroutine = ZEND_ASYNC_CURRENT_COROUTINE; // @@ -1597,12 +1626,15 @@ ZEND_STACK_ALIGNED void fiber_entry(zend_fiber_transfer *transfer) const circular_buffer_t *coroutine_queue = &ASYNC_G(coroutine_queue); circular_buffer_t *resumed_coroutines = &ASYNC_G(resumed_coroutines); + zend_coroutine_t **acting_coroutine = &ZEND_ASYNC_ACTING_COROUTINE; + bool *is_graceful_shutdown = &ZEND_ASYNC_GRACEFUL_SHUTDOWN; do { TRY_HANDLE_EXCEPTION(); *in_scheduler_context = true; + *acting_coroutine = NULL; ZEND_ASSERT(circular_buffer_is_not_empty(resumed_coroutines) == 0 && "resumed_coroutines should be 0"); @@ -1681,6 +1713,22 @@ ZEND_STACK_ALIGNED void fiber_entry(zend_fiber_transfer *transfer) break; } +#ifdef PHP_DEBUG + /** + * This code is intended to stop the EventLoop in case of a bailout situation or when shutdown has started. + * In the case of a bailout, a situation is possible + * where descriptors remain in the reactor that were not properly removed. + */ + if (UNEXPECTED(*is_graceful_shutdown && + *heartbeat_handler == NULL && + circular_buffer_is_empty(&ASYNC_G(microtasks)) && + zend_hash_num_elements(&ASYNC_G(coroutines)) == 0 && + circular_buffer_is_empty(coroutine_queue) + )) + { + break; + } +#endif } while (zend_hash_num_elements(&ASYNC_G(coroutines)) > 0 || circular_buffer_is_not_empty(&ASYNC_G(microtasks)) || ZEND_ASYNC_REACTOR_LOOP_ALIVE()); diff --git a/scope.c b/scope.c index b17f6159..55411f14 100644 --- a/scope.c +++ b/scope.c @@ -1081,6 +1081,7 @@ static bool scope_dispose(zend_async_event_t *scope_event) if (ZEND_ASYNC_EVENT_REFCOUNT(scope_event) > 1) { ZEND_ASYNC_EVENT_DEL_REF(scope_event); ZEND_ASYNC_CALLBACKS_NOTIFY(scope_event, NULL, NULL); + zend_async_callbacks_free(&scope->scope.event); return true; } @@ -1090,8 +1091,19 @@ static bool scope_dispose(zend_async_event_t *scope_event) ZEND_ASYNC_SCOPE_SET_DISPOSING(&scope->scope); - ZEND_ASSERT(scope->coroutines.length == 0 && scope->scope.scopes.length == 0 && - "Scope should be empty before disposal"); + ZEND_ASSERT(scope->coroutines.length == 0 && "Scope should be empty before disposal"); + + // Dispose all child scopes + for (uint32_t i = 0; i < scope->scope.scopes.length; ++i) { + async_scope_t *child_scope = (async_scope_t *) scope->scope.scopes.data[i]; + // We are breaking the link between the parent and the child; it is no longer needed. + child_scope->scope.parent_scope = NULL; + child_scope->scope.event.dispose(&child_scope->scope.event); + if (UNEXPECTED(EG(exception))) { + ZEND_ASYNC_SCOPE_CLR_DISPOSING(&scope->scope); + return false; + } + } zend_object *critical_exception = NULL; diff --git a/tests/common/http_server.php b/tests/common/http_server.php index b0daeab6..82688e01 100644 --- a/tests/common/http_server.php +++ b/tests/common/http_server.php @@ -308,6 +308,15 @@ function route_test_request($method, $path, $full_request) { } else { return http_test_response(405, "Method Not Allowed"); } + + case '/put': + if ($method === 'PUT') { + $body_start = strpos($full_request, "\r\n\r\n"); + $body = $body_start !== false ? substr($full_request, $body_start + 4) : ''; + return http_test_response(200, "PUT received: " . strlen($body) . " bytes"); + } else { + return http_test_response(405, "Method Not Allowed"); + } case '/headers': // Return request headers as response diff --git a/tests/common/test_router.php b/tests/common/test_router.php index 72ae6a4a..bf578c9a 100644 --- a/tests/common/test_router.php +++ b/tests/common/test_router.php @@ -196,6 +196,34 @@ } break; + case '/put': + if ($method === 'PUT') { + $body = file_get_contents('php://input'); + header('Content-Type: text/plain'); + echo "PUT received: " . strlen($body) . " bytes"; + } else { + http_response_code(405); + header('Allow: PUT'); + echo "Method Not Allowed"; + } + break; + + case '/upload': + if ($method === 'POST' && !empty($_FILES)) { + header('Content-Type: text/plain'); + $file = $_FILES['file'] ?? null; + if ($file) { + echo "{$file['name']}|{$file['type']}|{$file['size']}"; + } else { + echo "No file received"; + } + } else { + http_response_code(400); + header('Content-Type: text/plain'); + echo "Expected POST with file upload"; + } + break; + default: http_response_code(404); header('Content-Type: text/html; charset=UTF-8'); diff --git a/tests/context/006-root_context_basic.phpt b/tests/context/006-root_context_basic.phpt new file mode 100644 index 00000000..af00ff46 --- /dev/null +++ b/tests/context/006-root_context_basic.phpt @@ -0,0 +1,18 @@ +--TEST-- +root_context() basic usage and no memory leak +--FILE-- + +--EXPECT-- +bool(true) +bool(true) +done diff --git a/tests/curl/011-curl_file_upload.phpt b/tests/curl/011-curl_file_upload.phpt new file mode 100644 index 00000000..36b6d270 --- /dev/null +++ b/tests/curl/011-curl_file_upload.phpt @@ -0,0 +1,50 @@ +--TEST-- +curl_exec with CURLFile upload crashes with fiber assertion +--EXTENSIONS-- +curl +--FILE-- +port}\n"; + +// Create a test file +$tmpfile = tempnam(sys_get_temp_dir(), 'curl_upload_test_'); +file_put_contents($tmpfile, 'hello world'); + +$coroutine = spawn(function() use ($server, $tmpfile) { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, "http://localhost:{$server->port}/upload"); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_setopt($ch, CURLOPT_SAFE_UPLOAD, true); + + $file = curl_file_create($tmpfile, 'text/plain', 'test.txt'); + curl_setopt($ch, CURLOPT_POSTFIELDS, ['file' => $file]); + + echo "Before curl_exec\n"; + $response = curl_exec($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + echo "HTTP Code: $http_code\n"; + echo "Error: " . ($error ?: "none") . "\n"; + echo "Response: $response\n"; +}); + +await($coroutine); + +@unlink($tmpfile); +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +Server started on localhost:%d +Before curl_exec +HTTP Code: 200 +Error: none +Response: %s +Done diff --git a/tests/curl/012-write_file_basic.phpt b/tests/curl/012-write_file_basic.phpt new file mode 100644 index 00000000..55e91d92 --- /dev/null +++ b/tests/curl/012-write_file_basic.phpt @@ -0,0 +1,52 @@ +--TEST-- +Async curl_write: CURLOPT_FILE downloads response to file +--EXTENSIONS-- +curl +--FILE-- +port}/"); + curl_setopt($ch, CURLOPT_FILE, $fp); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + + $result = curl_exec($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + + unset($ch); + fclose($fp); + + echo "curl_exec returned: " . ($result ? "true" : "false") . "\n"; + echo "HTTP Code: $http_code\n"; + echo "Error: " . ($error ?: "none") . "\n"; +}); + +await($coroutine); + +$contents = file_get_contents($tmpfile); +echo "File contents: $contents\n"; +echo "File size: " . strlen($contents) . "\n"; + +@unlink($tmpfile); +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +curl_exec returned: true +HTTP Code: 200 +Error: none +File contents: Hello World +File size: 11 +Done diff --git a/tests/curl/013-write_file_large.phpt b/tests/curl/013-write_file_large.phpt new file mode 100644 index 00000000..56106b83 --- /dev/null +++ b/tests/curl/013-write_file_large.phpt @@ -0,0 +1,53 @@ +--TEST-- +Async curl_write: CURLOPT_FILE with large response +--EXTENSIONS-- +curl +--FILE-- +port}/large"); + curl_setopt($ch, CURLOPT_FILE, $fp); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + + $result = curl_exec($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + + unset($ch); + fclose($fp); + + echo "curl_exec returned: " . ($result ? "true" : "false") . "\n"; + echo "HTTP Code: $http_code\n"; + echo "Error: " . ($error ?: "none") . "\n"; +}); + +await($coroutine); + +$contents = file_get_contents($tmpfile); +$expected = str_repeat("ABCDEFGHIJ", 1000); +echo "File size: " . strlen($contents) . "\n"; +echo "Content matches: " . ($contents === $expected ? "yes" : "no") . "\n"; + +@unlink($tmpfile); +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +curl_exec returned: true +HTTP Code: 200 +Error: none +File size: 10000 +Content matches: yes +Done diff --git a/tests/curl/014-write_user_basic.phpt b/tests/curl/014-write_user_basic.phpt new file mode 100644 index 00000000..164a0d56 --- /dev/null +++ b/tests/curl/014-write_user_basic.phpt @@ -0,0 +1,47 @@ +--TEST-- +Async curl_write: CURLOPT_WRITEFUNCTION receives data in PHP callback +--EXTENSIONS-- +curl +--FILE-- +port}/"); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$received) { + $received .= $data; + return strlen($data); + }); + + $result = curl_exec($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + + unset($ch); + + echo "curl_exec returned: " . ($result ? "true" : "false") . "\n"; + echo "HTTP Code: $http_code\n"; + echo "Error: " . ($error ?: "none") . "\n"; + echo "Received: $received\n"; +}); + +await($coroutine); + +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +curl_exec returned: true +HTTP Code: 200 +Error: none +Received: Hello World +Done diff --git a/tests/curl/015-write_user_large.phpt b/tests/curl/015-write_user_large.phpt new file mode 100644 index 00000000..8aff38e2 --- /dev/null +++ b/tests/curl/015-write_user_large.phpt @@ -0,0 +1,52 @@ +--TEST-- +Async curl_write: CURLOPT_WRITEFUNCTION with large response and multiple callback invocations +--EXTENSIONS-- +curl +--FILE-- +port}/large"); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$received, &$call_count) { + $call_count++; + $received .= $data; + return strlen($data); + }); + + $result = curl_exec($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + unset($ch); + + echo "curl_exec returned: " . ($result ? "true" : "false") . "\n"; + echo "HTTP Code: $http_code\n"; + echo "Callback invocations: " . ($call_count >= 1 ? "at least 1" : "0") . "\n"; + echo "Total received: " . strlen($received) . "\n"; + + $expected = str_repeat("ABCDEFGHIJ", 1000); + echo "Content matches: " . ($received === $expected ? "yes" : "no") . "\n"; +}); + +await($coroutine); + +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +curl_exec returned: true +HTTP Code: 200 +Callback invocations: at least 1 +Total received: 10000 +Content matches: yes +Done diff --git a/tests/curl/016-write_user_return_value.phpt b/tests/curl/016-write_user_return_value.phpt new file mode 100644 index 00000000..75d57cca --- /dev/null +++ b/tests/curl/016-write_user_return_value.phpt @@ -0,0 +1,72 @@ +--TEST-- +Async curl_write: CURLOPT_WRITEFUNCTION return value controls transfer +--EXTENSIONS-- +curl +--FILE-- +port}/large"); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) { + // Return 0 to abort transfer + return 0; + }); + + $result = curl_exec($ch); + $errno = curl_errno($ch); + + unset($ch); + + echo "Abort test: curl_exec returned: " . ($result ? "true" : "false") . "\n"; + echo "Abort test: errno: $errno\n"; + // CURLE_WRITE_ERROR = 23 + echo "Abort test: is write error: " . ($errno === 23 ? "yes" : "no") . "\n"; +}); + +await($coroutine); + +// Test 2: returning exact data length succeeds +$coroutine2 = spawn(function() use ($server) { + $total = 0; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, "http://localhost:{$server->port}/"); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$total) { + $len = strlen($data); + $total += $len; + return $len; + }); + + $result = curl_exec($ch); + $errno = curl_errno($ch); + + unset($ch); + + echo "Accept test: curl_exec returned: " . ($result ? "true" : "false") . "\n"; + echo "Accept test: errno: $errno\n"; + echo "Accept test: total bytes: $total\n"; +}); + +await($coroutine2); + +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +Abort test: curl_exec returned: false +Abort test: errno: 23 +Abort test: is write error: yes +Accept test: curl_exec returned: true +Accept test: errno: 0 +Accept test: total bytes: 11 +Done diff --git a/tests/curl/017-write_user_async_io.phpt b/tests/curl/017-write_user_async_io.phpt new file mode 100644 index 00000000..21e38e98 --- /dev/null +++ b/tests/curl/017-write_user_async_io.phpt @@ -0,0 +1,55 @@ +--TEST-- +Async curl_write: CURLOPT_WRITEFUNCTION callback performs async I/O (file write inside callback) +--EXTENSIONS-- +curl +--FILE-- +port}/large"); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use ($fp) { + // Perform file I/O inside the callback — this works because + // the callback runs inside a coroutine in async mode + $written = fwrite($fp, $data); + return $written; + }); + + $result = curl_exec($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + unset($ch); + fclose($fp); + + echo "curl_exec returned: " . ($result ? "true" : "false") . "\n"; + echo "HTTP Code: $http_code\n"; +}); + +await($coroutine); + +$contents = file_get_contents($tmpfile); +$expected = str_repeat("ABCDEFGHIJ", 1000); +echo "File size: " . strlen($contents) . "\n"; +echo "Content matches: " . ($contents === $expected ? "yes" : "no") . "\n"; + +@unlink($tmpfile); +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +curl_exec returned: true +HTTP Code: 200 +File size: 10000 +Content matches: yes +Done diff --git a/tests/curl/018-write_file_concurrent.phpt b/tests/curl/018-write_file_concurrent.phpt new file mode 100644 index 00000000..0d132155 --- /dev/null +++ b/tests/curl/018-write_file_concurrent.phpt @@ -0,0 +1,68 @@ +--TEST-- +Async curl_write: multiple parallel curl_exec with CURLOPT_FILE +--EXTENSIONS-- +curl +--FILE-- +port}/large"); + curl_setopt($ch, CURLOPT_FILE, $fp); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + + $result = curl_exec($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + unset($ch); + fclose($fp); + + return ['id' => $id, 'result' => $result, 'http_code' => $http_code]; + }); +} + +[$results, $exceptions] = await_all($coroutines); + +// Sort by id for deterministic output +usort($results, fn($a, $b) => $a['id'] - $b['id']); + +$expected = str_repeat("ABCDEFGHIJ", 1000); + +foreach ($results as $r) { + $contents = file_get_contents($tmpfiles[$r['id']]); + echo "Request {$r['id']}: HTTP {$r['http_code']}, "; + echo "size=" . strlen($contents) . ", "; + echo "match=" . ($contents === $expected ? "yes" : "no") . "\n"; +} + +echo "Exceptions: " . count(array_filter($exceptions)) . "\n"; + +foreach ($tmpfiles as $f) { + @unlink($f); +} + +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +Request 1: HTTP 200, size=10000, match=yes +Request 2: HTTP 200, size=10000, match=yes +Request 3: HTTP 200, size=10000, match=yes +Exceptions: 0 +Done diff --git a/tests/curl/019-write_user_concurrent.phpt b/tests/curl/019-write_user_concurrent.phpt new file mode 100644 index 00000000..40a2e567 --- /dev/null +++ b/tests/curl/019-write_user_concurrent.phpt @@ -0,0 +1,59 @@ +--TEST-- +Async curl_write: multiple parallel curl_exec with CURLOPT_WRITEFUNCTION +--EXTENSIONS-- +curl +--FILE-- +port}/large"); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$received) { + $received .= $data; + return strlen($data); + }); + + $result = curl_exec($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + unset($ch); + + return ['id' => $id, 'http_code' => $http_code, 'size' => strlen($received), 'data' => $received]; + }); +} + +[$results, $exceptions] = await_all($coroutines); + +usort($results, fn($a, $b) => $a['id'] - $b['id']); + +$expected = str_repeat("ABCDEFGHIJ", 1000); + +foreach ($results as $r) { + echo "Request {$r['id']}: HTTP {$r['http_code']}, "; + echo "size={$r['size']}, "; + echo "match=" . ($r['data'] === $expected ? "yes" : "no") . "\n"; +} + +echo "Exceptions: " . count(array_filter($exceptions)) . "\n"; + +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +Request 1: HTTP 200, size=10000, match=yes +Request 2: HTTP 200, size=10000, match=yes +Request 3: HTTP 200, size=10000, match=yes +Exceptions: 0 +Done diff --git a/tests/curl/020-write_mixed_concurrent.phpt b/tests/curl/020-write_mixed_concurrent.phpt new file mode 100644 index 00000000..9e4bed26 --- /dev/null +++ b/tests/curl/020-write_mixed_concurrent.phpt @@ -0,0 +1,94 @@ +--TEST-- +Async curl_write: mixed CURLOPT_FILE and CURLOPT_WRITEFUNCTION in parallel +--EXTENSIONS-- +curl +--FILE-- +port}/large"); + curl_setopt($ch, CURLOPT_FILE, $fp); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + + curl_exec($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + unset($ch); + fclose($fp); + + return ['mode' => 'FILE', 'http_code' => $http_code]; +}); + +// Coroutine 2: CURLOPT_WRITEFUNCTION +$c2 = spawn(function() use ($server) { + $received = ''; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, "http://localhost:{$server->port}/large"); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$received) { + $received .= $data; + return strlen($data); + }); + + curl_exec($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + unset($ch); + + return ['mode' => 'USER', 'http_code' => $http_code, 'size' => strlen($received), 'data' => $received]; +}); + +// Coroutine 3: CURLOPT_RETURNTRANSFER (for comparison) +$c3 = spawn(function() use ($server) { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, "http://localhost:{$server->port}/large"); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + + $response = curl_exec($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + unset($ch); + + return ['mode' => 'RETURN', 'http_code' => $http_code, 'size' => strlen($response), 'data' => $response]; +}); + +[$results, $exceptions] = await_all([$c1, $c2, $c3]); + +$expected = str_repeat("ABCDEFGHIJ", 1000); + +// FILE result +$file_contents = file_get_contents($tmpfile); +echo "FILE: HTTP {$results[0]['http_code']}, size=" . strlen($file_contents) . ", match=" . ($file_contents === $expected ? "yes" : "no") . "\n"; + +// USER result +echo "USER: HTTP {$results[1]['http_code']}, size={$results[1]['size']}, match=" . ($results[1]['data'] === $expected ? "yes" : "no") . "\n"; + +// RETURN result +echo "RETURN: HTTP {$results[2]['http_code']}, size={$results[2]['size']}, match=" . ($results[2]['data'] === $expected ? "yes" : "no") . "\n"; + +echo "Exceptions: " . count(array_filter($exceptions)) . "\n"; + +@unlink($tmpfile); +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +FILE: HTTP 200, size=10000, match=yes +USER: HTTP 200, size=10000, match=yes +RETURN: HTTP 200, size=10000, match=yes +Exceptions: 0 +Done diff --git a/tests/curl/021-write_user_json.phpt b/tests/curl/021-write_user_json.phpt new file mode 100644 index 00000000..dca9d1a0 --- /dev/null +++ b/tests/curl/021-write_user_json.phpt @@ -0,0 +1,51 @@ +--TEST-- +Async curl_write: CURLOPT_WRITEFUNCTION with JSON response +--EXTENSIONS-- +curl +--FILE-- +port}/json"); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$chunks) { + $chunks[] = $data; + return strlen($data); + }); + + $result = curl_exec($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + unset($ch); + + $body = implode('', $chunks); + $decoded = json_decode($body, true); + + echo "curl_exec returned: " . ($result ? "true" : "false") . "\n"; + echo "HTTP Code: $http_code\n"; + echo "Chunk count: " . count($chunks) . "\n"; + echo "Message: {$decoded['message']}\n"; + echo "Status: {$decoded['status']}\n"; +}); + +await($coroutine); + +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +curl_exec returned: true +HTTP Code: 200 +Chunk count: %d +Message: Hello JSON +Status: ok +Done diff --git a/tests/curl/022-write_file_json.phpt b/tests/curl/022-write_file_json.phpt new file mode 100644 index 00000000..09fa3216 --- /dev/null +++ b/tests/curl/022-write_file_json.phpt @@ -0,0 +1,50 @@ +--TEST-- +Async curl_write: CURLOPT_FILE with JSON response written to file +--EXTENSIONS-- +curl +--FILE-- +port}/json"); + curl_setopt($ch, CURLOPT_FILE, $fp); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + + $result = curl_exec($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + unset($ch); + fclose($fp); + + $contents = file_get_contents($tmpfile); + $decoded = json_decode($contents, true); + + echo "curl_exec returned: " . ($result ? "true" : "false") . "\n"; + echo "HTTP Code: $http_code\n"; + echo "Message: {$decoded['message']}\n"; + echo "Status: {$decoded['status']}\n"; +}); + +await($coroutine); + +@unlink($tmpfile); +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +curl_exec returned: true +HTTP Code: 200 +Message: Hello JSON +Status: ok +Done diff --git a/tests/curl/023-write_user_exception.phpt b/tests/curl/023-write_user_exception.phpt new file mode 100644 index 00000000..0aa1ba50 --- /dev/null +++ b/tests/curl/023-write_user_exception.phpt @@ -0,0 +1,37 @@ +--TEST-- +Async curl_write: CURLOPT_WRITEFUNCTION callback throws exception +--EXTENSIONS-- +curl +--FILE-- +port}/"); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) { + throw new RuntimeException("callback error"); + }); + + try { + curl_exec($ch); + echo "no exception\n"; + } catch (RuntimeException $e) { + echo "caught: " . $e->getMessage() . "\n"; + } +}); + +await($coroutine); + +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +caught: callback error +Done diff --git a/tests/curl/024-upload_nonexistent_file.phpt b/tests/curl/024-upload_nonexistent_file.phpt new file mode 100644 index 00000000..5b9ccbf5 --- /dev/null +++ b/tests/curl/024-upload_nonexistent_file.phpt @@ -0,0 +1,47 @@ +--TEST-- +Async curl: CURLFile upload of nonexistent file returns error +--EXTENSIONS-- +curl +--FILE-- +port}/upload"); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_setopt($ch, CURLOPT_SAFE_UPLOAD, true); + + // Use a file that definitely does not exist + $nonexistent = sys_get_temp_dir() . '/curl_upload_NONEXISTENT_' . uniqid() . '.txt'; + $file = curl_file_create($nonexistent, 'text/plain', 'ghost.txt'); + curl_setopt($ch, CURLOPT_POSTFIELDS, ['file' => $file]); + + $result = curl_exec($ch); + $errno = curl_errno($ch); + $error = curl_error($ch); + + echo "curl_exec returned: " . var_export($result, true) . "\n"; + echo "errno: $errno\n"; + // CURLE_READ_ERROR = 26 or CURLE_ABORTED_BY_CALLBACK = 42 + echo "has error: " . ($errno !== 0 ? "yes" : "no") . "\n"; + echo "error message: " . ($error ?: "none") . "\n"; +}); + +await($coroutine); + +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +curl_exec returned: false +errno: %d +has error: yes +error message: %s +Done diff --git a/tests/curl/025-write_file_broken_pipe.phpt b/tests/curl/025-write_file_broken_pipe.phpt new file mode 100644 index 00000000..f2f95b53 --- /dev/null +++ b/tests/curl/025-write_file_broken_pipe.phpt @@ -0,0 +1,52 @@ +--TEST-- +Async curl_write: CURLOPT_FILE to broken pipe triggers write error +--SKIPIF-- + +--EXTENSIONS-- +curl +--INI-- +error_reporting=E_ALL & ~E_NOTICE +--FILE-- +port}/large"); + curl_setopt($ch, CURLOPT_FILE, $fp); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + + $result = curl_exec($ch); + $errno = curl_errno($ch); + + fclose($fp); + + echo "curl_exec returned: " . ($result ? "true" : "false") . "\n"; + echo "errno: $errno\n"; + // CURLE_WRITE_ERROR = 23 + echo "is write error: " . ($errno === 23 ? "yes" : "no") . "\n"; +}); + +await($coroutine); + +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +curl_exec returned: false +errno: 23 +is write error: yes +Done diff --git a/tests/curl/026-header_file_basic.phpt b/tests/curl/026-header_file_basic.phpt new file mode 100644 index 00000000..e4d30e60 --- /dev/null +++ b/tests/curl/026-header_file_basic.phpt @@ -0,0 +1,53 @@ +--TEST-- +Async curl: CURLOPT_HEADERFUNCTION with PHP_CURL_FILE saves headers to file +--EXTENSIONS-- +curl +--FILE-- +port}/json"); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_WRITEHEADER, $hfp); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + + $body = curl_exec($ch); + $errno = curl_errno($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + unset($ch); + fclose($hfp); + + echo "curl_exec returned body: " . ($body !== false ? "yes" : "no") . "\n"; + echo "errno: $errno\n"; + echo "HTTP Code: $http_code\n"; +}); + +await($coroutine); + +$headers = file_get_contents($header_file); +echo "Headers contain HTTP: " . (str_contains($headers, 'HTTP/') ? "yes" : "no") . "\n"; +echo "Headers not empty: " . (strlen($headers) > 0 ? "yes" : "no") . "\n"; + +@unlink($header_file); +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +curl_exec returned body: yes +errno: 0 +HTTP Code: 200 +Headers contain HTTP: yes +Headers not empty: yes +Done diff --git a/tests/curl/027-header_user_basic.phpt b/tests/curl/027-header_user_basic.phpt new file mode 100644 index 00000000..6434754d --- /dev/null +++ b/tests/curl/027-header_user_basic.phpt @@ -0,0 +1,60 @@ +--TEST-- +Async curl: CURLOPT_HEADERFUNCTION with user callback collects headers +--EXTENSIONS-- +curl +--FILE-- +port}/json"); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$headers) { + $trimmed = trim($header); + if ($trimmed !== '') { + $headers[] = $trimmed; + } + return strlen($header); + }); + + $body = curl_exec($ch); + $errno = curl_errno($ch); + + unset($ch); + + echo "curl_exec returned body: " . ($body !== false ? "yes" : "no") . "\n"; + echo "errno: $errno\n"; + echo "Headers count > 0: " . (count($headers) > 0 ? "yes" : "no") . "\n"; + echo "Has HTTP status: " . (str_contains($headers[0], 'HTTP/') ? "yes" : "no") . "\n"; + + $has_content_type = false; + foreach ($headers as $h) { + if (str_contains($h, 'Content-Type')) { + $has_content_type = true; + break; + } + } + echo "Has Content-Type: " . ($has_content_type ? "yes" : "no") . "\n"; +}); + +await($coroutine); + +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +curl_exec returned body: yes +errno: 0 +Headers count > 0: yes +Has HTTP status: yes +Has Content-Type: yes +Done diff --git a/tests/curl/028-read_file_basic.phpt b/tests/curl/028-read_file_basic.phpt new file mode 100644 index 00000000..89542a91 --- /dev/null +++ b/tests/curl/028-read_file_basic.phpt @@ -0,0 +1,52 @@ +--TEST-- +Async curl: CURLOPT_INFILE reads file data for PUT request +--EXTENSIONS-- +curl +--FILE-- +port}/put"); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_PUT, true); + curl_setopt($ch, CURLOPT_INFILE, $fp); + curl_setopt($ch, CURLOPT_INFILESIZE, 1000); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + + $result = curl_exec($ch); + $errno = curl_errno($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + unset($ch); + fclose($fp); + + echo "curl_exec returned: " . ($result !== false ? "yes" : "no") . "\n"; + echo "errno: $errno\n"; + echo "HTTP Code: $http_code\n"; + echo "Response: $result\n"; +}); + +await($coroutine); + +@unlink($upload_file); +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +curl_exec returned: yes +errno: 0 +HTTP Code: 200 +Response: PUT received: 1000 bytes +Done diff --git a/tests/curl/029-read_user_basic.phpt b/tests/curl/029-read_user_basic.phpt new file mode 100644 index 00000000..dc365769 --- /dev/null +++ b/tests/curl/029-read_user_basic.phpt @@ -0,0 +1,54 @@ +--TEST-- +Async curl: CURLOPT_READFUNCTION provides data via PHP callback for PUT request +--EXTENSIONS-- +curl +--FILE-- +port}/put"); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_UPLOAD, true); + curl_setopt($ch, CURLOPT_INFILESIZE, strlen($data)); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_setopt($ch, CURLOPT_READFUNCTION, function($ch, $infile, $length) use ($data, &$offset) { + $chunk = substr($data, $offset, $length); + $offset += strlen($chunk); + return $chunk; + }); + + $result = curl_exec($ch); + $errno = curl_errno($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + unset($ch); + + echo "curl_exec returned: " . ($result !== false ? "yes" : "no") . "\n"; + echo "errno: $errno\n"; + echo "HTTP Code: $http_code\n"; + echo "Response: $result\n"; + echo "All data sent: " . ($offset === strlen($data) ? "yes" : "no ($offset)") . "\n"; +}); + +await($coroutine); + +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +curl_exec returned: yes +errno: 0 +HTTP Code: 200 +Response: PUT received: 1000 bytes +All data sent: yes +Done diff --git a/tests/curl/030-read_file_large.phpt b/tests/curl/030-read_file_large.phpt new file mode 100644 index 00000000..f84c7c15 --- /dev/null +++ b/tests/curl/030-read_file_large.phpt @@ -0,0 +1,53 @@ +--TEST-- +Async curl: large file PUT via CURLOPT_INFILE (1MB) +--EXTENSIONS-- +curl +--FILE-- +port}/put"); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_PUT, true); + curl_setopt($ch, CURLOPT_INFILE, $fp); + curl_setopt($ch, CURLOPT_INFILESIZE, $size); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + + $result = curl_exec($ch); + $errno = curl_errno($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + unset($ch); + fclose($fp); + + echo "curl_exec returned: " . ($result !== false ? "yes" : "no") . "\n"; + echo "errno: $errno\n"; + echo "HTTP Code: $http_code\n"; + echo "Response: $result\n"; +}); + +await($coroutine); + +@unlink($upload_file); +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +curl_exec returned: yes +errno: 0 +HTTP Code: 200 +Response: PUT received: 1048576 bytes +Done diff --git a/tests/curl/031-progress_callback.phpt b/tests/curl/031-progress_callback.phpt new file mode 100644 index 00000000..7c458b64 --- /dev/null +++ b/tests/curl/031-progress_callback.phpt @@ -0,0 +1,52 @@ +--TEST-- +Async curl: progress callback works in async context +--EXTENSIONS-- +curl +--FILE-- +port}/large"); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_setopt($ch, CURLOPT_NOPROGRESS, false); + curl_setopt($ch, CURLOPT_XFERINFOFUNCTION, function($ch, $dltotal, $dlnow, $ultotal, $ulnow) use (&$progress_called) { + $progress_called++; + return 0; // continue + }); + + $result = curl_exec($ch); + $errno = curl_errno($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $length = strlen($result); + + unset($ch); + + echo "curl_exec returned: " . ($result !== false ? "yes" : "no") . "\n"; + echo "errno: $errno\n"; + echo "HTTP Code: $http_code\n"; + echo "Response length: $length\n"; + echo "Progress called: " . ($progress_called > 0 ? "yes ($progress_called times)" : "no") . "\n"; +}); + +await($coroutine); + +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +curl_exec returned: yes +errno: 0 +HTTP Code: 200 +Response length: 10000 +Progress called: yes (%d times) +Done diff --git a/tests/curl/032-debug_callback.phpt b/tests/curl/032-debug_callback.phpt new file mode 100644 index 00000000..fa197281 --- /dev/null +++ b/tests/curl/032-debug_callback.phpt @@ -0,0 +1,57 @@ +--TEST-- +Async curl: debug callback works in async context +--EXTENSIONS-- +curl +--FILE-- +port}/"); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_setopt($ch, CURLOPT_VERBOSE, true); + curl_setopt($ch, CURLOPT_DEBUGFUNCTION, function($ch, $type, $data) use (&$debug_types) { + $debug_types[$type] = ($debug_types[$type] ?? 0) + 1; + return 0; + }); + + $result = curl_exec($ch); + $errno = curl_errno($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + unset($ch); + + echo "curl_exec returned: " . ($result !== false ? "yes" : "no") . "\n"; + echo "errno: $errno\n"; + echo "HTTP Code: $http_code\n"; + echo "Response: $result\n"; + echo "Debug called: " . (array_sum($debug_types) > 0 ? "yes" : "no") . "\n"; + echo "Has CURLINFO_TEXT (0): " . (isset($debug_types[CURLINFO_TEXT]) ? "yes" : "no") . "\n"; + echo "Has CURLINFO_HEADER_OUT (2): " . (isset($debug_types[CURLINFO_HEADER_OUT]) ? "yes" : "no") . "\n"; + echo "Has CURLINFO_HEADER_IN (1): " . (isset($debug_types[CURLINFO_HEADER_IN]) ? "yes" : "no") . "\n"; +}); + +await($coroutine); + +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +curl_exec returned: yes +errno: 0 +HTTP Code: 200 +Response: Hello World +Debug called: yes +Has CURLINFO_TEXT (0): yes +Has CURLINFO_HEADER_OUT (2): yes +Has CURLINFO_HEADER_IN (1): yes +Done diff --git a/tests/curl/033-read_user_exception.phpt b/tests/curl/033-read_user_exception.phpt new file mode 100644 index 00000000..1bbb66ea --- /dev/null +++ b/tests/curl/033-read_user_exception.phpt @@ -0,0 +1,47 @@ +--TEST-- +Async curl: exception in CURLOPT_READFUNCTION callback +--EXTENSIONS-- +curl +--FILE-- +port}/put"); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_UPLOAD, true); + curl_setopt($ch, CURLOPT_INFILESIZE, 1000); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_setopt($ch, CURLOPT_READFUNCTION, function($ch, $infile, $length) use (&$call_count) { + $call_count++; + throw new RuntimeException("Read callback error"); + }); + + $result = curl_exec($ch); + $errno = curl_errno($ch); + + echo "curl_exec returned: " . ($result !== false ? "data" : "false") . "\n"; + echo "errno: $errno\n"; + echo "Callback called: " . ($call_count > 0 ? "yes" : "no") . "\n"; +}); + +try { + await($coroutine); +} catch (\Throwable $e) { + echo "Exception: " . $e->getMessage() . "\n"; +} + +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +Exception: Read callback error +Done \ No newline at end of file diff --git a/tests/curl/034-header_user_exception.phpt b/tests/curl/034-header_user_exception.phpt new file mode 100644 index 00000000..37f3ee94 --- /dev/null +++ b/tests/curl/034-header_user_exception.phpt @@ -0,0 +1,43 @@ +--TEST-- +Async curl: exception in CURLOPT_HEADERFUNCTION callback +--EXTENSIONS-- +curl +--FILE-- +port}/"); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) { + if (stripos($header, 'Content-Type') !== false) { + throw new RuntimeException("Header callback error"); + } + return strlen($header); + }); + + $result = curl_exec($ch); + $errno = curl_errno($ch); + + echo "curl_exec returned: " . ($result !== false ? "data" : "false") . "\n"; + echo "errno: $errno\n"; +}); + +try { + await($coroutine); +} catch (\Throwable $e) { + echo "Exception: " . $e->getMessage() . "\n"; +} + +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +%ADone diff --git a/tests/curl/035-progress_exception.phpt b/tests/curl/035-progress_exception.phpt new file mode 100644 index 00000000..193c1674 --- /dev/null +++ b/tests/curl/035-progress_exception.phpt @@ -0,0 +1,47 @@ +--TEST-- +Async curl: exception in CURLOPT_XFERINFOFUNCTION callback +--EXTENSIONS-- +curl +--FILE-- +port}/large"); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_setopt($ch, CURLOPT_NOPROGRESS, false); + curl_setopt($ch, CURLOPT_XFERINFOFUNCTION, function($ch, $dltotal, $dlnow, $ultotal, $ulnow) use (&$call_count) { + $call_count++; + if ($call_count >= 2) { + throw new RuntimeException("Progress callback error"); + } + return 0; + }); + + $result = curl_exec($ch); + $errno = curl_errno($ch); + + echo "curl_exec returned: " . ($result !== false ? "data" : "false") . "\n"; + echo "errno: $errno\n"; +}); + +try { + await($coroutine); +} catch (\Throwable $e) { + echo "Exception: " . $e->getMessage() . "\n"; +} + +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +%ADone diff --git a/tests/curl/036-multi_curlfile_upload.phpt b/tests/curl/036-multi_curlfile_upload.phpt new file mode 100644 index 00000000..ebfa8fdf --- /dev/null +++ b/tests/curl/036-multi_curlfile_upload.phpt @@ -0,0 +1,65 @@ +--TEST-- +Async curl multi: CURLFile upload via curl_multi +--EXTENSIONS-- +curl +--FILE-- +port}/upload"); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_setopt($ch, CURLOPT_SAFE_UPLOAD, true); + + $file = curl_file_create($tmpfile, 'text/plain', 'test.txt'); + curl_setopt($ch, CURLOPT_POSTFIELDS, ['file' => $file]); + + curl_multi_add_handle($mh, $ch); + + $active = null; + do { + $status = curl_multi_exec($mh, $active); + if ($status !== CURLM_OK) { + echo "Multi exec error: " . curl_multi_strerror($status) . "\n"; + break; + } + if ($active > 0) { + curl_multi_select($mh, 1.0); + } + } while ($active > 0); + + $response = curl_multi_getcontent($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + + curl_multi_remove_handle($mh, $ch); + curl_multi_close($mh); + + echo "HTTP Code: $http_code\n"; + echo "Error: " . ($error ?: "none") . "\n"; + echo "Response: $response\n"; +}); + +await($coroutine); + +@unlink($tmpfile); +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +HTTP Code: 200 +Error: none +Response: test.txt|text/plain|22 +Done diff --git a/tests/curl/037-multi_write_user.phpt b/tests/curl/037-multi_write_user.phpt new file mode 100644 index 00000000..9c0308f8 --- /dev/null +++ b/tests/curl/037-multi_write_user.phpt @@ -0,0 +1,62 @@ +--TEST-- +Async curl multi: CURLOPT_WRITEFUNCTION callback in multi mode +--EXTENSIONS-- +curl +--FILE-- +port}/"); + curl_setopt($ch1, CURLOPT_TIMEOUT, 5); + curl_setopt($ch1, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$received1) { + $received1 .= $data; + return strlen($data); + }); + + $ch2 = curl_init(); + curl_setopt($ch2, CURLOPT_URL, "http://localhost:{$server->port}/json"); + curl_setopt($ch2, CURLOPT_TIMEOUT, 5); + curl_setopt($ch2, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$received2) { + $received2 .= $data; + return strlen($data); + }); + + curl_multi_add_handle($mh, $ch1); + curl_multi_add_handle($mh, $ch2); + + $active = null; + do { + $status = curl_multi_exec($mh, $active); + if ($status !== CURLM_OK) break; + if ($active > 0) curl_multi_select($mh, 1.0); + } while ($active > 0); + + curl_multi_remove_handle($mh, $ch1); + curl_multi_remove_handle($mh, $ch2); + curl_multi_close($mh); + + echo "Received 1: $received1\n"; + echo "Received 2: $received2\n"; +}); + +await($coroutine); + +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +Received 1: Hello World +Received 2: {"message":"Hello JSON","status":"ok"} +Done diff --git a/tests/curl/038-multi_write_file.phpt b/tests/curl/038-multi_write_file.phpt new file mode 100644 index 00000000..83abdb24 --- /dev/null +++ b/tests/curl/038-multi_write_file.phpt @@ -0,0 +1,72 @@ +--TEST-- +Async curl multi: CURLOPT_FILE downloads response to file in multi mode +--EXTENSIONS-- +curl +--FILE-- +port}/"); + curl_setopt($ch1, CURLOPT_FILE, $fp1); + curl_setopt($ch1, CURLOPT_TIMEOUT, 5); + + $ch2 = curl_init(); + curl_setopt($ch2, CURLOPT_URL, "http://localhost:{$server->port}/json"); + curl_setopt($ch2, CURLOPT_FILE, $fp2); + curl_setopt($ch2, CURLOPT_TIMEOUT, 5); + + curl_multi_add_handle($mh, $ch1); + curl_multi_add_handle($mh, $ch2); + + $active = null; + do { + $status = curl_multi_exec($mh, $active); + if ($status !== CURLM_OK) break; + if ($active > 0) curl_multi_select($mh, 1.0); + } while ($active > 0); + + $errno1 = curl_errno($ch1); + $errno2 = curl_errno($ch2); + + curl_multi_remove_handle($mh, $ch1); + curl_multi_remove_handle($mh, $ch2); + curl_multi_close($mh); + + fclose($fp1); + fclose($fp2); + + echo "errno1: $errno1\n"; + echo "errno2: $errno2\n"; +}); + +await($coroutine); + +echo "File 1: " . file_get_contents($tmpfile1) . "\n"; +echo "File 2: " . file_get_contents($tmpfile2) . "\n"; + +@unlink($tmpfile1); +@unlink($tmpfile2); +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +errno1: 0 +errno2: 0 +File 1: Hello World +File 2: {"message":"Hello JSON","status":"ok"} +Done diff --git a/tests/curl/039-multi_header_user.phpt b/tests/curl/039-multi_header_user.phpt new file mode 100644 index 00000000..238f044a --- /dev/null +++ b/tests/curl/039-multi_header_user.phpt @@ -0,0 +1,90 @@ +--TEST-- +Async curl multi: CURLOPT_HEADERFUNCTION callback in multi mode +--EXTENSIONS-- +curl +--FILE-- +port}/"); + curl_setopt($ch1, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch1, CURLOPT_TIMEOUT, 5); + curl_setopt($ch1, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$headers1) { + $trimmed = trim($header); + if ($trimmed !== '') { + $headers1[] = $trimmed; + } + return strlen($header); + }); + + $ch2 = curl_init(); + curl_setopt($ch2, CURLOPT_URL, "http://localhost:{$server->port}/json"); + curl_setopt($ch2, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch2, CURLOPT_TIMEOUT, 5); + curl_setopt($ch2, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$headers2) { + $trimmed = trim($header); + if ($trimmed !== '') { + $headers2[] = $trimmed; + } + return strlen($header); + }); + + curl_multi_add_handle($mh, $ch1); + curl_multi_add_handle($mh, $ch2); + + $active = null; + do { + $status = curl_multi_exec($mh, $active); + if ($status !== CURLM_OK) break; + if ($active > 0) curl_multi_select($mh, 1.0); + } while ($active > 0); + + $body1 = curl_multi_getcontent($ch1); + $body2 = curl_multi_getcontent($ch2); + + curl_multi_remove_handle($mh, $ch1); + curl_multi_remove_handle($mh, $ch2); + curl_multi_close($mh); + + echo "Body 1: $body1\n"; + echo "Headers 1 has HTTP: " . (str_contains($headers1[0], 'HTTP/') ? "yes" : "no") . "\n"; + echo "Headers 1 count > 0: " . (count($headers1) > 0 ? "yes" : "no") . "\n"; + + echo "Body 2: $body2\n"; + echo "Headers 2 has HTTP: " . (str_contains($headers2[0], 'HTTP/') ? "yes" : "no") . "\n"; + + $has_json_ct = false; + foreach ($headers2 as $h) { + if (str_contains(strtolower($h), 'content-type') && str_contains($h, 'application/json')) { + $has_json_ct = true; + break; + } + } + echo "Headers 2 has JSON Content-Type: " . ($has_json_ct ? "yes" : "no") . "\n"; +}); + +await($coroutine); + +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +Body 1: Hello World +Headers 1 has HTTP: yes +Headers 1 count > 0: yes +Body 2: {"message":"Hello JSON","status":"ok"} +Headers 2 has HTTP: yes +Headers 2 has JSON Content-Type: yes +Done diff --git a/tests/curl/040-multi_header_file.phpt b/tests/curl/040-multi_header_file.phpt new file mode 100644 index 00000000..84893a6e --- /dev/null +++ b/tests/curl/040-multi_header_file.phpt @@ -0,0 +1,63 @@ +--TEST-- +Async curl multi: CURLOPT_WRITEHEADER saves headers to file in multi mode +--EXTENSIONS-- +curl +--FILE-- +port}/json"); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_WRITEHEADER, $hfp); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + + curl_multi_add_handle($mh, $ch); + + $active = null; + do { + $status = curl_multi_exec($mh, $active); + if ($status !== CURLM_OK) break; + if ($active > 0) curl_multi_select($mh, 1.0); + } while ($active > 0); + + $body = curl_multi_getcontent($ch); + $errno = curl_errno($ch); + + curl_multi_remove_handle($mh, $ch); + curl_multi_close($mh); + + fclose($hfp); + + echo "errno: $errno\n"; + echo "Body: $body\n"; +}); + +await($coroutine); + +$headers = file_get_contents($header_file); +echo "Headers contain HTTP: " . (str_contains($headers, 'HTTP/') ? "yes" : "no") . "\n"; +echo "Headers not empty: " . (strlen($headers) > 0 ? "yes" : "no") . "\n"; + +@unlink($header_file); +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +errno: 0 +Body: {"message":"Hello JSON","status":"ok"} +Headers contain HTTP: yes +Headers not empty: yes +Done diff --git a/tests/curl/041-multi_read_file.phpt b/tests/curl/041-multi_read_file.phpt new file mode 100644 index 00000000..f6e617b6 --- /dev/null +++ b/tests/curl/041-multi_read_file.phpt @@ -0,0 +1,63 @@ +--TEST-- +Async curl multi: CURLOPT_INFILE PUT request via multi +--EXTENSIONS-- +curl +--FILE-- +port}/put"); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_PUT, true); + curl_setopt($ch, CURLOPT_INFILE, $fp); + curl_setopt($ch, CURLOPT_INFILESIZE, 1000); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + + curl_multi_add_handle($mh, $ch); + + $active = null; + do { + $status = curl_multi_exec($mh, $active); + if ($status !== CURLM_OK) break; + if ($active > 0) curl_multi_select($mh, 1.0); + } while ($active > 0); + + $response = curl_multi_getcontent($ch); + $errno = curl_errno($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + curl_multi_remove_handle($mh, $ch); + curl_multi_close($mh); + + fclose($fp); + + echo "errno: $errno\n"; + echo "HTTP Code: $http_code\n"; + echo "Response: $response\n"; +}); + +await($coroutine); + +@unlink($upload_file); +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +errno: 0 +HTTP Code: 200 +Response: PUT received: 1000 bytes +Done diff --git a/tests/curl/042-multi_read_user.phpt b/tests/curl/042-multi_read_user.phpt new file mode 100644 index 00000000..3990ded0 --- /dev/null +++ b/tests/curl/042-multi_read_user.phpt @@ -0,0 +1,64 @@ +--TEST-- +Async curl multi: CURLOPT_READFUNCTION callback via multi +--EXTENSIONS-- +curl +--FILE-- +port}/put"); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_UPLOAD, true); + curl_setopt($ch, CURLOPT_INFILESIZE, strlen($data)); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_setopt($ch, CURLOPT_READFUNCTION, function($ch, $infile, $length) use ($data, &$offset) { + $chunk = substr($data, $offset, $length); + $offset += strlen($chunk); + return $chunk; + }); + + curl_multi_add_handle($mh, $ch); + + $active = null; + do { + $status = curl_multi_exec($mh, $active); + if ($status !== CURLM_OK) break; + if ($active > 0) curl_multi_select($mh, 1.0); + } while ($active > 0); + + $response = curl_multi_getcontent($ch); + $errno = curl_errno($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + curl_multi_remove_handle($mh, $ch); + curl_multi_close($mh); + + echo "errno: $errno\n"; + echo "HTTP Code: $http_code\n"; + echo "Response: $response\n"; + echo "All data sent: " . ($offset === strlen($data) ? "yes" : "no") . "\n"; +}); + +await($coroutine); + +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +errno: 0 +HTTP Code: 200 +Response: PUT received: 1000 bytes +All data sent: yes +Done diff --git a/tests/curl/043-multi_write_user_exception.phpt b/tests/curl/043-multi_write_user_exception.phpt new file mode 100644 index 00000000..c01cb172 --- /dev/null +++ b/tests/curl/043-multi_write_user_exception.phpt @@ -0,0 +1,58 @@ +--TEST-- +Async curl multi: exception in CURLOPT_WRITEFUNCTION propagates to curl_multi_exec +--EXTENSIONS-- +curl +--FILE-- +port}/"); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) { + throw new RuntimeException("multi callback error"); + }); + + curl_multi_add_handle($mh, $ch); + + try { + $active = null; + do { + $status = curl_multi_exec($mh, $active); + if ($status !== CURLM_OK) break; + if ($active > 0) curl_multi_select($mh, 1.0); + } while ($active > 0); + echo "no exception\n"; + } catch (RuntimeException $e) { + echo "caught: " . $e->getMessage() . "\n"; + } + + // curl_multi_info_read may or may not have CURLMSG_DONE here: + // libcurl does not propagate write callback errors to the transfer's + // internal state, so CURLMSG_DONE generation depends on whether the + // server's FIN arrives before curl_multi_socket_action processes the + // timer — a race condition. Use curl_errno() as the reliable check. + $errno = curl_errno($ch); + echo "curl_errno: " . ($errno === CURLE_WRITE_ERROR ? "CURLE_WRITE_ERROR" : "errno=$errno") . "\n"; + + curl_multi_remove_handle($mh, $ch); + curl_multi_close($mh); +}); + +await($coroutine); + +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +caught: multi callback error +curl_errno: CURLE_WRITE_ERROR +Done diff --git a/tests/curl/044-multi_curlfile_concurrent.phpt b/tests/curl/044-multi_curlfile_concurrent.phpt new file mode 100644 index 00000000..bd9bc4bf --- /dev/null +++ b/tests/curl/044-multi_curlfile_concurrent.phpt @@ -0,0 +1,82 @@ +--TEST-- +Async curl multi: multiple concurrent CURLFile uploads +--EXTENSIONS-- +curl +--FILE-- +port}/upload"); + curl_setopt($ch1, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch1, CURLOPT_TIMEOUT, 5); + curl_setopt($ch1, CURLOPT_SAFE_UPLOAD, true); + curl_setopt($ch1, CURLOPT_POSTFIELDS, ['file' => curl_file_create($tmpfile1, 'text/plain', 'one.txt')]); + + $ch2 = curl_init(); + curl_setopt($ch2, CURLOPT_URL, "http://localhost:{$server->port}/upload"); + curl_setopt($ch2, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch2, CURLOPT_TIMEOUT, 5); + curl_setopt($ch2, CURLOPT_SAFE_UPLOAD, true); + curl_setopt($ch2, CURLOPT_POSTFIELDS, ['file' => curl_file_create($tmpfile2, 'text/plain', 'two.txt')]); + + $ch3 = curl_init(); + curl_setopt($ch3, CURLOPT_URL, "http://localhost:{$server->port}/upload"); + curl_setopt($ch3, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch3, CURLOPT_TIMEOUT, 5); + curl_setopt($ch3, CURLOPT_SAFE_UPLOAD, true); + curl_setopt($ch3, CURLOPT_POSTFIELDS, ['file' => curl_file_create($tmpfile3, 'text/plain', 'three.txt')]); + + curl_multi_add_handle($mh, $ch1); + curl_multi_add_handle($mh, $ch2); + curl_multi_add_handle($mh, $ch3); + + $active = null; + do { + $status = curl_multi_exec($mh, $active); + if ($status !== CURLM_OK) break; + if ($active > 0) curl_multi_select($mh, 1.0); + } while ($active > 0); + + $r1 = curl_multi_getcontent($ch1); + $r2 = curl_multi_getcontent($ch2); + $r3 = curl_multi_getcontent($ch3); + + curl_multi_remove_handle($mh, $ch1); + curl_multi_remove_handle($mh, $ch2); + curl_multi_remove_handle($mh, $ch3); + curl_multi_close($mh); + + echo "Upload 1: $r1\n"; + echo "Upload 2: $r2\n"; + echo "Upload 3: $r3\n"; +}); + +await($coroutine); + +@unlink($tmpfile1); +@unlink($tmpfile2); +@unlink($tmpfile3); +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +Upload 1: one.txt|text/plain|16 +Upload 2: two.txt|text/plain|8 +Upload 3: three.txt|text/plain|20 +Done diff --git a/tests/curl/045-multi_mixed_callbacks.phpt b/tests/curl/045-multi_mixed_callbacks.phpt new file mode 100644 index 00000000..89bccff6 --- /dev/null +++ b/tests/curl/045-multi_mixed_callbacks.phpt @@ -0,0 +1,89 @@ +--TEST-- +Async curl multi: mixed callback modes across handles (RETURNTRANSFER + FILE + WRITEFUNCTION) +--EXTENSIONS-- +curl +--FILE-- +port}/"); + curl_setopt($ch1, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch1, CURLOPT_TIMEOUT, 5); + + // Handle 2: CURLOPT_FILE + $ch2 = curl_init(); + curl_setopt($ch2, CURLOPT_URL, "http://localhost:{$server->port}/json"); + curl_setopt($ch2, CURLOPT_FILE, $fp); + curl_setopt($ch2, CURLOPT_TIMEOUT, 5); + + // Handle 3: CURLOPT_WRITEFUNCTION + $ch3 = curl_init(); + curl_setopt($ch3, CURLOPT_URL, "http://localhost:{$server->port}/large"); + curl_setopt($ch3, CURLOPT_TIMEOUT, 5); + curl_setopt($ch3, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$user_data) { + $user_data .= $data; + return strlen($data); + }); + + curl_multi_add_handle($mh, $ch1); + curl_multi_add_handle($mh, $ch2); + curl_multi_add_handle($mh, $ch3); + + $active = null; + do { + $status = curl_multi_exec($mh, $active); + if ($status !== CURLM_OK) break; + if ($active > 0) curl_multi_select($mh, 1.0); + } while ($active > 0); + + $r1 = curl_multi_getcontent($ch1); + $e1 = curl_errno($ch1); + $e2 = curl_errno($ch2); + $e3 = curl_errno($ch3); + + curl_multi_remove_handle($mh, $ch1); + curl_multi_remove_handle($mh, $ch2); + curl_multi_remove_handle($mh, $ch3); + curl_multi_close($mh); + + fclose($fp); + + echo "Handle 1 (RETURNTRANSFER): $r1\n"; + echo "Handle 1 errno: $e1\n"; + echo "Handle 2 errno: $e2\n"; + echo "Handle 3 errno: $e3\n"; + echo "Handle 3 (WRITEFUNCTION) length: " . strlen($user_data) . "\n"; +}); + +await($coroutine); + +$file_content = file_get_contents($tmpfile); +echo "Handle 2 (FILE): $file_content\n"; + +@unlink($tmpfile); +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +Handle 1 (RETURNTRANSFER): Hello World +Handle 1 errno: 0 +Handle 2 errno: 0 +Handle 3 errno: 0 +Handle 3 (WRITEFUNCTION) length: 10000 +Handle 2 (FILE): {"message":"Hello JSON","status":"ok"} +Done diff --git a/tests/curl/046-multi_concurrent_coroutines.phpt b/tests/curl/046-multi_concurrent_coroutines.phpt new file mode 100644 index 00000000..2a115066 --- /dev/null +++ b/tests/curl/046-multi_concurrent_coroutines.phpt @@ -0,0 +1,95 @@ +--TEST-- +Async curl multi: two coroutines each with own curl_multi handle +--EXTENSIONS-- +curl +--FILE-- +port}/"); + curl_setopt($ch1, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch1, CURLOPT_TIMEOUT, 5); + + $ch2 = curl_init(); + curl_setopt($ch2, CURLOPT_URL, "http://localhost:{$server->port}/json"); + curl_setopt($ch2, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch2, CURLOPT_TIMEOUT, 5); + + curl_multi_add_handle($mh, $ch1); + curl_multi_add_handle($mh, $ch2); + + $active = null; + do { + $status = curl_multi_exec($mh, $active); + if ($status !== CURLM_OK) break; + if ($active > 0) curl_multi_select($mh, 1.0); + } while ($active > 0); + + $r1 = curl_multi_getcontent($ch1); + $r2 = curl_multi_getcontent($ch2); + + curl_multi_remove_handle($mh, $ch1); + curl_multi_remove_handle($mh, $ch2); + curl_multi_close($mh); + + return ['coro' => 1, 'r1' => $r1, 'r2' => $r2]; +}); + +$c2 = spawn(function() use ($server) { + $mh = curl_multi_init(); + + $ch1 = curl_init(); + curl_setopt($ch1, CURLOPT_URL, "http://localhost:{$server->port}/large"); + curl_setopt($ch1, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch1, CURLOPT_TIMEOUT, 5); + + $ch2 = curl_init(); + curl_setopt($ch2, CURLOPT_URL, "http://localhost:{$server->port}/"); + curl_setopt($ch2, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch2, CURLOPT_TIMEOUT, 5); + + curl_multi_add_handle($mh, $ch1); + curl_multi_add_handle($mh, $ch2); + + $active = null; + do { + $status = curl_multi_exec($mh, $active); + if ($status !== CURLM_OK) break; + if ($active > 0) curl_multi_select($mh, 1.0); + } while ($active > 0); + + $r1 = curl_multi_getcontent($ch1); + $r2 = curl_multi_getcontent($ch2); + + curl_multi_remove_handle($mh, $ch1); + curl_multi_remove_handle($mh, $ch2); + curl_multi_close($mh); + + return ['coro' => 2, 'r1_len' => strlen($r1), 'r2' => $r2]; +}); + +[$results, $exceptions] = await_all([$c1, $c2]); + +usort($results, fn($a, $b) => $a['coro'] - $b['coro']); + +echo "Coro 1: r1={$results[0]['r1']}, r2={$results[0]['r2']}\n"; +echo "Coro 2: r1_len={$results[1]['r1_len']}, r2={$results[1]['r2']}\n"; +echo "Exceptions: " . count(array_filter($exceptions)) . "\n"; + +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +Coro 1: r1=Hello World, r2={"message":"Hello JSON","status":"ok"} +Coro 2: r1_len=10000, r2=Hello World +Exceptions: 0 +Done diff --git a/tests/curl/047-multi_concurrent_callbacks.phpt b/tests/curl/047-multi_concurrent_callbacks.phpt new file mode 100644 index 00000000..acdcfb60 --- /dev/null +++ b/tests/curl/047-multi_concurrent_callbacks.phpt @@ -0,0 +1,106 @@ +--TEST-- +Async curl multi: two coroutines with different callback modes (WRITEFUNCTION vs FILE) +--EXTENSIONS-- +curl +--FILE-- +port}/"); + curl_setopt($ch1, CURLOPT_TIMEOUT, 5); + curl_setopt($ch1, CURLOPT_WRITEFUNCTION, function($ch, $d) use (&$data1) { + $data1 .= $d; + return strlen($d); + }); + + $ch2 = curl_init(); + curl_setopt($ch2, CURLOPT_URL, "http://localhost:{$server->port}/json"); + curl_setopt($ch2, CURLOPT_TIMEOUT, 5); + curl_setopt($ch2, CURLOPT_WRITEFUNCTION, function($ch, $d) use (&$data2) { + $data2 .= $d; + return strlen($d); + }); + + curl_multi_add_handle($mh, $ch1); + curl_multi_add_handle($mh, $ch2); + + $active = null; + do { + $status = curl_multi_exec($mh, $active); + if ($status !== CURLM_OK) break; + if ($active > 0) curl_multi_select($mh, 1.0); + } while ($active > 0); + + curl_multi_remove_handle($mh, $ch1); + curl_multi_remove_handle($mh, $ch2); + curl_multi_close($mh); + + return ['data1' => $data1, 'data2' => $data2]; +}); + +// Coroutine 2: curl_multi with CURLOPT_FILE +$c2 = spawn(function() use ($server, $tmpfile) { + $fp = fopen($tmpfile, 'w'); + + $mh = curl_multi_init(); + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, "http://localhost:{$server->port}/large"); + curl_setopt($ch, CURLOPT_FILE, $fp); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + + curl_multi_add_handle($mh, $ch); + + $active = null; + do { + $status = curl_multi_exec($mh, $active); + if ($status !== CURLM_OK) break; + if ($active > 0) curl_multi_select($mh, 1.0); + } while ($active > 0); + + $errno = curl_errno($ch); + + curl_multi_remove_handle($mh, $ch); + curl_multi_close($mh); + fclose($fp); + + return ['errno' => $errno]; +}); + +[$results, $exceptions] = await_all([$c1, $c2]); + +echo "Coro 1 data1: {$results[0]['data1']}\n"; +echo "Coro 1 data2: {$results[0]['data2']}\n"; +echo "Coro 2 errno: {$results[1]['errno']}\n"; + +$file_content = file_get_contents($tmpfile); +echo "Coro 2 file size: " . strlen($file_content) . "\n"; + +echo "Exceptions: " . count(array_filter($exceptions)) . "\n"; + +@unlink($tmpfile); +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +Coro 1 data1: Hello World +Coro 1 data2: {"message":"Hello JSON","status":"ok"} +Coro 2 errno: 0 +Coro 2 file size: 10000 +Exceptions: 0 +Done diff --git a/tests/curl/048-multi_mixed_single_multi.phpt b/tests/curl/048-multi_mixed_single_multi.phpt new file mode 100644 index 00000000..4cc5b5cc --- /dev/null +++ b/tests/curl/048-multi_mixed_single_multi.phpt @@ -0,0 +1,103 @@ +--TEST-- +Async curl: concurrent coroutines mixing curl_exec and curl_multi +--EXTENSIONS-- +curl +--FILE-- +port}/large"); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + + $result = curl_exec($ch); + $errno = curl_errno($ch); + + return ['mode' => 'single', 'len' => strlen($result), 'errno' => $errno]; +}); + +// Coroutine 2: curl_multi with multiple handles +$c2 = spawn(function() use ($server) { + $mh = curl_multi_init(); + + $ch1 = curl_init(); + curl_setopt($ch1, CURLOPT_URL, "http://localhost:{$server->port}/"); + curl_setopt($ch1, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch1, CURLOPT_TIMEOUT, 5); + + $ch2 = curl_init(); + curl_setopt($ch2, CURLOPT_URL, "http://localhost:{$server->port}/json"); + curl_setopt($ch2, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch2, CURLOPT_TIMEOUT, 5); + + curl_multi_add_handle($mh, $ch1); + curl_multi_add_handle($mh, $ch2); + + $active = null; + do { + $status = curl_multi_exec($mh, $active); + if ($status !== CURLM_OK) break; + if ($active > 0) curl_multi_select($mh, 1.0); + } while ($active > 0); + + $r1 = curl_multi_getcontent($ch1); + $r2 = curl_multi_getcontent($ch2); + + curl_multi_remove_handle($mh, $ch1); + curl_multi_remove_handle($mh, $ch2); + curl_multi_close($mh); + + return ['mode' => 'multi', 'r1' => $r1, 'r2' => $r2]; +}); + +// Coroutine 3: curl_exec with WRITEFUNCTION +$c3 = spawn(function() use ($server) { + $body = ''; + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, "http://localhost:{$server->port}/json"); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $d) use (&$body) { + $body .= $d; + return strlen($d); + }); + + curl_exec($ch); + $errno = curl_errno($ch); + + return ['mode' => 'single_cb', 'body' => $body, 'errno' => $errno]; +}); + +[$results, $exceptions] = await_all([$c1, $c2, $c3]); + +// Sort by mode for deterministic output +usort($results, fn($a, $b) => strcmp($a['mode'], $b['mode'])); + +foreach ($results as $r) { + if ($r['mode'] === 'multi') { + echo "multi: r1={$r['r1']}, r2={$r['r2']}\n"; + } elseif ($r['mode'] === 'single') { + echo "single: len={$r['len']}, errno={$r['errno']}\n"; + } else { + echo "single_cb: body={$r['body']}, errno={$r['errno']}\n"; + } +} + +echo "Exceptions: " . count(array_filter($exceptions)) . "\n"; + +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +multi: r1=Hello World, r2={"message":"Hello JSON","status":"ok"} +single: len=10000, errno=0 +single_cb: body={"message":"Hello JSON","status":"ok"}, errno=0 +Exceptions: 0 +Done diff --git a/tests/curl/049-multi_concurrent_curlfile.phpt b/tests/curl/049-multi_concurrent_curlfile.phpt new file mode 100644 index 00000000..bdbe233d --- /dev/null +++ b/tests/curl/049-multi_concurrent_curlfile.phpt @@ -0,0 +1,90 @@ +--TEST-- +Async curl multi: two coroutines each uploading CURLFile via curl_multi +--EXTENSIONS-- +curl +--FILE-- +port}/upload"); + curl_setopt($ch1, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch1, CURLOPT_TIMEOUT, 5); + curl_setopt($ch1, CURLOPT_SAFE_UPLOAD, true); + curl_setopt($ch1, CURLOPT_POSTFIELDS, ['file' => curl_file_create($tmpfile1, 'text/plain', 'from_coro1.txt')]); + + curl_multi_add_handle($mh, $ch1); + + $active = null; + do { + $status = curl_multi_exec($mh, $active); + if ($status !== CURLM_OK) break; + if ($active > 0) curl_multi_select($mh, 1.0); + } while ($active > 0); + + $r = curl_multi_getcontent($ch1); + curl_multi_remove_handle($mh, $ch1); + curl_multi_close($mh); + + return ['coro' => 1, 'result' => $r]; +}); + +// Coroutine 2: upload 2 files via curl_multi +$c2 = spawn(function() use ($server, $tmpfile2) { + $mh = curl_multi_init(); + + $ch1 = curl_init(); + curl_setopt($ch1, CURLOPT_URL, "http://localhost:{$server->port}/upload"); + curl_setopt($ch1, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch1, CURLOPT_TIMEOUT, 5); + curl_setopt($ch1, CURLOPT_SAFE_UPLOAD, true); + curl_setopt($ch1, CURLOPT_POSTFIELDS, ['file' => curl_file_create($tmpfile2, 'text/plain', 'from_coro2.txt')]); + + curl_multi_add_handle($mh, $ch1); + + $active = null; + do { + $status = curl_multi_exec($mh, $active); + if ($status !== CURLM_OK) break; + if ($active > 0) curl_multi_select($mh, 1.0); + } while ($active > 0); + + $r = curl_multi_getcontent($ch1); + curl_multi_remove_handle($mh, $ch1); + curl_multi_close($mh); + + return ['coro' => 2, 'result' => $r]; +}); + +[$results, $exceptions] = await_all([$c1, $c2]); + +usort($results, fn($a, $b) => $a['coro'] - $b['coro']); + +echo "Coro 1: {$results[0]['result']}\n"; +echo "Coro 2: {$results[1]['result']}\n"; +echo "Exceptions: " . count(array_filter($exceptions)) . "\n"; + +@unlink($tmpfile1); +@unlink($tmpfile2); +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +Coro 1: from_coro1.txt|text/plain|23 +Coro 2: from_coro2.txt|text/plain|28 +Exceptions: 0 +Done diff --git a/tests/curl/050-multi_concurrent_exception_isolation.phpt b/tests/curl/050-multi_concurrent_exception_isolation.phpt new file mode 100644 index 00000000..69875330 --- /dev/null +++ b/tests/curl/050-multi_concurrent_exception_isolation.phpt @@ -0,0 +1,99 @@ +--TEST-- +Async curl multi: exception in one coroutine does not affect another coroutine's curl_multi +--EXTENSIONS-- +curl +--FILE-- +port}/"); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) { + throw new RuntimeException("coro1 callback error"); + }); + + curl_multi_add_handle($mh, $ch); + + // Exception propagates from curl_multi_exec — don't catch it here + $active = null; + do { + $status = curl_multi_exec($mh, $active); + if ($status !== CURLM_OK) break; + if ($active > 0) curl_multi_select($mh, 1.0); + } while ($active > 0); + + curl_multi_remove_handle($mh, $ch); + curl_multi_close($mh); +}); + +// Coroutine 2: normal operation, should complete successfully +$c2 = spawn(function() use ($server) { + $mh = curl_multi_init(); + + $ch1 = curl_init(); + curl_setopt($ch1, CURLOPT_URL, "http://localhost:{$server->port}/"); + curl_setopt($ch1, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch1, CURLOPT_TIMEOUT, 5); + + $ch2 = curl_init(); + curl_setopt($ch2, CURLOPT_URL, "http://localhost:{$server->port}/json"); + curl_setopt($ch2, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch2, CURLOPT_TIMEOUT, 5); + + curl_multi_add_handle($mh, $ch1); + curl_multi_add_handle($mh, $ch2); + + $active = null; + do { + $status = curl_multi_exec($mh, $active); + if ($status !== CURLM_OK) break; + if ($active > 0) curl_multi_select($mh, 1.0); + } while ($active > 0); + + $r1 = curl_multi_getcontent($ch1); + $r2 = curl_multi_getcontent($ch2); + $e1 = curl_errno($ch1); + $e2 = curl_errno($ch2); + + curl_multi_remove_handle($mh, $ch1); + curl_multi_remove_handle($mh, $ch2); + curl_multi_close($mh); + + return ['r1' => $r1, 'r2' => $r2, 'e1' => $e1, 'e2' => $e2]; +}); + +[$results, $exceptions] = await_all([$c1, $c2]); + +// Coroutine 1 should have thrown — exception in $exceptions +if (isset($exceptions[0]) && $exceptions[0] instanceof RuntimeException) { + echo "Coro 1 exception: " . $exceptions[0]->getMessage() . "\n"; +} else { + echo "Coro 1: no exception (unexpected)\n"; +} + +// Coroutine 2 should succeed regardless +echo "Coro 2 r1: {$results[1]['r1']}\n"; +echo "Coro 2 r2: {$results[1]['r2']}\n"; +echo "Coro 2 e1: {$results[1]['e1']}\n"; +echo "Coro 2 e2: {$results[1]['e2']}\n"; + +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +Coro 1 exception: coro1 callback error +Coro 2 r1: Hello World +Coro 2 r2: {"message":"Hello JSON","status":"ok"} +Coro 2 e1: 0 +Coro 2 e2: 0 +Done diff --git a/tests/curl/051-multi_read_user_exception.phpt b/tests/curl/051-multi_read_user_exception.phpt new file mode 100644 index 00000000..8fae1f80 --- /dev/null +++ b/tests/curl/051-multi_read_user_exception.phpt @@ -0,0 +1,52 @@ +--TEST-- +Async curl multi: exception in CURLOPT_READFUNCTION propagates to curl_multi_exec +--EXTENSIONS-- +curl +--FILE-- +port}/put"); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_UPLOAD, true); + curl_setopt($ch, CURLOPT_INFILESIZE, 1000); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_setopt($ch, CURLOPT_READFUNCTION, function($ch, $infile, $length) { + throw new RuntimeException("multi read callback error"); + }); + + curl_multi_add_handle($mh, $ch); + + try { + $active = null; + do { + $status = curl_multi_exec($mh, $active); + if ($status !== CURLM_OK) break; + if ($active > 0) curl_multi_select($mh, 1.0); + } while ($active > 0); + echo "no exception\n"; + } catch (RuntimeException $e) { + echo "caught: " . $e->getMessage() . "\n"; + } + + curl_multi_remove_handle($mh, $ch); + curl_multi_close($mh); +}); + +await($coroutine); + +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +caught: multi read callback error +Done diff --git a/tests/curl/052-multi_header_user_exception.phpt b/tests/curl/052-multi_header_user_exception.phpt new file mode 100644 index 00000000..34a1353e --- /dev/null +++ b/tests/curl/052-multi_header_user_exception.phpt @@ -0,0 +1,53 @@ +--TEST-- +Async curl multi: exception in CURLOPT_HEADERFUNCTION propagates to curl_multi_exec +--EXTENSIONS-- +curl +--FILE-- +port}/"); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) { + if (stripos($header, 'Content-Type') !== false) { + throw new RuntimeException("multi header callback error"); + } + return strlen($header); + }); + + curl_multi_add_handle($mh, $ch); + + try { + $active = null; + do { + $status = curl_multi_exec($mh, $active); + if ($status !== CURLM_OK) break; + if ($active > 0) curl_multi_select($mh, 1.0); + } while ($active > 0); + echo "no exception\n"; + } catch (RuntimeException $e) { + echo "caught: " . $e->getMessage() . "\n"; + } + + curl_multi_remove_handle($mh, $ch); + curl_multi_close($mh); +}); + +await($coroutine); + +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +caught: multi header callback error +Done diff --git a/tests/curl/053-multi_curlfile_nonexistent.phpt b/tests/curl/053-multi_curlfile_nonexistent.phpt new file mode 100644 index 00000000..42d1d904 --- /dev/null +++ b/tests/curl/053-multi_curlfile_nonexistent.phpt @@ -0,0 +1,61 @@ +--TEST-- +Async curl multi: CURLFile upload of nonexistent file returns error +--EXTENSIONS-- +curl +--FILE-- +port}/upload"); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_setopt($ch, CURLOPT_SAFE_UPLOAD, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, ['file' => curl_file_create($nonexistent, 'text/plain', 'ghost.txt')]); + + curl_multi_add_handle($mh, $ch); + + $active = null; + do { + $status = curl_multi_exec($mh, $active); + if ($status !== CURLM_OK) break; + if ($active > 0) curl_multi_select($mh, 1.0); + } while ($active > 0); + + while ($info = curl_multi_info_read($mh)) { + if ($info['msg'] === CURLMSG_DONE) { + echo "Transfer errno: " . $info['result'] . "\n"; + echo "Has error: " . ($info['result'] !== 0 ? "yes" : "no") . "\n"; + } + } + + $errno = curl_errno($ch); + $error = curl_error($ch); + echo "curl_errno: $errno\n"; + echo "curl_error: " . ($error ? "present" : "none") . "\n"; + + curl_multi_remove_handle($mh, $ch); + curl_multi_close($mh); +}); + +await($coroutine); + +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +Transfer errno: %d +Has error: yes +curl_errno: %d +curl_error: present +Done diff --git a/tests/curl/054-multi_write_file_broken_pipe.phpt b/tests/curl/054-multi_write_file_broken_pipe.phpt new file mode 100644 index 00000000..b19925bd --- /dev/null +++ b/tests/curl/054-multi_write_file_broken_pipe.phpt @@ -0,0 +1,65 @@ +--TEST-- +Async curl multi: CURLOPT_FILE to broken pipe triggers write error +--EXTENSIONS-- +curl +--SKIPIF-- + +--INI-- +error_reporting=E_ALL & ~E_NOTICE +--FILE-- + writes get EPIPE + $pair = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, 0); + $fp = $pair[0]; + fclose($pair[1]); + + $mh = curl_multi_init(); + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, "http://localhost:{$server->port}/large"); + curl_setopt($ch, CURLOPT_FILE, $fp); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + + curl_multi_add_handle($mh, $ch); + + $active = null; + do { + $status = curl_multi_exec($mh, $active); + if ($status !== CURLM_OK) break; + if ($active > 0) curl_multi_select($mh, 1.0); + } while ($active > 0); + + while ($info = curl_multi_info_read($mh)) { + if ($info['msg'] === CURLMSG_DONE) { + $errno = $info['result']; + echo "Transfer result: " . ($errno === CURLE_WRITE_ERROR ? "CURLE_WRITE_ERROR" : "errno=$errno") . "\n"; + } + } + + $errno = curl_errno($ch); + echo "curl_errno: " . ($errno === CURLE_WRITE_ERROR ? "CURLE_WRITE_ERROR" : "errno=$errno") . "\n"; + + curl_multi_remove_handle($mh, $ch); + curl_multi_close($mh); + fclose($fp); +}); + +await($coroutine); + +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +Transfer result: CURLE_WRITE_ERROR +curl_errno: CURLE_WRITE_ERROR +Done diff --git a/tests/curl/055-multi_connection_error.phpt b/tests/curl/055-multi_connection_error.phpt new file mode 100644 index 00000000..22db6ac4 --- /dev/null +++ b/tests/curl/055-multi_connection_error.phpt @@ -0,0 +1,50 @@ +--TEST-- +Async curl multi: connection error (invalid port) reports error via curl_multi_info_read +--EXTENSIONS-- +curl +--FILE-- + 0) curl_multi_select($mh, 1.0); + } while ($active > 0); + + while ($info = curl_multi_info_read($mh)) { + if ($info['msg'] === CURLMSG_DONE) { + echo "Has error: " . ($info['result'] !== 0 ? "yes" : "no") . "\n"; + } + } + + $errno = curl_errno($ch); + $error = curl_error($ch); + echo "curl_errno: " . ($errno !== 0 ? "nonzero" : "0") . "\n"; + echo "curl_error: " . (!empty($error) ? "present" : "none") . "\n"; + + curl_multi_remove_handle($mh, $ch); + curl_multi_close($mh); +}); + +await($coroutine); +echo "Done\n"; +?> +--EXPECTF-- +Has error: yes +curl_errno: nonzero +curl_error: present +Done diff --git a/tests/curl/056-multi_progress_exception.phpt b/tests/curl/056-multi_progress_exception.phpt new file mode 100644 index 00000000..20a007d5 --- /dev/null +++ b/tests/curl/056-multi_progress_exception.phpt @@ -0,0 +1,59 @@ +--TEST-- +Async curl multi: exception in CURLOPT_XFERINFOFUNCTION propagates +--EXTENSIONS-- +curl +--FILE-- +port}/large"); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_setopt($ch, CURLOPT_NOPROGRESS, false); + curl_setopt($ch, CURLOPT_XFERINFOFUNCTION, function($ch, $dltotal, $dlnow, $ultotal, $ulnow) use (&$call_count, &$thrown) { + $call_count++; + if ($call_count >= 2 && !$thrown) { + $thrown = true; + throw new RuntimeException("multi progress error"); + } + return 0; + }); + + curl_multi_add_handle($mh, $ch); + + try { + $active = null; + do { + $status = curl_multi_exec($mh, $active); + if ($status !== CURLM_OK) break; + if ($active > 0) curl_multi_select($mh, 1.0); + } while ($active > 0); + echo "no exception\n"; + } catch (RuntimeException $e) { + echo "caught: " . $e->getMessage() . "\n"; + } + + curl_multi_remove_handle($mh, $ch); + curl_multi_close($mh); +}); + +await($coroutine); + +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECTF-- +caught: multi progress error +Done diff --git a/tests/curl/057-curl_handle_reuse_file.phpt b/tests/curl/057-curl_handle_reuse_file.phpt new file mode 100644 index 00000000..8cd5bc17 --- /dev/null +++ b/tests/curl/057-curl_handle_reuse_file.phpt @@ -0,0 +1,32 @@ +--TEST-- +Reusing curl handle with CURLOPT_FILE does not crash (write IO callback cleanup) +--EXTENSIONS-- +curl +--FILE-- +port}/"); + curl_setopt($ch, CURLOPT_FILE, $fp); + curl_exec($ch); + } + + fclose($fp); + echo "PASS: handle reuse with file\n"; +})); + +async_test_server_stop($server); +?> +--EXPECT-- +PASS: handle reuse with file diff --git a/tests/curl/058-curl_early_fclose.phpt b/tests/curl/058-curl_early_fclose.phpt new file mode 100644 index 00000000..3e854a91 --- /dev/null +++ b/tests/curl/058-curl_early_fclose.phpt @@ -0,0 +1,38 @@ +--TEST-- +Closing CURLOPT_FILE stream before curl_close does not crash +--EXTENSIONS-- +curl +--FILE-- +port}/"); + curl_setopt($ch, CURLOPT_FILE, $fp); + curl_exec($ch); + + // Close file BEFORE curl handle — IO ref must keep it alive + fclose($fp); + + // Second request after fclose — must not crash + $fp2 = fopen($nullDevice, 'w'); + curl_setopt($ch, CURLOPT_FILE, $fp2); + curl_exec($ch); + fclose($fp2); + + echo "PASS: early fclose\n"; +})); + +async_test_server_stop($server); +?> +--EXPECT-- +PASS: early fclose diff --git a/tests/curl/059-curl_handle_reuse_different_fp.phpt b/tests/curl/059-curl_handle_reuse_different_fp.phpt new file mode 100644 index 00000000..2958a0e8 --- /dev/null +++ b/tests/curl/059-curl_handle_reuse_different_fp.phpt @@ -0,0 +1,32 @@ +--TEST-- +Reusing curl handle with different CURLOPT_FILE streams each iteration +--EXTENSIONS-- +curl +--FILE-- +port}/"); + curl_setopt($ch, CURLOPT_FILE, $fp); + curl_exec($ch); + fclose($fp); + } + + echo "PASS: different fp each iteration\n"; +})); + +async_test_server_stop($server); +?> +--EXPECT-- +PASS: different fp each iteration diff --git a/tests/curl/060-curl_concurrent_reuse_file.phpt b/tests/curl/060-curl_concurrent_reuse_file.phpt new file mode 100644 index 00000000..e6acdbe8 --- /dev/null +++ b/tests/curl/060-curl_concurrent_reuse_file.phpt @@ -0,0 +1,46 @@ +--TEST-- +Concurrent curl handle reuse with CURLOPT_FILE in multiple coroutines +--EXTENSIONS-- +curl +--FILE-- +port}/"); + curl_setopt($ch, CURLOPT_FILE, $fp); + curl_exec($ch); + } + + fclose($fp); + $results[$c] = "coroutine $c done"; + }); +} + +await_all($coroutines); +ksort($results); +foreach ($results as $line) { + echo $line . "\n"; +} +echo "PASS: concurrent reuse\n"; + +async_test_server_stop($server); +?> +--EXPECT-- +coroutine 0 done +coroutine 1 done +coroutine 2 done +PASS: concurrent reuse diff --git a/tests/curl/061-read_callback_socket_source.phpt b/tests/curl/061-read_callback_socket_source.phpt new file mode 100644 index 00000000..039c8a4d --- /dev/null +++ b/tests/curl/061-read_callback_socket_source.phpt @@ -0,0 +1,60 @@ +--TEST-- +Async curl: CURLOPT_READFUNCTION reads from TCP socket (sync IO fallback in scheduler context) +--EXTENSIONS-- +curl +--SKIPIF-- + +--FILE-- + +--EXPECT-- +errno: 0 +string(25) "custom:contents of socket" diff --git a/tests/curl/062-read_callback_socket_source_win.phpt b/tests/curl/062-read_callback_socket_source_win.phpt new file mode 100644 index 00000000..0009af8f --- /dev/null +++ b/tests/curl/062-read_callback_socket_source_win.phpt @@ -0,0 +1,58 @@ +--TEST-- +Async curl: CURLOPT_READFUNCTION reads from TCP socket (sync IO fallback on Windows) +--EXTENSIONS-- +curl +--SKIPIF-- + +--FILE-- +port}", $errno, $errstr, 5); + fwrite($sock, "GET / HTTP/1.0\r\nHost: localhost\r\n\r\n"); + + $sWriteFile = tempnam(sys_get_temp_dir(), 'curl_read_sock_'); + $sWriteUrl = 'file://' . $sWriteFile; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $sWriteUrl); + curl_setopt($ch, CURLOPT_UPLOAD, 1); + curl_setopt($ch, CURLOPT_READFUNCTION, function($ch, $infile, $maxOut) use ($sock) { + $data = fread($sock, $maxOut); + if ($data === false || $data === '') { + return ''; + } + return $data; + }); + curl_exec($ch); + + $errno = curl_errno($ch); + echo "errno: $errno\n"; + + fclose($sock); + + $output = file_get_contents($sWriteFile); + echo (str_contains($output, 'Hello World') ? "Content: OK" : "Content: FAIL") . "\n"; + + @unlink($sWriteFile); +}); + +await($coroutine); + +async_test_server_stop($server); +echo "Done\n"; +?> +--EXPECT-- +errno: 0 +Content: OK +Done diff --git a/tests/exec/017-exec_unicode_binary.phpt b/tests/exec/017-exec_unicode_binary.phpt index 0508369a..832f0846 100644 --- a/tests/exec/017-exec_unicode_binary.phpt +++ b/tests/exec/017-exec_unicode_binary.phpt @@ -3,6 +3,8 @@ exec() async handles UTF-8 and special characters --SKIPIF-- --FILE-- --FILE-- --EXPECT-- -Length: 24 -Has CR: yes +Length: 23 Lines: 3 Line3 raw: " spaces " diff --git a/tests/exec/021-shell_exec_chdir_cwd.phpt b/tests/exec/021-shell_exec_chdir_cwd.phpt new file mode 100644 index 00000000..91635191 --- /dev/null +++ b/tests/exec/021-shell_exec_chdir_cwd.phpt @@ -0,0 +1,21 @@ +--TEST-- +shell_exec() respects virtual CWD after chdir() +--FILE-- + +--EXPECT-- +bool(true) diff --git a/tests/exec/022-exec_chdir_cwd.phpt b/tests/exec/022-exec_chdir_cwd.phpt new file mode 100644 index 00000000..7ed92596 --- /dev/null +++ b/tests/exec/022-exec_chdir_cwd.phpt @@ -0,0 +1,29 @@ +--TEST-- +exec() respects virtual CWD after chdir() +--SKIPIF-- + +--FILE-- + +--EXPECT-- +bool(true) +int(0) diff --git a/tests/exec/023-system_chdir_cwd.phpt b/tests/exec/023-system_chdir_cwd.phpt new file mode 100644 index 00000000..018a43ab --- /dev/null +++ b/tests/exec/023-system_chdir_cwd.phpt @@ -0,0 +1,25 @@ +--TEST-- +system() respects virtual CWD after chdir() +--FILE-- + +--EXPECT-- +bool(true) +int(0) diff --git a/tests/exec/024-passthru_chdir_cwd.phpt b/tests/exec/024-passthru_chdir_cwd.phpt new file mode 100644 index 00000000..0f80267b --- /dev/null +++ b/tests/exec/024-passthru_chdir_cwd.phpt @@ -0,0 +1,25 @@ +--TEST-- +passthru() respects virtual CWD after chdir() +--FILE-- + +--EXPECT-- +bool(true) +int(0) diff --git a/tests/fiber/029-fiber_create_without_callback_no_leak.phpt b/tests/fiber/029-fiber_create_without_callback_no_leak.phpt new file mode 100644 index 00000000..7706b29a --- /dev/null +++ b/tests/fiber/029-fiber_create_without_callback_no_leak.phpt @@ -0,0 +1,18 @@ +--TEST-- +Fiber created without callback should not leak async scope +--FILE-- +getMessage(), "\n"; +} +echo "OK\n"; +?> +--EXPECT-- +Fiber::__construct() expects exactly 1 argument, 0 given +OK diff --git a/tests/include/001-require_in_coroutine.phpt b/tests/include/001-require_in_coroutine.phpt new file mode 100644 index 00000000..8d41cfd6 --- /dev/null +++ b/tests/include/001-require_in_coroutine.phpt @@ -0,0 +1,46 @@ +--TEST-- +require inside coroutine - visibility in main and other coroutines +--FILE-- +getValue() . "\n"; + echo "c1: " . TEST_INCLUDED_CONST . "\n"; +}); + +await($c1); + +// check visibility from main scope after coroutine finished +echo "main: " . test_included_function() . "\n"; +echo "main: " . (new TestIncludedClass())->getValue() . "\n"; +echo "main: " . TEST_INCLUDED_CONST . "\n"; + +// check visibility from another coroutine +$c2 = spawn(function() { + echo "c2: " . test_included_function() . "\n"; + echo "c2: " . (new TestIncludedClass())->getValue() . "\n"; + echo "c2: " . TEST_INCLUDED_CONST . "\n"; +}); + +await($c2); + +echo "done\n"; +?> +--EXPECT-- +c1: included_ok +c1: class_ok +c1: 42 +main: included_ok +main: class_ok +main: 42 +c2: included_ok +c2: class_ok +c2: 42 +done diff --git a/tests/include/002-include_in_coroutine.phpt b/tests/include/002-include_in_coroutine.phpt new file mode 100644 index 00000000..75d1f0af --- /dev/null +++ b/tests/include/002-include_in_coroutine.phpt @@ -0,0 +1,26 @@ +--TEST-- +include inside coroutine - with return value +--FILE-- + +--EXPECT-- +array(2) { + ["key"]=> + string(5) "value" + ["number"]=> + int(123) +} +done diff --git a/tests/include/003-require_once_multiple_coroutines.phpt b/tests/include/003-require_once_multiple_coroutines.phpt new file mode 100644 index 00000000..081bb9d2 --- /dev/null +++ b/tests/include/003-require_once_multiple_coroutines.phpt @@ -0,0 +1,35 @@ +--TEST-- +require_once from multiple coroutines - no redeclare error +--FILE-- + +--EXPECT-- +c1: included_ok +c2: included_ok +main: included_ok +done diff --git a/tests/include/004-include_output_in_coroutine.phpt b/tests/include/004-include_output_in_coroutine.phpt new file mode 100644 index 00000000..deb475c5 --- /dev/null +++ b/tests/include/004-include_output_in_coroutine.phpt @@ -0,0 +1,23 @@ +--TEST-- +include with echo output inside coroutine +--FILE-- + +--EXPECT-- +before_include +output_from_include +after_include +done diff --git a/tests/include/005-require_concurrent_different_files.phpt b/tests/include/005-require_concurrent_different_files.phpt new file mode 100644 index 00000000..c4e13441 --- /dev/null +++ b/tests/include/005-require_concurrent_different_files.phpt @@ -0,0 +1,28 @@ +--TEST-- +require different files from concurrent coroutines +--FILE-- + +--EXPECT-- +c1: value +output_from_include +c2: done +done diff --git a/tests/include/006-require_in_nested_coroutine.phpt b/tests/include/006-require_in_nested_coroutine.phpt new file mode 100644 index 00000000..d735920a --- /dev/null +++ b/tests/include/006-require_in_nested_coroutine.phpt @@ -0,0 +1,34 @@ +--TEST-- +require inside nested coroutines +--FILE-- + +--EXPECT-- +outer: included_ok +inner: included_ok +inner data: 123 +outer end +done diff --git a/tests/include/007-include_nonexistent_in_coroutine.phpt b/tests/include/007-include_nonexistent_in_coroutine.phpt new file mode 100644 index 00000000..52acf845 --- /dev/null +++ b/tests/include/007-include_nonexistent_in_coroutine.phpt @@ -0,0 +1,22 @@ +--TEST-- +include nonexistent file inside coroutine - warning handling +--FILE-- + +--EXPECT-- +bool(false) +coroutine continues +done diff --git a/tests/include/008-file_get_contents_in_coroutine.phpt b/tests/include/008-file_get_contents_in_coroutine.phpt new file mode 100644 index 00000000..925018fe --- /dev/null +++ b/tests/include/008-file_get_contents_in_coroutine.phpt @@ -0,0 +1,24 @@ +--TEST-- +file_get_contents and file_put_contents inside coroutine +--FILE-- + +--EXPECT-- +read: hello async world +done diff --git a/tests/include/009-require_double_include_redeclare.phpt b/tests/include/009-require_double_include_redeclare.phpt new file mode 100644 index 00000000..c07159f9 --- /dev/null +++ b/tests/include/009-require_double_include_redeclare.phpt @@ -0,0 +1,29 @@ +--TEST-- +require same file twice in coroutine - must trigger redeclare error +--FILE-- +getMessage() . "\n"; + } +}); + +await($c1); + +echo "done\n"; +?> +--EXPECTF-- +first require ok + +Fatal error: Cannot redeclare function test_included_function() (previously declared in %stest_include_file.inc:%d) in %stest_include_file.inc on line %d diff --git a/tests/include/010-require_in_coroutine_then_main_redeclare.phpt b/tests/include/010-require_in_coroutine_then_main_redeclare.phpt new file mode 100644 index 00000000..ae3d0496 --- /dev/null +++ b/tests/include/010-require_in_coroutine_then_main_redeclare.phpt @@ -0,0 +1,23 @@ +--TEST-- +require in coroutine then require (not _once) in main - must trigger redeclare error +--FILE-- + +--EXPECTF-- +coroutine: included_ok + +Fatal error: Cannot redeclare function test_included_function() (previously declared in %stest_include_file.inc:%d) in %stest_include_file.inc on line %d diff --git a/tests/include/test_include_file.inc b/tests/include/test_include_file.inc new file mode 100644 index 00000000..38527814 --- /dev/null +++ b/tests/include/test_include_file.inc @@ -0,0 +1,12 @@ + 'value', 'number' => 123]; diff --git a/tests/include/test_include_with_output.inc b/tests/include/test_include_with_output.inc new file mode 100644 index 00000000..235c067b --- /dev/null +++ b/tests/include/test_include_with_output.inc @@ -0,0 +1,2 @@ + +--FILE-- + ["pipe", "w"]], + $pipes + ); + + if (!is_resource($process)) { + return "fail"; + } + + // Set non-blocking BEFORE reading + stream_set_blocking($pipes[1], false); + + $start = hrtime(true); + $result = fread($pipes[1], 1024); + $elapsed_ms = (hrtime(true) - $start) / 1_000_000; + + echo "fread returned: " . var_export($result, true) . "\n"; + // Must return immediately (well under 100ms), not hang for 500ms + echo "returned quickly: " . ($elapsed_ms < 100 ? "yes" : "no") . "\n"; + echo "eof: " . (feof($pipes[1]) ? "yes" : "no") . "\n"; + + fclose($pipes[1]); + proc_close($process); +}); + +await($coroutine); +echo "Done\n"; + +?> +--EXPECT-- +Start +fread returned: '' +returned quickly: yes +eof: no +Done diff --git a/tests/io/045-nonblocking_pipe_write_broken.phpt b/tests/io/045-nonblocking_pipe_write_broken.phpt new file mode 100644 index 00000000..d87ded61 --- /dev/null +++ b/tests/io/045-nonblocking_pipe_write_broken.phpt @@ -0,0 +1,59 @@ +--TEST-- +Non-blocking write to broken pipe returns error without crash +--SKIPIF-- + +--FILE-- + ["pipe", "r"], + 1 => ["pipe", "w"], + ], + $pipes + ); + + if (!is_resource($process)) { + return "fail"; + } + + // Wait for child to exit so pipe is truly broken + fread($pipes[1], 1024); + fclose($pipes[1]); + + // Set stdin to non-blocking + stream_set_blocking($pipes[0], false); + + // Write to broken pipe — must not crash or hang + $result = @fwrite($pipes[0], str_repeat("X", 65536)); + echo "write result: " . var_export($result, true) . "\n"; + echo "no crash: yes\n"; + + fclose($pipes[0]); + proc_close($process); +}); + +await($coroutine); +echo "Done\n"; + +?> +--EXPECTF-- +Start +write result: %s +no crash: yes +Done diff --git a/tests/io/046-nonblocking_pipe_read_with_data.phpt b/tests/io/046-nonblocking_pipe_read_with_data.phpt new file mode 100644 index 00000000..939f69a5 --- /dev/null +++ b/tests/io/046-nonblocking_pipe_read_with_data.phpt @@ -0,0 +1,60 @@ +--TEST-- +Non-blocking pipe read returns available data immediately +--SKIPIF-- + +--FILE-- + ["pipe", "w"]], + $pipes + ); + + if (!is_resource($process)) { + return "fail"; + } + + // Set non-blocking, then poll for data with retries + // (CI runners may be slow to start the child process) + stream_set_blocking($pipes[1], false); + + $data = ''; + for ($i = 0; $i < 10; $i++) { + usleep(50000); // 50ms + $chunk = fread($pipes[1], 1024); + if ($chunk !== '' && $chunk !== false) { + $data = $chunk; + break; + } + } + echo "read: '$data'\n"; + echo "has data: " . ($data !== '' && $data !== false ? "yes" : "no") . "\n"; + + fclose($pipes[1]); + proc_close($process); +}); + +await($coroutine); +echo "Done\n"; + +?> +--EXPECT-- +Start +read: 'hello async' +has data: yes +Done diff --git a/tests/io/047-nonblocking_pipe_write_success.phpt b/tests/io/047-nonblocking_pipe_write_success.phpt new file mode 100644 index 00000000..442a8085 --- /dev/null +++ b/tests/io/047-nonblocking_pipe_write_success.phpt @@ -0,0 +1,58 @@ +--TEST-- +Non-blocking pipe write succeeds and data is received by reader +--SKIPIF-- + +--FILE-- + ["pipe", "r"], + 1 => ["pipe", "w"], + ], + $pipes + ); + + if (!is_resource($process)) { + return "fail"; + } + + // Set stdin to non-blocking + stream_set_blocking($pipes[0], false); + + $written = fwrite($pipes[0], "non-blocking-data"); + echo "bytes written: $written\n"; + fclose($pipes[0]); + + // Read back from child (blocking is fine here) + $output = stream_get_contents($pipes[1]); + echo "child output: '$output'\n"; + + fclose($pipes[1]); + proc_close($process); +}); + +await($coroutine); +echo "Done\n"; + +?> +--EXPECT-- +Start +bytes written: 17 +child output: 'non-blocking-data' +Done diff --git a/tests/io/048-file_offset_sync_with_dup.phpt b/tests/io/048-file_offset_sync_with_dup.phpt new file mode 100644 index 00000000..d2a7b9dc --- /dev/null +++ b/tests/io/048-file_offset_sync_with_dup.phpt @@ -0,0 +1,26 @@ +--TEST-- +File offset stays in sync between dup'd fds (write) +--DESCRIPTION-- +When two fds share the same open file description (via dup/redirect), +writes through async IO must advance the kernel file offset so the +other fd sees the correct position. +--FILE-- + ['file', $file, 'w'], 2 => ['redirect', 1]], $pipes); +proc_close($proc); + +$contents = file_get_contents($file); +var_dump(strlen($contents)); +// Both "Hello" and "World" must be present (order may vary) +var_dump(str_contains($contents, 'Hello')); +var_dump(str_contains($contents, 'World')); + +unlink($file); +?> +--EXPECT-- +int(10) +bool(true) +bool(true) diff --git a/tests/io/049-file_offset_sequential_writes.phpt b/tests/io/049-file_offset_sequential_writes.phpt new file mode 100644 index 00000000..d3cdb773 --- /dev/null +++ b/tests/io/049-file_offset_sequential_writes.phpt @@ -0,0 +1,23 @@ +--TEST-- +Sequential async file writes maintain correct offsets +--DESCRIPTION-- +Multiple sequential writes to a file must produce the correct total +content without gaps or overwrites. +--FILE-- + +--EXPECT-- +int(9) +string(9) "AAABBBCCC" diff --git a/tests/io/050-file_seek_write_read_offset.phpt b/tests/io/050-file_seek_write_read_offset.phpt new file mode 100644 index 00000000..552c2f52 --- /dev/null +++ b/tests/io/050-file_seek_write_read_offset.phpt @@ -0,0 +1,29 @@ +--TEST-- +fseek + fwrite + fread maintain correct file offsets +--FILE-- + +--EXPECT-- +int(10) +int(6) +string(10) "012XYZ6789" +int(10) diff --git a/tests/io/051-fseek_seek_end.phpt b/tests/io/051-fseek_seek_end.phpt new file mode 100644 index 00000000..85e122a1 --- /dev/null +++ b/tests/io/051-fseek_seek_end.phpt @@ -0,0 +1,50 @@ +--TEST-- +fseek with SEEK_END positions correctly in async context +--FILE-- + +--EXPECT-- +Start +ftell at end: 10 +ftell at -3 from end: 7 +Read: 'HIJ' +Read at -5: 'FG' +Result: done +End diff --git a/tests/io/052-ftruncate_extend.phpt b/tests/io/052-ftruncate_extend.phpt new file mode 100644 index 00000000..2050e04e --- /dev/null +++ b/tests/io/052-ftruncate_extend.phpt @@ -0,0 +1,49 @@ +--TEST-- +ftruncate to extend file beyond current size fills with null bytes +--FILE-- + +--EXPECT-- +Start +Size before: 3 +Size after extend: 8 +Length: 8 +First 3 bytes: 'ABC' +Padding is null bytes: yes +Result: done +End diff --git a/tests/io/053-fwrite_zero_length.phpt b/tests/io/053-fwrite_zero_length.phpt new file mode 100644 index 00000000..dc9f5e0f --- /dev/null +++ b/tests/io/053-fwrite_zero_length.phpt @@ -0,0 +1,51 @@ +--TEST-- +fwrite with zero-length string does not corrupt file position +--FILE-- + +--EXPECT-- +Start +ftell after write: 5 +fwrite empty returned: 0 +ftell after empty write: 5 +ftell after second write: 10 +Content: 'HelloWorld' +Result: done +End diff --git a/tests/io/054-fread_write_only_file.phpt b/tests/io/054-fread_write_only_file.phpt new file mode 100644 index 00000000..e7c34630 --- /dev/null +++ b/tests/io/054-fread_write_only_file.phpt @@ -0,0 +1,40 @@ +--TEST-- +fread on write-only file handle returns false +--FILE-- + +--EXPECT-- +Start +fread returned: false +Content: 'Hello' +Result: done +End diff --git a/tests/io/055-fseek_beyond_eof_write.phpt b/tests/io/055-fseek_beyond_eof_write.phpt new file mode 100644 index 00000000..1168ecc3 --- /dev/null +++ b/tests/io/055-fseek_beyond_eof_write.phpt @@ -0,0 +1,54 @@ +--TEST-- +fseek beyond EOF then write creates sparse region +--FILE-- + +--EXPECT-- +Start +ftell after seek past EOF: 10 +ftell after write: 13 +Total length: 13 +First 3: 'ABC' +Last 3: 'XYZ' +Gap is null: yes +Result: done +End diff --git a/tests/io/056-concurrent_writes_different_files.phpt b/tests/io/056-concurrent_writes_different_files.phpt new file mode 100644 index 00000000..8a66d128 --- /dev/null +++ b/tests/io/056-concurrent_writes_different_files.phpt @@ -0,0 +1,60 @@ +--TEST-- +Concurrent writes to different files from multiple coroutines +--FILE-- + $file) { + $coroutines[] = spawn(function() use ($file, $i) { + $fp = fopen($file, 'w'); + for ($j = 0; $j < 100; $j++) { + fwrite($fp, "line_{$i}_{$j}\n"); + } + fclose($fp); + return $i; + }); +} + +[$results, $exceptions] = await_all($coroutines); +sort($results); +echo "Completed: " . implode(',', $results) . "\n"; +echo "Exceptions: " . count($exceptions) . "\n"; + +// Verify each file +foreach ($files as $i => $file) { + $lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + echo "File $i: " . count($lines) . " lines\n"; + echo "First: {$lines[0]}\n"; + echo "Last: {$lines[99]}\n"; + unlink($file); +} + +echo "End\n"; + +?> +--EXPECT-- +Start +Completed: 0,1,2 +Exceptions: 0 +File 0: 100 lines +First: line_0_0 +Last: line_0_99 +File 1: 100 lines +First: line_1_0 +Last: line_1_99 +File 2: 100 lines +First: line_2_0 +Last: line_2_99 +End diff --git a/tests/io/057-stream_get_contents_pipe_maxlength.phpt b/tests/io/057-stream_get_contents_pipe_maxlength.phpt new file mode 100644 index 00000000..eafe2576 --- /dev/null +++ b/tests/io/057-stream_get_contents_pipe_maxlength.phpt @@ -0,0 +1,51 @@ +--TEST-- +stream_get_contents on pipe with maxlength +--SKIPIF-- + +--FILE-- + ['pipe', 'w']], + $pipes + ); + + // Read only first 5 bytes + $data = stream_get_contents($pipes[1], 5); + echo "First 5: '$data'\n"; + + // Read next 3 bytes + $data = stream_get_contents($pipes[1], 3); + echo "Next 3: '$data'\n"; + + // Read rest + $data = stream_get_contents($pipes[1]); + echo "Rest: '$data'\n"; + + fclose($pipes[1]); + proc_close($process); + return "done"; +}); + +$result = await($coroutine); +echo "Result: $result\n"; + +echo "End\n"; + +?> +--EXPECT-- +Start +First 5: 'ABCDE' +Next 3: 'FGH' +Rest: 'IJKLMNOP' +Result: done +End diff --git a/tests/io/058-fpassthru_pipe.phpt b/tests/io/058-fpassthru_pipe.phpt new file mode 100644 index 00000000..fdb3f9cf --- /dev/null +++ b/tests/io/058-fpassthru_pipe.phpt @@ -0,0 +1,49 @@ +--TEST-- +fpassthru reads remaining pipe data to stdout +--SKIPIF-- + +--FILE-- + ['pipe', 'w']], + $pipes + ); + + // Read first 6 bytes + $partial = fread($pipes[1], 6); + echo "Partial: '$partial'\n"; + + // fpassthru sends the rest to stdout + echo "Rest: "; + $bytes = fpassthru($pipes[1]); + echo "\n"; + echo "Bytes passed: $bytes\n"; + + fclose($pipes[1]); + proc_close($process); + return "done"; +}); + +$result = await($coroutine); +echo "Result: $result\n"; + +echo "End\n"; + +?> +--EXPECT-- +Start +Partial: 'Hello ' +Rest: World +Bytes passed: 5 +Result: done +End diff --git a/tests/io/059-stream_get_line_async.phpt b/tests/io/059-stream_get_line_async.phpt new file mode 100644 index 00000000..30d67510 --- /dev/null +++ b/tests/io/059-stream_get_line_async.phpt @@ -0,0 +1,49 @@ +--TEST-- +stream_get_line reads up to delimiter in async context +--FILE-- + +--EXPECT-- +Start +First: 'key1=value1' +Second: 'key2=value2' +Third: 'key3=value3' +EOF: false +Result: done +End diff --git a/tests/io/060-concurrent_append_same_file.phpt b/tests/io/060-concurrent_append_same_file.phpt new file mode 100644 index 00000000..8303ead4 --- /dev/null +++ b/tests/io/060-concurrent_append_same_file.phpt @@ -0,0 +1,45 @@ +--TEST-- +Sequential appends to same file from separate open/close cycles in coroutine +--FILE-- + +--EXPECT-- +Start +Content: 'INIT_AAA_BBB_CCC' +Length: 16 +End diff --git a/tests/io/069-append_concurrent_two_coroutines.phpt b/tests/io/069-append_concurrent_two_coroutines.phpt new file mode 100644 index 00000000..1a7a1aa9 --- /dev/null +++ b/tests/io/069-append_concurrent_two_coroutines.phpt @@ -0,0 +1,59 @@ +--TEST-- +Two coroutines appending to the same file do not corrupt each other's data +--XFAIL-- +Windows: WriteFile ignores CRT _O_APPEND flag because FILE_WRITE_DATA is present alongside FILE_APPEND_DATA on the HANDLE. Removing FILE_WRITE_DATA would fix atomic append but breaks ftruncate (SetEndOfFile requires FILE_WRITE_DATA). The lseek(SEEK_END) workaround in the reactor is not sufficient when libuv dispatches writes to a worker thread — another coroutine can obtain the same EOF offset before the first write completes. +--FILE-- + +--EXPECT-- +Start +length: 16 +A writes: 4 +B writes: 4 +no corruption: yes +End diff --git a/tests/output_buffer/008-ob_flush_after_file_write.phpt b/tests/output_buffer/008-ob_flush_after_file_write.phpt new file mode 100644 index 00000000..f905b836 --- /dev/null +++ b/tests/output_buffer/008-ob_flush_after_file_write.phpt @@ -0,0 +1,15 @@ +--TEST-- +Output Buffer: ob_start auto-flush after file_put_contents +--FILE-- + +--EXPECT-- +buffered output diff --git a/tests/pdo_pgsql/001-pdo_pgsql_copy_from.phpt b/tests/pdo_pgsql/001-pdo_pgsql_copy_from.phpt new file mode 100644 index 00000000..f5972dff --- /dev/null +++ b/tests/pdo_pgsql/001-pdo_pgsql_copy_from.phpt @@ -0,0 +1,281 @@ +--TEST-- +PDO PgSQL: Async COPY FROM with Pdo\Pgsql (copyFromArray and copyFromFile) +--EXTENSIONS-- +pdo_pgsql +true_async +--SKIPIF-- + +--FILE-- +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $db->setAttribute(PDO::ATTR_STRINGIFY_FETCHES, false); + + $db->exec('CREATE TABLE test_async_copy_from (a integer not null primary key, b text, c integer)'); + + $tableRows = []; + $tableRowsCustom = []; + for ($i = 0; $i < 3; $i++) { + $tableRows[] = "{$i}\ttest insert {$i}\t\\N"; + $tableRowsCustom[] = "{$i};test insert {$i};NULL"; + } + + // Test copyFromArray with default parameters + echo "Testing copyFromArray() with defaults\n"; + $db->beginTransaction(); + var_dump($db->copyFromArray('test_async_copy_from', $tableRows)); + + $stmt = $db->query("SELECT * FROM test_async_copy_from ORDER BY a"); + foreach ($stmt as $r) { + var_dump($r); + } + $db->rollback(); + + // Test copyFromArray with custom separator and null + echo "Testing copyFromArray() with custom separator\n"; + $db->beginTransaction(); + var_dump($db->copyFromArray('test_async_copy_from', $tableRowsCustom, ";", "NULL")); + + $stmt = $db->query("SELECT * FROM test_async_copy_from ORDER BY a"); + foreach ($stmt as $r) { + var_dump($r); + } + $db->rollback(); + + // Test copyFromArray with selected fields + echo "Testing copyFromArray() with selected fields\n"; + $tableRowsFields = []; + for ($i = 0; $i < 3; $i++) { + $tableRowsFields[] = "{$i};NULL"; + } + $db->beginTransaction(); + var_dump($db->copyFromArray('test_async_copy_from', $tableRowsFields, ";", "NULL", "a,c")); + + $stmt = $db->query("SELECT * FROM test_async_copy_from ORDER BY a"); + foreach ($stmt as $r) { + var_dump($r); + } + $db->rollback(); + + // Test copyFromFile + echo "Testing copyFromFile() with defaults\n"; + $filename = __DIR__ . '/test_async_copy.csv'; + file_put_contents($filename, implode("\n", $tableRows)); + + $db->beginTransaction(); + var_dump($db->copyFromFile('test_async_copy_from', $filename)); + + $stmt = $db->query("SELECT * FROM test_async_copy_from ORDER BY a"); + foreach ($stmt as $r) { + var_dump($r); + } + $db->rollback(); + + @unlink($filename); + + // Test copyFromArray with error (non-existing table) + echo "Testing copyFromArray() with error\n"; + $db->beginTransaction(); + try { + $db->copyFromArray('test_nonexistent_table', $tableRows); + } catch (Exception $e) { + echo "Exception: caught\n"; + } + $db->rollback(); + + $db->exec('DROP TABLE IF EXISTS test_async_copy_from'); + echo "done\n"; +}); + +await($coroutine); +?> +--EXPECT-- +Testing copyFromArray() with defaults +bool(true) +array(6) { + ["a"]=> + int(0) + [0]=> + int(0) + ["b"]=> + string(13) "test insert 0" + [1]=> + string(13) "test insert 0" + ["c"]=> + NULL + [2]=> + NULL +} +array(6) { + ["a"]=> + int(1) + [0]=> + int(1) + ["b"]=> + string(13) "test insert 1" + [1]=> + string(13) "test insert 1" + ["c"]=> + NULL + [2]=> + NULL +} +array(6) { + ["a"]=> + int(2) + [0]=> + int(2) + ["b"]=> + string(13) "test insert 2" + [1]=> + string(13) "test insert 2" + ["c"]=> + NULL + [2]=> + NULL +} +Testing copyFromArray() with custom separator +bool(true) +array(6) { + ["a"]=> + int(0) + [0]=> + int(0) + ["b"]=> + string(13) "test insert 0" + [1]=> + string(13) "test insert 0" + ["c"]=> + NULL + [2]=> + NULL +} +array(6) { + ["a"]=> + int(1) + [0]=> + int(1) + ["b"]=> + string(13) "test insert 1" + [1]=> + string(13) "test insert 1" + ["c"]=> + NULL + [2]=> + NULL +} +array(6) { + ["a"]=> + int(2) + [0]=> + int(2) + ["b"]=> + string(13) "test insert 2" + [1]=> + string(13) "test insert 2" + ["c"]=> + NULL + [2]=> + NULL +} +Testing copyFromArray() with selected fields +bool(true) +array(6) { + ["a"]=> + int(0) + [0]=> + int(0) + ["b"]=> + NULL + [1]=> + NULL + ["c"]=> + NULL + [2]=> + NULL +} +array(6) { + ["a"]=> + int(1) + [0]=> + int(1) + ["b"]=> + NULL + [1]=> + NULL + ["c"]=> + NULL + [2]=> + NULL +} +array(6) { + ["a"]=> + int(2) + [0]=> + int(2) + ["b"]=> + NULL + [1]=> + NULL + ["c"]=> + NULL + [2]=> + NULL +} +Testing copyFromFile() with defaults +bool(true) +array(6) { + ["a"]=> + int(0) + [0]=> + int(0) + ["b"]=> + string(13) "test insert 0" + [1]=> + string(13) "test insert 0" + ["c"]=> + NULL + [2]=> + NULL +} +array(6) { + ["a"]=> + int(1) + [0]=> + int(1) + ["b"]=> + string(13) "test insert 1" + [1]=> + string(13) "test insert 1" + ["c"]=> + NULL + [2]=> + NULL +} +array(6) { + ["a"]=> + int(2) + [0]=> + int(2) + ["b"]=> + string(13) "test insert 2" + [1]=> + string(13) "test insert 2" + ["c"]=> + NULL + [2]=> + NULL +} +Testing copyFromArray() with error +Exception: caught +done diff --git a/tests/pdo_pgsql/002-pdo_pgsql_basic_async.phpt b/tests/pdo_pgsql/002-pdo_pgsql_basic_async.phpt new file mode 100644 index 00000000..eadf72dd --- /dev/null +++ b/tests/pdo_pgsql/002-pdo_pgsql_basic_async.phpt @@ -0,0 +1,65 @@ +--TEST-- +PDO PgSQL: Basic async queries with concurrent coroutines +--EXTENSIONS-- +pdo_pgsql +true_async +--SKIPIF-- + +--FILE-- +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + $db->exec('CREATE TABLE IF NOT EXISTS test_async_basic (id serial PRIMARY KEY, val text)'); + $db->exec('TRUNCATE test_async_basic'); + + // Test INSERT + SELECT + $db->exec("INSERT INTO test_async_basic (val) VALUES ('hello'), ('world'), ('async')"); + + $stmt = $db->query('SELECT val FROM test_async_basic ORDER BY id'); + $rows = $stmt->fetchAll(PDO::FETCH_COLUMN); + echo implode(',', $rows) . "\n"; + + // Test prepared statements + $stmt = $db->prepare('SELECT val FROM test_async_basic WHERE id = :id'); + $stmt->execute(['id' => 2]); + echo $stmt->fetchColumn() . "\n"; + + // Test concurrent coroutines with separate connections + $c1 = spawn(function() { + $db = AsyncPDOPgSQLTest::factory(); + $stmt = $db->query("SELECT 'coroutine1' AS result"); + return $stmt->fetchColumn(); + }); + + $c2 = spawn(function() { + $db = AsyncPDOPgSQLTest::factory(); + $stmt = $db->query("SELECT 'coroutine2' AS result"); + return $stmt->fetchColumn(); + }); + + echo await($c1) . "\n"; + echo await($c2) . "\n"; + + $db->exec('DROP TABLE IF EXISTS test_async_basic'); + echo "done\n"; +}); + +await($coroutine); +?> +--EXPECT-- +hello,world,async +world +coroutine1 +coroutine2 +done diff --git a/tests/pdo_pgsql/003-pdo_pgsql_persistent_sync.phpt b/tests/pdo_pgsql/003-pdo_pgsql_persistent_sync.phpt new file mode 100644 index 00000000..ed911856 --- /dev/null +++ b/tests/pdo_pgsql/003-pdo_pgsql_persistent_sync.phpt @@ -0,0 +1,68 @@ +--TEST-- +PDO PgSQL: Persistent connections use sync I/O inside coroutines +--EXTENSIONS-- +pdo_pgsql +true_async +--SKIPIF-- + +--FILE-- + true, + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + ]); + + // Basic query should work (falls back to sync) + $stmt = $db->query('SELECT 1 AS n'); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + echo "persistent query: " . $row['n'] . "\n"; + + // Prepared statement should work + $stmt = $db->prepare('SELECT :val AS result'); + $stmt->execute(['val' => 'persistent_ok']); + echo "persistent prepare: " . $stmt->fetchColumn() . "\n"; + + // Multiple queries on same persistent connection + $db->exec('CREATE TABLE IF NOT EXISTS test_persistent_sync (id serial PRIMARY KEY, val text)'); + $db->exec('TRUNCATE test_persistent_sync'); + $db->exec("INSERT INTO test_persistent_sync (val) VALUES ('a'), ('b'), ('c')"); + + $stmt = $db->query('SELECT val FROM test_persistent_sync ORDER BY id'); + $rows = $stmt->fetchAll(PDO::FETCH_COLUMN); + echo "persistent rows: " . implode(',', $rows) . "\n"; + + $db->exec('DROP TABLE IF EXISTS test_persistent_sync'); + + // Non-persistent connection in the same coroutine should still use async + $db2 = new Pdo\Pgsql($dsn, null, null, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + ]); + + $stmt = $db2->query('SELECT 42 AS n'); + echo "non-persistent query: " . $stmt->fetch(PDO::FETCH_ASSOC)['n'] . "\n"; + + echo "done\n"; +}); + +await($coroutine); +?> +--EXPECT-- +persistent query: 1 +persistent prepare: persistent_ok +persistent rows: a,b,c +non-persistent query: 42 +done diff --git a/tests/pdo_pgsql/004-pdo_pgsql_unbuffered_async.phpt b/tests/pdo_pgsql/004-pdo_pgsql_unbuffered_async.phpt new file mode 100644 index 00000000..54b634a1 --- /dev/null +++ b/tests/pdo_pgsql/004-pdo_pgsql_unbuffered_async.phpt @@ -0,0 +1,69 @@ +--TEST-- +PDO PgSQL: Unbuffered (lazy fetch) queries work correctly in async context +--EXTENSIONS-- +pdo_pgsql +true_async +--SKIPIF-- + +--FILE-- +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + $db->exec('CREATE TABLE IF NOT EXISTS test_async_unbuf (id serial PRIMARY KEY, val text)'); + $db->exec('TRUNCATE test_async_unbuf'); + + for ($i = 0; $i < 10; $i++) { + $db->exec("INSERT INTO test_async_unbuf (val) VALUES ('row_$i')"); + } + + // Test unbuffered fetch (ATTR_PREFETCH = 0) + echo "=== unbuffered fetch ===\n"; + $stmt = $db->prepare('SELECT val FROM test_async_unbuf ORDER BY id', [ + PDO::ATTR_PREFETCH => 0 + ]); + $stmt->execute(); + $rows = []; + while ($row = $stmt->fetch(PDO::FETCH_COLUMN)) { + $rows[] = $row; + } + echo count($rows) . " rows fetched\n"; + echo "first: " . $rows[0] . ", last: " . $rows[9] . "\n"; + + // After unbuffered fetch, a new query on the same connection must work + echo "=== subsequent query ===\n"; + $stmt2 = $db->query('SELECT COUNT(*) FROM test_async_unbuf'); + echo "count: " . $stmt2->fetchColumn() . "\n"; + + // Test switching between unbuffered and buffered + echo "=== buffered after unbuffered ===\n"; + $stmt3 = $db->prepare('SELECT val FROM test_async_unbuf ORDER BY id LIMIT 3'); + $stmt3->execute(); + $rows = $stmt3->fetchAll(PDO::FETCH_COLUMN); + echo implode(',', $rows) . "\n"; + + $db->exec('DROP TABLE IF EXISTS test_async_unbuf'); + echo "done\n"; +}); + +await($coroutine); +?> +--EXPECT-- +=== unbuffered fetch === +10 rows fetched +first: row_0, last: row_9 +=== subsequent query === +count: 10 +=== buffered after unbuffered === +row_0,row_1,row_2 +done diff --git a/tests/pdo_pgsql/inc/async_pdo_pgsql_test.inc b/tests/pdo_pgsql/inc/async_pdo_pgsql_test.inc new file mode 100644 index 00000000..f60baa01 --- /dev/null +++ b/tests/pdo_pgsql/inc/async_pdo_pgsql_test.inc @@ -0,0 +1,26 @@ +query("SELECT 1"); + } catch (Exception $e) { + die('skip PostgreSQL server not available: ' . $e->getMessage()); + } + } + + static function skipIfNoAsync(): void { + if (!function_exists('Async\\spawn')) { + die('skip async extension not loaded'); + } + } +} +?> diff --git a/tests/pdo_pgsql/inc/config.inc b/tests/pdo_pgsql/inc/config.inc new file mode 100644 index 00000000..72be7a0f --- /dev/null +++ b/tests/pdo_pgsql/inc/config.inc @@ -0,0 +1,11 @@ + $v) { + putenv("$k=$v"); +} +?> diff --git a/tests/stream/024-stream_select_remote_disconnect.phpt b/tests/stream/024-stream_select_remote_disconnect.phpt index ace26f52..567baaf1 100644 --- a/tests/stream/024-stream_select_remote_disconnect.phpt +++ b/tests/stream/024-stream_select_remote_disconnect.phpt @@ -211,5 +211,5 @@ Client process: connecting to port %d Client process: connected, sending data Client process: closing connection abruptly Client process: exited -Client process exit code: 0 +Client process exit code: %s Test result: server completed \ No newline at end of file