Skip to content

Send a chunk completely or not at all - #202

Merged
mgrossmann merged 2 commits into
mainfrom
issue-201-chunk-all-or-error
Aug 18, 2026
Merged

Send a chunk completely or not at all#202
mgrossmann merged 2 commits into
mainfrom
issue-201-chunk-all-or-error

Conversation

@mgrossmann

Copy link
Copy Markdown
Contributor

Fixes #201

Problem

The chunked path of http_send() checked its three send_raw() calls — chunk header, data, trailing CRLF — only for a negative return, then returned len unconditionally. send_raw() legitimately returns partial progress: the sockets are non-blocking (FIONBIO), so a full TCP send buffer breaks it out on EWOULDBLOCK having written anywhere from 0 to len-1 bytes. The header had already announced len bytes, so the stream went short and the next chunk header — or the 0\r\n\r\n terminator from httpdone() — 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 budget http_printv() got in #200 — the two constants now live in httpd.h so they cannot drift apart. A dead socket still fails immediately.

When a chunk cannot be completed:

  • framing is dropped (chunked = 0), so httpdone() cannot append a terminator that would tell the client a truncated response ended normally;
  • 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;
  • CSTATE_DONE is 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 with chunked == 1, in two real cases (httpget.c:106-127): SSI files, which deliberately get no Content-Length, and UFS files where ufs_stat() fails or reports filesize == 0. For those, a slow reader now waits inside http_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 modules clean (cc370, -Wall -Werror), make test builds all 17 test modules, make test-host 98 assertions pass.
  • No unit test: the logic hinges on send()/errno/socket state and is not DUAL-testable as written. Extracting the stall decision into a pure function (the httpbody.c / httpstat.c pattern) would make it testable and is worth doing if this area is touched again.
  • Not yet run against a live system. The live check is a chunked response to a slow reader — e.g. an SSI page or a filesize == 0 UFS file fetched with curl --limit-rate 2k — which must arrive complete and correctly framed; curl reports a transfer error if any chunk is short.
  • Not addressed here: a worker inside the paced retry does not check 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 in http_printv(), so it belongs in both places or neither — a follow-up, not this PR.

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.
@mgrossmann

Copy link
Copy Markdown
Contributor Author

Two things checked after opening this, both worth recording:

usleep() is safe in worker context. The paced retry here (and the one #200 already merged into http_printv()) calls libc370's usleep(), which is STIMER WAIT — an SVC that suspends a TCB. That is only acceptable if a worker owns its TCB rather than being multiplexed inside a shared one; otherwise one slow client would freeze the whole server for up to 10 s. Confirmed it does: @@ctcrtx.c:87 in libc370 creates every CTHREAD worker with ATTACH EP=CTHREAD,ECB=…, so each worker is a real MVS subtask and STIMER suspends only that worker. Note the existing STIMER WAIT at httpd.c:1208 is not the precedent it looks like — that one is in main(), on the main task.

Invariant now holds by construction (dfcfc7c). send_raw_all() sets CSTATE_DONE itself on every negative return instead of relying on send_raw() having done it: the MSG_SEND_UNDERFLOW guard also returns negative without touching the state, and since http_send_file() ignores -1 and stops on the client state alone, that gap would have been load bearing on a path that is otherwise unreachable.

Also opened #203 for the third item on #199's fix list, which never landed and had no ticket after #199 closed — the unchecked http_printf() cascades in /.dsrv. Not a hang any more since #200, just wasted work against a dead socket.

@mgrossmann
mgrossmann merged commit fb6d31a into main Aug 18, 2026
1 check passed
@mgrossmann
mgrossmann deleted the issue-201-chunk-all-or-error branch August 18, 2026 06:53
mgrossmann added a commit that referenced this pull request Aug 18, 2026
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.
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.

Chunked http_send() claims full success after a partial send_raw(), corrupting chunk framing

1 participant