ADR-040: Without a total timeout, the cancellable send bounds silence
Table of contents
Status
Accepted (amends ADR-037: A cancellable send is a method, not an exported handle)
Date
2026-09-27
Context
ADR-037: A cancellable send is a method, not an exported handle gave the tick loop of
send a defensive wall-clock bound of timeout + connect_ and justified it with one sentence: it "sits strictly above libcurl's own deadlines", so it can only trip when the handler stopped settling its promise.
That sentence is true only when a total timeout is set.
timeout = 0 is the default on TYPO3 13.4 and 14.3, and it gives libcurl no total deadline at all.
The bound is then connect_ — 15 s with the default connect_ of 10 — and it sits above nothing: it ends every call that takes longer, however much the server is still sending, while
send on the same client completes the same call.
Measured in the review of #392 with timeout = 0, connect_ and a server sending 12 events 700 ms apart:
send completed after 7.72 s,
send was aborted after 6.01 s (#394).
The functional test named under Consequences reproduces it against the unchanged code: the call fails with Cancellable transfer exceeded its wall-, audited as http_ / success = false.
The same loop runs the OAuth token leg (OAuth) inside a cancellable call, with the same bound and therefore the same defect.
ADR-039: A streaming send drives the pinned curl transfer from read() met the same problem on the streaming send and decided it there: with a total timeout keep the wall-clock budget, without one bound the server's silence — 60 s (Secure), reset by every final head and every body byte after one.
The factory already puts that idle budget on every transport it builds without a total timeout; only the streaming send read it.
Decision
Every send on the cancellable transport applies the rule of ADR-039:
send,
send and the OAuth token leg.
- With a total timeout (
timeout > 0, the platform value orwith) nothing changes. libcurl enforces the timeout, and the wall-clock budgetTimeout () timeout + connect_sits above it, measured from the start of the transfer. Progress does not extend it (timeout + 5 s with), and the factory still computes it as before (ATotal Timeout Progress Does Not Extend The Wall Clock Budget () the).Factory Gives The Cancellable Send An Idle Bound Only Without ATotal Timeout () - Without a total timeout the wall-clock budget is not applied. The call ends when nothing has arrived for the idle budget, with its own fixed literal and code, audited as a failure under
http_/call success = false— the reasoning of ADR-037 for the wall-clock bound applies unchanged: nobody asked for it, so it is not a cancellation.
| Send | Literal | Code |
|---|---|---|
send | Cancellable transfer received nothing within its idle limit and was aborted | 1786579206 |
| OAuth token leg | Cancellable OAuth token transfer received nothing within its idle limit and was aborted | 1786579307 (OAuth) |
One mechanism, not a copy
The loop in
send and the one in the token leg were private copies of the step Streaming already performs — poll the signal, check the bound, tick, drain the promise queue, observe settlement through then handlers.
Both now drive a Streaming instead of their own loop, so the idle bound, the rule that it is checked after the tick, and the rule that a settled transfer is never aborted are the ones ADR-039 records, not a third version of them.
What stays local is the classification of the outcome: the literals, the exception types and, for
send, the audit ladder.
ADR-037's arguments for this loop's shape — no wait, no SYNCHRONOUS, settlement through handlers, one teardown on every abnormal exit — hold for Streaming as they held for the copy.
What counts as progress
The rule is ADR-039's, and it now lives in one class, Transfer, which all three sends use: a final response head (status 200 and above) counts, and so do the body bytes the sink took while a final head was the current one.
A 1xx head resets the current head, so a server repeating 100 Continue buys no time on any Guzzle version, and the raw bytes behind an unsolicited 101 Switching Protocols count as nothing (a, bytes, token leg a).
A final head after an interim one counts, and so does a trickle after it (a).
send buffers the whole body, so it has no streaming sink to count.
Guzzle's curl handler writes the body into a php:// stream when no sink is given; the send now opens that stream itself and passes it as sink, together with an on_ callback, and counts the stream's size.
The body returned is that stream, as before.
It is deliberately not the streaming send's Streaming: that one holds at most 16 MiB, and
send has never limited a body (a).
sink and on_ are request options the send sets itself; the send still takes no options from its caller, and stream is still never set.
Consequences
- A cancellable call without a total timeout that keeps receiving completes, however long it takes (
without; on the wireATotal Timeout ACall Still Delivering Outlives The Wall Clock Budget () Streaming, the measurement from #394, red before this change with the wall-clock literal).Send Test:: without ATotal Timeout ACancellable Call Still Delivering Outlives The Old Budget () - A server that accepts the connection and sends nothing ends the call after 60 s instead of
connect_(timeout + 5 s without,ATotal Timeout ASilent Server Ends At The Idle Bound () without; on the wire, with the bound shortened to 1 s,ATotal Timeout ACall That Goes Quiet After Its Head Ends At The Idle Bound () without; token legATotal Timeout ASilent Server Ends ACancellable Call At The Idle Bound () without,ATotal Timeout ASilent Token Endpoint Ends At The Idle Bound () without). That is later than before; the signal still ends such a call at any moment.ATotal Timeout ASlow Token Endpoint Still Completes () - At
timeout = 0the 60 s window starts when the transfer is built, so it also covers the connect, the TLS handshake and the upload of the request body: a slow upload to a server that sends nothing back until it has the whole body gets 60 s, where the wall-clock budget gave itconnect_(15 s by default), and with atimeout + 5 s connect_above 55 s the window is tighter than that budget was.timeout - A server that trickles a byte every few seconds keeps the call open indefinitely, exactly as it keeps
sendopen atRequest () timeout = 0. An operator who needs a hard ceiling setstimeoutor callswith. ADR-039 gives the reason a low-speed limit is not added.Timeout () Securekeeps its name although it now bounds non-streaming sends too; renaming a public constant is not worth the break.Http Client Factory:: STREAMING_ IDLE_ BUDGET_ SECONDS - A refresh-token round trip that ends at the idle bound throws
OAuthwith its own code, like one that ends at the wall-clock bound;Exception fetchfalls back toToken With Fallback () client_only for a rejected refresh token, so neither bound triggers a second round trip.credentials