Send a chunk completely or not at all - #202
Conversation
The chunked path of http_send() checked its three send_raw() calls -- chunk header, data, trailing CRLF -- only for a negative return, then reported len unconditionally. But send_raw() legitimately returns partial progress: the sockets are non-blocking, so a full TCP send buffer breaks it out on EWOULDBLOCK with anywhere from 0 to len-1 bytes written. The header had already announced len bytes, so the stream went short and the next chunk header, or the terminator from httpdone(), landed mid-chunk where the client expected data. From there the framing is garbage. The caller meanwhile was told everything went out, so no layer could even attempt recovery. Header, data and trailer now go through send_raw_all(), which completes the buffer or fails: a closed receive window is waited out with the same paced retry and no-progress budget http_printv() uses, now shared in httpd.h so the two cannot drift apart. A dead socket still fails at once. When a chunk cannot be completed the response is unparseable for good, so framing is dropped -- otherwise httpdone() would append a "0\r\n\r\n" telling the client a truncated response had ended normally -- and keep-alive is cleared, on the reasoning httppars() already applies to an unread body: the connection must not carry a torn chunk into the next request. Every failing return leaves the client at CSTATE_DONE, because http_send_file() tests only for a positive return and that state is what stops it. The non-chunked contract is untouched: a partial return stays visible there, which is what http_send_file() drives its state machine on. Fixes #201
send_raw() sets the state on its hard-error return, but the MSG_SEND_UNDERFLOW guard breaks out with a negative pos without touching it. That path is defensive and should not be reachable, yet the invariant it would break is load bearing: http_send_file() ignores a negative return and stops on the client state alone. Set it on every negative return so the invariant holds by construction.
|
Two things checked after opening this, both worth recording:
Invariant now holds by construction (dfcfc7c). Also opened #203 for the third item on #199's fix list, which never landed and had no ticket after #199 closed — the unchecked |
Verifying the shutdown-quiesce fix (#205) live turned up a Hercules platform bug that had been latent all along: X'75' SEND (tcpip.c) issues a blocking host send() on the emulated CPU thread, so a client that holds a connection open and stops reading fills the host send buffer, freezes a CP, and the watchdog reacts to the stalled CP by deliberately crashing the whole emulator. It killed the machine five times before we understood it, and it is not a test artifact -- a laptop closing or a VPN dropping without RST mid-response is the same traffic shape. The new document records the root cause with the gdb backtrace, the three preconditions that have to coincide, the exact client script, and RED/GREEN criteria, so the next person can re-verify after a Hercules update rather than rediscover it. It also explains why our earlier tests never hit it: aborting clients produce a reset (send fails immediately), slow-but-reading clients keep making progress, and the #199 spin executes billions of instructions, which looks healthy to the watchdog. The two findings are linked: until SEND stops blocking, a full send buffer never surfaces EWOULDBLOCK to the guest, so the stall budget from #200/#202 and the quiesce check from #205 are unreachable on this platform. With SEND patched, both fired live for the first time -- P HTTPD under continuous stall load now completes in 3 s instead of requiring C HTTPD. Cross-referenced from the shutdown-drain section in development.md, which covers the same discipline for module authors.
Fixes #201
Problem
The chunked path of
http_send()checked its threesend_raw()calls — chunk header, data, trailing CRLF — only for a negative return, then returnedlenunconditionally.send_raw()legitimately returns partial progress: the sockets are non-blocking (FIONBIO), so a full TCP send buffer breaks it out onEWOULDBLOCKhaving written anywhere from 0 tolen-1bytes. The header had already announcedlenbytes, so the stream went short and the next chunk header — or the0\r\n\r\nterminator fromhttpdone()— landed mid-chunk where the client expected data bytes. From there the framing is garbage. The caller was meanwhile told everything went out, so no layer could attempt recovery.Fix
Header, data and trailer go through a new
send_raw_all(), which completes the buffer or fails. A closed receive window is waited out with the same paced retry and no-progress budgethttp_printv()got in #200 — the two constants now live inhttpd.hso they cannot drift apart. A dead socket still fails immediately.When a chunk cannot be completed:
chunked = 0), sohttpdone()cannot append a terminator that would tell the client a truncated response ended normally;httppars()already applies to an unread body — the connection must not carry a torn chunk into the next request;CSTATE_DONEis set on every failing return. This matters:http_send_file()(httpfile.c:100,:333) tests only for a positive return and ignores-1, so the client state is what actually stops it.The non-chunked contract is deliberately untouched — a partial return stays visible there, which is what
http_send_file()drives its state machine on.Note: this path is reached by static files, not just CGIs
Worth recording, since it was checked rather than assumed.
http_send_file()does run withchunked == 1, in two real cases (httpget.c:106-127): SSI files, which deliberately get noContent-Length, and UFS files whereufs_stat()fails or reportsfilesize == 0. For those, a slow reader now waits insidehttp_send()(bounded at 10 s) instead of returning to the state machine for another pass. That is the intended trade: a chunk cannot be made atomic any other way, and the previous behaviour on a slow reader was a busy pass-loop that #159's 1000-pass escape could cut off mid-response.Verification
make modulesclean (cc370,-Wall -Werror),make testbuilds all 17 test modules,make test-host98 assertions pass.send()/errno/socket state and is not DUAL-testable as written. Extracting the stall decision into a pure function (thehttpbody.c/httpstat.cpattern) would make it testable and is worth doing if this area is touched again.filesize == 0UFS file fetched withcurl --limit-rate 2k— which must arrive complete and correctly framed;curlreports a transfer error if any chunk is short.HTTPD_FLAG_QUIESCE/SHUTDOWN(same family as Shutdown crash: S33E on worker DETACH + recovery ESTAE runs C runtime under a torn-down CRT #122). That applies equally to the pause Fail dead-socket sends instead of reporting zero progress #200 already put inhttp_printv(), so it belongs in both places or neither — a follow-up, not this PR.