Skip to content

Honour ZEND_ASYNC_IO_WRITEV_AWAIT: a vectored write the caller can wait for - #262

Merged
EdmondDantes merged 3 commits into
mainfrom
262-awaited-writev
Aug 20, 2026
Merged

Honour ZEND_ASYNC_IO_WRITEV_AWAIT: a vectored write the caller can wait for#262
EdmondDantes merged 3 commits into
mainfrom
262-awaited-writev

Conversation

@EdmondDantes

Copy link
Copy Markdown
Contributor

Reactor half of true-async/php-src#24. Consumer: true-async/server (HTTP/1 chunk frames).

Why

The vectored write reports nothing — io_pipe_writev_cb freed the request and sent no NOTIFY — so a caller that needs the outcome has to use the single-buffer write. That one does not take the buffer over, so the caller frees it, and the only moment it can is when its own wait ends. Cancel the waiting coroutine and those two moments come apart: the wait is over, the write is still queued, and libuv holds a pointer into memory the caller has released.

Reproduced in the server: a handler parked writing to a peer that never reads, setShutdownTimeout(0), then stop(). Probes fired in order — the await returns with req->completed == false, libuv_io_req_dispose defers because ASYNC_IO_REQ_F_UV_IN_FLIGHT is set, and the caller frees a 20008-byte frame. Stamping the freed block showed the allocator hands the same address straight back.

What changes

ASYNC_IO_REQ_F_AWAITED is set at submit when the caller passes the flag. The completion releases the slots exactly as before, then:

  • a dispose that arrived while the write was queued is finished here;
  • otherwise the request stays alive and io->event is notified with the request as the result and no exception.

The exception stays on the request. Passing it on the broadcast would wake every reader and writer on the handle, because each listener forwards an exception unconditionally and filters by result only otherwise — that is load-bearing elsewhere (it is how a writer parked on a closed handle is woken), so the awaited path must not use it.

The mode is now read from the low bit instead of by equality, so ZSTR and IOV keep their values and an unknown bit trips an assert instead of silently selecting a mode.

Evidence

Built against php-src#24. ext/async/tests: 1104 passed, 0 failed. true-async/server tests/phpt against the same build: 390 passed, 0 failed, with the server's HTTP/1 frame path converted to the new op — after which the caller frees nothing and the probe that caught the early free stops firing.

The vectored write reported nothing: the completion freed the request and
sent no notification, so the only write a caller could wait for was the
single-buffer one — which leaves the buffer with the caller and therefore
makes the caller free it when its own wait ends. Cancel the waiting coroutine
and those moments come apart: the wait is over while the write is still in
libuv's queue, pointing at memory the caller has released.

With the flag set the completion releases the slots as before, then either
finishes a dispose that arrived while the write was queued, or notifies
io->event with the request as the result and no exception. The exception is
left on the request for the awaiter to read: passing it on the broadcast would
wake every reader and writer on the handle, because each listener forwards an
exception unconditionally and filters by result only otherwise.

The mode now comes from the low bit rather than from equality, so the two
existing values keep their meaning.
An assert aborts a debug build and, on release, silently gives an unknown bit
whatever meaning this version happens to have — for a caller compiled against
a newer header, the quiet outcome is the dangerous one. AWAIT is ZSTR-only:
the awaited completion hands the request to its awaiter, so it cannot also
keep the IOV promise that free_cb runs at completion.
@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@EdmondDantes
EdmondDantes merged commit 7188eb6 into main Aug 20, 2026
9 checks passed
@EdmondDantes
EdmondDantes deleted the 262-awaited-writev branch August 20, 2026 16:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant