[HttpClient] Fix GuzzleHttpHandler consuming responses out of band - #65632
#65632[HttpClient] Fix GuzzleHttpHandler consuming responses out of band#65632
Conversation
Guzzle runs .then() callbacks from its task queue, which streamPending() ticked from inside its own stream() loop, while the iterator was suspended. A callback that waits on another promise then drove the handler again, consuming a second stream() iterator over the same responses. The suspended iterator resumed with stale bookkeeping: chunks for a response whose wrapped one had been swapped in the meantime crashed with "Instance of X is already consumed and cannot be managed", promises got settled twice, and responses were stranded in $pending, to throw later from their destructor. The task queue now runs once the iterator is gone, so such a callback still makes progress, and a guard keeps a nested call from starting a second loop. on_headers and on_stats are called inline from the loop, as Guzzle's contract requires, so a promise waited on from there cannot make progress. The handler now reports that, the way Guzzle's own CurlMultiHandler does, instead of letting Guzzle reject the promise with a message that names no cause. Such a promise is settled while the transfer is still tracked here, so the handler also stops settling a promise that is no longer pending, which used to escape as "Cannot change a rejected promise to fulfilled".
41850f2 to
3e82c7a
Compare
|
Thank you, the analysis is correct and the reproducer made it straightforward to verify. I amended your commit with three changes and pushed. The guard alone reintroduced one of the three failures it removes. A guarded nested wait is a no-op, so Guzzle rejects that promise itself. The response stays in Added a private The refusal now names its cause.
Three tests cover those, each failing both on 8.1 and on the previous state of this pull request. I ran 20 probes on 8.1, on your version and on this one: concurrent mixed requests, a transport error mid-flight, cancel while siblings run, cancel from a I also rewrote the description, since that is what people read once this is merged rather than the thread. |
|
Thank you @peter17. |
Follow-up to your closing note on #65306: the accurate "already consumed" exception added by #65311 did point at the real culprit, and it is the Guzzle handler.
Problem
streamPending()ticks Guzzle's task queue from afinallyinside itsstream()loop, so the queue runs while the iterator is suspended. Guzzle invokes.then()callbacks from that queue, and such a callback may wait on another promise, which drives the handler again. The nested call then consumes a secondstream()iterator over the same responses, behind the back of the suspended one.The suspended iterator resumes with stale bookkeeping, and from
AsyncResponse's point of view the response was consumed out of band. Three failures follow, all reachable through the plain Guzzle API:plus responses stranded in
$pending: never settled, they are garbage collected unconsumed and throw from their destructor, uncaught, with a stack starting atAsyncResponse::__destruct().This is what I have been chasing in production behind
NoPrivateNetworkHttpClient(~10 crashes per 50k requests): a crawler driving concurrent Guzzle promises whose callbacks wait on each other.Reproducer
Against current 8.1 this prints 55 leaked
LogicExceptions and then dies at shutdown; with the patch it prints onlydone.repro.php
Fix
The task queue now runs once the iterator is gone, so a callback that waits on another promise still makes progress. It just starts its loop when none is running. A
$streamingguard keeps a nested call from starting a second loop.Three cases cannot make progress at all, because
on_headersandon_statsare called inline from the loop, as Guzzle's contract requires. The handler now reports each of them the way Guzzle's ownCurlMultiHandlerdoes:RequestException, naming the cause. Without that, Guzzle rejects the promise itself with "Invoking the wait callback did not resolve the promise", which names nothing and is not even aGuzzleException, so callers filtering on that interface miss it.execute()from such a callback throwsLogicException, instead of returning while transfers are still outstanding, which its own docblock says it never does.LogicException: Cannot change a rejected promise to fulfilled. That one also happens without the two points above, whenever an inline callback waits on its own promise.Behaviour change
A promise waited on from
on_headersoron_statsnow fails withRequestExceptioninstead of silently corrupting the stream. Creating a new request from such a callback and waiting on it fails the same way. Both used to appear to work, at the cost of the corruption above.Test
Four tests, all
MockHttpClient-based and deterministic, no reflection involved:testWaitingFromACallbackDoesNotConsumeASecondStreamasserts the invariant directly, never twostream()iterators alive at once, with a.then()callback waiting on a sibling promise. 2 live iterators before the patch, 1 after.testWaitingFromAnInlineCallbackFailsWithoutStrandingTheHandler,testRunningTheEventLoopFromAnInlineCallbackIsRejectedandtestAPromiseGuzzleRejectedFromAnInlineCallbackIsNotResolvedAfterwardscover the three cases above. The last one fails on 8.1 without any of this patch.