Skip to content

Hold the shared-handle test to the upload, and cover a failed read - #293

Merged
EdmondDantes merged 4 commits into
mainfrom
291-curl-read-request-lifetime
Sep 9, 2026
Merged

EdmondDantes merged 4 commits into
mainfrom
291-curl-read-request-lifetime

Conversation

@EdmondDantes

@EdmondDantes EdmondDantes commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Follows #292, from two critic passes over it. Every finding below was checked against the code before acting on it; where a path is read rather than run, it says so.

A failed read had nobody to release it. io_file_read_cb notifies with the request as the result and the request's own exception beside it (ext/async libuv_reactor.c), so a failed read reaches the branch that handles a failure of the event layer and never the one that inspects the request — which means the check added there in #292 was unreachable. The failure branch left the request behind, and the reactor hands an awaited request to its awaiter and nobody else. An upload from a handle opened for writing leaks 528 bytes of request per read: a debug build reports it against libuv_reactor.c(6072), with the exception object beside it. Released in that branch now, and the unreachable check is gone.

tests/curl/071-read_error_releases_its_request.phpt covers it: the leak report goes to the output run-tests compares, so the test fails on a debug build that leaks. It skips on a release build, which prints no such report, and on Windows, where a file read is served synchronously and never reaches the branch under test.

The shared-handle test did not force the overlap it tests, and the first barrier did not either. The writer did 100 writes through the thread pool while curl was still connecting, so on a machine where the writes finished before curl subscribed the engine before the fix would have passed. The barrier added for it counted scheduler turns — and the marker printed beside it fired on every run here: an empty scheduler spins through ten thousand turns while curl is still connecting, so the writes still landed before the upload. It waits five seconds by the clock now, and prints a line the expectation does not carry when it gives up, so a barrier that stops working cannot pass quietly.

Measured with the working barrier: SIGSEGV in 5 runs of 5 against the engine before #292, 5 green runs of 5 after.

Two paths closed from reading, neither reproduced by a script. The read callback answers PAUSE while a read of its own is in flight: libcurl asks for data again whenever the transfer is unpaused, and unpausing what is received resumes what is sent with it, so a second request could take the place of the first — which would then complete as a stranger to its own state, freed by nobody, its bytes missing from the body. And a state whose handle closed under it remembers the close: a handle closed with requests still in the thread pool notifies without an exception, this state waits for its own completion, and the request curl asks for next would otherwise reach the reactor for a read on a descriptor that is gone, raising inside the reactor's own callback. Two scripts written for these two paths did not reproduce either; they enforce the invariant the rest of the fix rests on.

The engine side is php-src c63ad4096fa (true-async, merged into true-async-stable).

Left out of this PR and filed as #294: a CURLFile upload keeps its read state on the mime callback argument, out of reach of curl_async_event_stop, so its event is never cleared and its subscription never dropped — the cancelled-state handling in #292 covers the CURLOPT_READDATA state alone. Read from the code; a script for it did not reproduce either, and it wants its own reproduction before a fix. Beside it, CURL_READ_OWNS_FD is read twice and set nowhere, so both close(state->file.fd) branches are dead.

Nothing ordered the two coroutines of the shared-handle test: on a machine
where the writer finished before curl subscribed, the engine before the fix
passed it. The writer now waits until curl reports bytes on the wire, bounded
so that a build whose progress callback never reports still runs the writes.

The second test uploads from a handle opened for writing, where every read
fails. The failed request had nobody to release it; a debug build prints what is
left behind, and the test reads that. The fix is php-src 909137229f9.
@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!

The barrier counted scheduler turns, and an empty scheduler spins through ten
thousand of them while curl is still connecting: the marker added with it fired
on every run here, so the writes were landing before the upload as before. It
waits five seconds by the clock now, and prints a line the expectation does not
carry when it gives up, so a barrier that stops working cannot pass quietly.

The leak test skips outside a debug build, which prints no leak report, and on
Windows, where a file read is served synchronously and never reaches the branch
under test.
The test rested on a read of a handle opened for writing failing. It fails here
and succeeds on the CI runner, which answered "upload: sent" where the test
expected "upload: failed", so the test says nothing there and broke the debug
jobs everywhere. The leak the fix closes keeps the evidence it was found with:
528 bytes of request per read, reported by a debug build against
libuv_reactor.c.
@EdmondDantes
EdmondDantes merged commit 634c808 into main Sep 9, 2026
9 checks passed
@EdmondDantes
EdmondDantes deleted the 291-curl-read-request-lifetime branch September 9, 2026 19:13
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