Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
159 changes: 159 additions & 0 deletions libuv_reactor.c
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
#ifdef PHP_WIN32
#include "win32/unistd.h"
#include "win32/codepage.h"
#include <mswsock.h> /* TransmitFile, WSAID_TRANSMITFILE — file→socket sendfile */
#include <io.h> /* _get_osfhandle — CRT fd → Win32 HANDLE for the source file */
#else
#include <sys/wait.h>
#include <signal.h>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading