From 02ca5e987230881bb57b65bf6a66b3f5e782864f Mon Sep 17 00:00:00 2001 From: Edmond <1571649+EdmondDantes@users.noreply.github.com> Date: Sat, 6 Jun 2026 13:08:15 +0300 Subject: [PATCH] fix(win): sendfile to a socket via TransmitFile, not uv_fs_sendfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ZEND_ASYNC_IO_SENDFILE is documented as "Windows TransmitFile" but the implementation routed every platform through uv_fs_sendfile, whose Windows backend writes the destination with CRT _write(). A TCP destination is a Winsock SOCKET, not a CRT file descriptor — three distinct Windows descriptor namespaces (HANDLE / SOCKET / CRT fd) that POSIX collapses into one int. So uv_fs_sendfile(out->crt_fd, ...) works on Linux and writes nowhere on Windows: response headers (uv_write) arrived, the file body vanished. Any static file above the 64 KiB slurp threshold served an empty/garbage body over plaintext HTTP/1 and HTTP/2. Resolve the descriptor confusion at the reactor layer: libuv_io_sendfile dispatches socket destinations to the real TransmitFile primitive — the destination socket from descriptor.socket, the source as a Win32 HANDLE via _get_osfhandle(crt_fd), never the conflated crt_fd-as-socket value. It runs synchronously on a libuv threadpool worker (uv_queue_work), exactly as uv_fs_sendfile runs its own copy loop off-loop, so completion still arrives on the loop thread through the unchanged sendfile_complete() notify path. The POSIX path is untouched (new code entirely under #ifdef PHP_WIN32). Verified on Windows: true-async/server static/011-static-precompressed- cache-uaf (96 KiB precompressed sidecar over plain TCP) goes red->green; full static suite 19 pass / 1 fail (unrelated h2 test) / 4 skip, no regressions in the large-file TLS / h2 paths. --- CHANGELOG.md | 1 + libuv_reactor.c | 159 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd4fe9f..5341277 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`Scope::allowZombies(): Scope`** — opt back into safe disposal on a scope (sets `DISPOSE_SAFELY`); returns `$this` for chaining. Inverse of the existing `asNotSafely()`. Use after `new Scope()` when the scope is expected to outlive coroutines parked in `delay()`/`recv()`/etc., turning them into zombies instead of cancelling them at dispose time. ### Fixed +- **file → socket `sendfile` silently dropped the body on Windows** — `ZEND_ASYNC_IO_SENDFILE` is documented as "Windows `TransmitFile`" (see the API entry under Added), but the implementation routed every platform through `uv_fs_sendfile`, whose Windows backend writes the destination via CRT `_write()`. A TCP destination is a Winsock `SOCKET`, not a CRT file descriptor — three distinct Windows descriptor namespaces (HANDLE / SOCKET / CRT fd) that POSIX collapses into one `int`, which is exactly why `uv_fs_sendfile(out->crt_fd, …)` works on Linux and writes nowhere on Windows. Headers (sent via `uv_write`) arrived; the file body vanished — so any static file above the 64 KiB slurp threshold served an empty/garbage body over plaintext HTTP/1 and HTTP/2. Fixed in `libuv_reactor.c`: `libuv_io_sendfile` now dispatches socket destinations to the real `TransmitFile` — destination socket from `descriptor.socket`, source as a Win32 `HANDLE` via `_get_osfhandle(crt_fd)` (never the conflated `crt_fd`-as-socket value) — run synchronously on a libuv threadpool worker (`uv_queue_work`), exactly as `uv_fs_sendfile` runs its own copy loop off-loop, so completion still arrives on the loop thread through the unchanged `sendfile_complete()` notify path. POSIX is untouched (the new path is entirely under `#ifdef PHP_WIN32`). Regression backstop: true-async/server `tests/phpt/server/static/011-static-precompressed-cache-uaf.phpt` (96 KiB precompressed sidecar over plain TCP). - **#154 `ThreadPool` worker crash on `exit()`/`die()` in a task or bootloader** — a graceful `exit()`/`die()` inside a submitted task or the bootloader threw an unwind-exit token that the worker either passed to `reject()` or re-raised via `zend_bailout()` — both crash the worker fiber with `"Error transfer requires a throwable value"` (assert + ASAN stack-use-after-return at `zend_fibers.c:491`). Fixed in `thread_pool.c`: the sync task call and the bootloader call now run under a `zend_try`, and the worker checks `zend_is_unwind_exit()`/`zend_is_graceful_exit()` on `EG(exception)`. Behaviour: `exit()`/`die()` in a **task** is graceful "this task is done" — the worker's request survives it, so the task's future resolves to **`null`** and the worker keeps serving subsequent tasks (verified mixing `exit()` with throwing and normal tasks on a single worker). A real fatal error (e.g. OOM `zend_bailout`) or `exit()`/`die()` in the **bootloader** instead delivers an `Async\ThreadTransferException` to pending awaiters and tears the pool down — the worker can't safely continue. Regression tests `tests/thread_pool/061-bootloader_exit.phpt`, `062-task_exit_sync.phpt`, `064-task_exit_worker_survives.phpt`. - **#154 `ThreadPool` swallowed the real error when a `$this`-bound bootloader could not load on the worker** — a bootloader bound to `$this` of a class not defined on the worker (or whose body threw) had its exception converted to an `E_WARNING` and discarded, while pending tasks were rejected with a generic `"task was cancelled before execution"` cancellation — hiding the cause. `thread_pool_drain_tasks` gained a `reject_with` parameter so the worker now propagates the actual error (e.g. `Cannot load transferred object: class "C" not found`, or the thrown exception) to every awaiter. Regression tests `tests/thread_pool/060-bootloader_this_transfer_error.phpt`, `063-bootloader_exception.phpt`. - **#146 Thread-pool task freed under a still-running worker (cross-thread UAF)** — `libuv_queue_task` took no reference on the `zend_async_task_t` it handed to `uv_queue_work`; the work wrapper held only a raw pointer. A coroutine awaiting the task that was **cancelled while the worker thread was still inside the task's `run()`** (e.g. blocked in a contended `flock()`) released its refs and freed the task — and its inline-tail data — out from under the detached worker. When the worker's syscall returned it wrote into freed memory (`heap-use-after-free in php_stdiop_flock_task_run`, `main/streams/plain_wrapper.c:1208`). Only reproducible under real multi-core scheduling (CI Linux x64 ASAN), not single-host WSL2. Fixed in `ext/async/libuv_reactor.c`: the in-flight work now owns a reference — `ZEND_ASYNC_EVENT_ADD_REF` after `uv_queue_work`, matched by `ZEND_ASYNC_EVENT_RELEASE` in `libuv_task_after_work_cb` (which libuv runs only after the worker returns). The task now outlives its worker regardless of coroutine cancellation — a general fix for every thread-pool task, not just `flock()`. This completes the earlier #146 work (inline-tail task data + pin-across-SUSPEND in php-src `plain_wrapper.c`), which was necessary but did not cover the cancel-vs-blocked-worker race. Regression backstop: the cancel-mid-flock scenarios in `fuzzy-tests/io/flock_chaos.feature`. diff --git a/libuv_reactor.c b/libuv_reactor.c index 799aaa1..85dca31 100644 --- a/libuv_reactor.c +++ b/libuv_reactor.c @@ -33,6 +33,8 @@ #ifdef PHP_WIN32 #include "win32/unistd.h" #include "win32/codepage.h" +#include /* TransmitFile, WSAID_TRANSMITFILE — file→socket sendfile */ +#include /* _get_osfhandle — CRT fd → Win32 HANDLE for the source file */ #else #include #include @@ -4444,6 +4446,126 @@ static void io_sendfile_zc_cb(uv_fs_t *fs_request) } } +#ifdef PHP_WIN32 +/* {{{ Windows file→socket sendfile via TransmitFile + * + * uv_fs_sendfile() targets the destination through CRT _write(), which + * only works when the destination is a CRT file descriptor. A TCP + * destination is a Winsock SOCKET — a different Windows descriptor + * namespace entirely (HANDLE / SOCKET / CRT fd are three distinct + * tables) — so feeding the socket value to _write writes nowhere and the + * body is silently lost. POSIX hides this because it unifies every + * descriptor into one int that sendfile(2) accepts directly. + * + * Resolve it at the right layer: dispatch socket destinations to + * TransmitFile, the Win32 file→socket primitive (the real "sendfile to a + * socket"). It runs synchronously on a libuv threadpool worker — exactly + * how uv_fs_sendfile runs its own copy loop off-loop — so completion + * still arrives on the loop thread via the after-work callback and the + * existing sendfile_complete() notify path is reused unchanged. */ + +/* TransmitFile's byte count is a DWORD; loop for larger files. 1 GiB per + * call stays well inside the limit and bounds worker occupancy per pass. */ +#define ASYNC_TRANSMITFILE_CHUNK_MAX (1u << 30) + +typedef struct { + uv_work_t work; + async_sendfile_req_t *req; + SOCKET sock; /* destination Winsock socket */ + HANDLE file; /* source file Win32 HANDLE */ + zend_off_t offset; /* start offset; -1 = current position */ + size_t remaining; + size_t transferred; + int uv_err; /* 0 = ok, else a UV_* error code */ +} async_transmitfile_work_t; + +/* Resolve TransmitFile lazily via WSAIoctl — no mswsock.lib link needed, + * and the pointer is process-stable so the static cache's benign + * same-value race across workers is harmless. */ +static LPFN_TRANSMITFILE async_resolve_transmitfile(const SOCKET s) +{ + static LPFN_TRANSMITFILE cached = NULL; + + if (cached != NULL) { + return cached; + } + + GUID guid = WSAID_TRANSMITFILE; + LPFN_TRANSMITFILE fn = NULL; + DWORD bytes = 0; + + if (WSAIoctl(s, SIO_GET_EXTENSION_FUNCTION_POINTER, &guid, sizeof(guid), + &fn, sizeof(fn), &bytes, NULL, NULL) == 0) { + cached = fn; + } + + return cached; +} + +/* Worker thread: no PHP/Zend API here, only Win32 + the req's own bytes. */ +static void io_transmitfile_work_cb(uv_work_t *work) +{ + async_transmitfile_work_t *tw = (async_transmitfile_work_t *) work->data; + + const LPFN_TRANSMITFILE transmit = async_resolve_transmitfile(tw->sock); + + if (UNEXPECTED(transmit == NULL)) { + tw->uv_err = UV_ENOSYS; + return; + } + + /* Position the file once; synchronous TransmitFile reads from and + * advances the file pointer, so later loop passes continue in place. */ + if (tw->offset >= 0) { + LARGE_INTEGER li; + li.QuadPart = (LONGLONG) tw->offset; + + if (!SetFilePointerEx(tw->file, li, NULL, FILE_BEGIN)) { + tw->uv_err = uv_translate_sys_error(GetLastError()); + return; + } + } + + while (tw->remaining > 0) { + const DWORD n = (DWORD) (tw->remaining < ASYNC_TRANSMITFILE_CHUNK_MAX + ? tw->remaining + : ASYNC_TRANSMITFILE_CHUNK_MAX); + + /* NULL OVERLAPPED on a (blocking) libuv socket → synchronous: + * returns only once all n bytes are handed to the transport. + * Stream sockets preserve send order, so the head written inline + * before this op stays ahead of the body on the wire. */ + if (!transmit(tw->sock, tw->file, n, 0, NULL, NULL, 0)) { + tw->uv_err = uv_translate_sys_error(WSAGetLastError()); + return; + } + + tw->transferred += n; + tw->remaining -= n; + } +} + +/* Loop thread: translate the worker outcome onto the shared completion. */ +static void io_transmitfile_after_cb(uv_work_t *work, const int status) +{ + async_transmitfile_work_t *tw = (async_transmitfile_work_t *) work->data; + async_sendfile_req_t *req = tw->req; + + req->transferred = tw->transferred; + + if (status == UV_ECANCELED) { + sendfile_complete(req, UV_ECANCELED, "Sendfile"); + } else if (tw->uv_err != 0) { + sendfile_complete(req, tw->uv_err, "TransmitFile"); + } else { + sendfile_complete(req, 0, NULL); + } + + pefree(tw, 0); +} +/* }}} */ +#endif /* PHP_WIN32 */ + /* {{{ libuv_io_sendfile * * Pure zero-copy: bytes never enter user space. Caller is responsible @@ -4486,6 +4608,43 @@ libuv_io_sendfile(zend_async_io_t *out_io_base, zend_async_io_t *in_io_base, return &req->base.base; } +#ifdef PHP_WIN32 + /* Socket destination: uv_fs_sendfile's CRT _write() cannot reach a + * Winsock SOCKET. Route through TransmitFile instead, using the proper + * descriptors — the socket from descriptor.socket, the source as a + * Win32 HANDLE via _get_osfhandle(crt_fd) — never the conflated + * crt_fd-as-socket value. */ + if (out_io->base.type == ZEND_ASYNC_IO_TYPE_TCP) { + const HANDLE file = (HANDLE) _get_osfhandle(in_io->crt_fd); + + if (UNEXPECTED(file == INVALID_HANDLE_VALUE)) { + async_throw_error("Failed to start sendfile: source is not a valid file handle"); + libuv_sendfile_req_dispose(&req->base.base); + return NULL; + } + + async_transmitfile_work_t *tw = pecalloc(1, sizeof(*tw), 0); + tw->work.data = tw; + tw->req = req; + tw->sock = (SOCKET) out_io->base.descriptor.socket; + tw->file = file; + tw->offset = offset; + tw->remaining = length; + + const int werr = uv_queue_work(UVLOOP, &tw->work, io_transmitfile_work_cb, + io_transmitfile_after_cb); + if (UNEXPECTED(werr < 0)) { + pefree(tw, 0); + async_throw_error("Failed to start sendfile: %s", uv_strerror(werr)); + libuv_sendfile_req_dispose(&req->base.base); + return NULL; + } + + ZEND_ASYNC_INCREASE_EVENT_COUNT(&in_io->base.event); + return &req->base.base; + } +#endif + req->base.fs_req.data = req; const int err = uv_fs_sendfile(UVLOOP, &req->base.fs_req,