From 861f90100c2717af421d560c4b077d094d5d3887 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:08:52 +0000 Subject: [PATCH] reactor: report a failed write on the io handle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A write submitted without an awaiter is freed in its completion, and the free_cb it calls takes no status — so the libuv error was turned into an exception and released without anyone seeing it. The only report left was the read side, where a peer that shut its write half down is indistinguishable from one that is gone. io_pipe_write_cb and io_pipe_writev_cb now set ZEND_ASYNC_IO_WRITE_FAILED on the handle before the free_cb runs. Needs php-src at TrueAsync ABI 0.26.0. --- CHANGELOG.md | 4 ++++ libuv_reactor.c | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a6b864..337fbfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **A write submitted without an awaiter reports its failure on the handle.** `io_pipe_write_cb` and `io_pipe_writev_cb` know the libuv status, but on that path the request is freed here and its `free_cb` takes no status, so the error object was built and released without anyone seeing it — a consumer could only infer the failure from the read side, where a peer that shut its write half down looks exactly like one that is gone. Both callbacks now set `ZEND_ASYNC_IO_WRITE_FAILED` on the io handle, which the reactor never clears. Needs php-src carrying that flag (TrueAsync ABI 0.26.0); against an older one the report is compiled out. + ### Fixed - **Four MySQL tests shared one fixture table and dropped it under each other whenever the suite ran in parallel.** `createTestTable`, `cleanupTestTable` and `runAsyncTest` defaulted to `async_test` in both the pdo_mysql and the mysqli helper, and every caller took the default, so `002`, `004`, `011` and `012` raced on one name in one database: the `DROP TABLE IF EXISTS` of whichever started second removed the table the first had just created, and its `INSERT` came back as `Table 'test.async_test' doesn't exist`. The default is now `async_test_`, unique because run-tests gives each test a process. Measured over the four tests at `-j4`: 10 failing runs out of 10 before, 0 out of 10 after. The suite has been reaching MySQL on CI only intermittently, which is why the collision surfaced now. Test only. diff --git a/libuv_reactor.c b/libuv_reactor.c index 9d91cfc..a9ef812 100644 --- a/libuv_reactor.c +++ b/libuv_reactor.c @@ -4924,6 +4924,15 @@ static void io_pipe_write_cb(uv_write_t *write_request, int status) req->base.transferred = -1; req->base.exception = async_new_exception(async_ce_input_output_exception, "Pipe write error: %s", uv_strerror(status)); + + /* The handle carries the verdict because the request may not: a + * fire-and-forget write is freed here, and its free_cb takes no status. + * Guarded because php-src carries the flag only from ABI 0.26.0. */ +#ifdef ZEND_ASYNC_IO_WRITE_FAILED + if (io != NULL) { + io->base.state |= ZEND_ASYNC_IO_WRITE_FAILED; + } +#endif } req->base.completed = true; @@ -4993,6 +5002,14 @@ static void io_pipe_writev_cb(uv_write_t *write_request, int status) req->base.transferred = -1; req->base.exception = async_new_exception( async_ce_input_output_exception, "Pipe writev error: %s", uv_strerror(status)); + + /* See io_pipe_write_cb: the handle keeps the verdict for a caller whose + * completion carries none. */ +#ifdef ZEND_ASYNC_IO_WRITE_FAILED + if (req->io != NULL) { + req->io->base.state |= ZEND_ASYNC_IO_WRITE_FAILED; + } +#endif } req->base.completed = true;