Skip to content

Cover a curl upload sharing its handle with another coroutine - #292

Merged
EdmondDantes merged 2 commits into
mainfrom
291-curl-foreign-completion
Sep 9, 2026
Merged

EdmondDantes merged 2 commits into
mainfrom
291-curl-foreign-completion

Conversation

@EdmondDantes

@EdmondDantes EdmondDantes commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Closes #291.

curl_async_read submits ZEND_ASYNC_IO_READ, returns CURL_READFUNC_PAUSE and parks on io->event — the event shared by every operation on that descriptor. curl_async_read_complete took whatever request the notification carried as its own: the next curl_async_read copied that request's buffer into curl's upload buffer and called req->dispose(req), while php_stdiop_write freed the same request as its owner.

A file handle passed as CURLOPT_READDATA while another coroutine writes to it ends the process with zend_mm_heap corrupted (double free). A gdb trace on libuv_io_req_dispose shows one address freed by php_stdiop_write (main/streams/plain_wrapper.c:637), then five times by curl_async_read (ext/curl/curl_async.c), then by php_stdiop_write again, where the allocator panics:

zend_mm_panic ("zend_mm_heap corrupted (double free)")
zend_mm_free_small
libuv_io_req_dispose (ext/async/libuv_reactor.c:4842)
php_stdiop_write (main/streams/plain_wrapper.c:637)
zif_fwrite

The upload also carries the foreign write's bytes rather than the file's, because req->buf of a write request is the source of that write. That one follows from the code and was not observed on the wire.

The engine side is php-src 8f25e009bef (true-async, merged into true-async-stable), two commits:

  • the read state remembers the request curl_async_read submitted, and a completion for any other request returns without touching the state;
  • a notification with no result is the handle closing, and the reactor sends it in two senses. With an exception every request has ended; without one the descriptor stays open for the requests still in the thread pool, which report themselves later, so a read waiting for one of those keeps waiting instead of failing its upload. Three requests that had nobody to release them are released with it: the one in flight when the handle fails, the one in flight when the read state is freed, and the one whose read ended with an exception, which curl_async_read took for the end of the file and turned into a silently truncated body. A cancelled state also drops its subscription before freeing itself, so a later notification on that descriptor does not read through it.

Of the php-src consumers of this event only curl went unfiltered: php_stdiop_read and php_stdiop_write have their own while (!req->completed) loop. This is #130 seen from the other end.

This PR carries the test and the changelog entry.

  • tests/curl/070-read_takes_only_its_completion.phpt: SIGSEGV in 5 runs of 5 against the engine before the change, 10 green runs of 10 after. Both coroutines share one descriptor offset, so the writer consumes bytes the upload would have sent: the body is announced as 200000 of the file's 1050000, which the writer's 819200 cannot exhaust, and the test does not rest on how libcurl answers a short upload. Run here through a copy with an absolute include, because a test including ../../../../ext/curl/tests/server.inc leaves the checkout through the local symlink — the same reason 063 and 064 are red on this machine and green on CI.
  • ext/curl/tests, tests/curl and tests/io: 370 tests, 8 failures. Four are the symlink artefacts (063, 064, 070, io/082); the other four — curl_curlfile_seek, curl_readfunction_throws_abort, curl_writefunction_throws_abort, curl_headerfunction_throws_abort — are red on this machine with the change and without it alike.

The IO event of a descriptor is shared by every operation on it, so the read
curl parks on is notified by writes it never submitted, and
curl_async_read_complete took whatever request the notification carried: curl
copied that request's buffer into its upload buffer and freed it, while its
owner freed it again. The fix is in php-src 15dff751a9f.

The test uploads from a handle another coroutine writes to, and asserts the
method the responder echoes: what reaches the server is whatever the two
coroutines left in the file, while the crash is what the test is after.
@codecov

codecov Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Both coroutines share one descriptor offset, so the writer consumes bytes the
upload would have sent: with the body announced as the whole file, curl reached
EOF short of the length it promised, and the test rested on how libcurl answers
a short upload. The body is announced as 200000 of 1050000 now, which the writer
cannot exhaust.

Also names what the changelog entry claims and what it derives: the foreign
bytes in the POST body follow from the code and were not observed on the wire.
@EdmondDantes
EdmondDantes merged commit 04aca44 into main Sep 9, 2026
9 checks passed
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.

curl takes a foreign completion from the shared IO event and frees it

1 participant