Skip to content

Answer Expect: 100-continue before anything reads the body - #208

Merged
mgrossmann merged 1 commit into
mainfrom
issue-207-expect-100-continue
Aug 19, 2026
Merged

Answer Expect: 100-continue before anything reads the body#208
mgrossmann merged 1 commit into
mainfrom
issue-207-expect-100-continue

Conversation

@mgrossmann

@mgrossmann mgrossmann commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Fixes #207.

Answers Expect: 100-continue at the end of http_in(), so a client that is
holding its body back is released before anything tries to read it.

Placement

The constraint is that it must precede every body read in the server, and
there are two: httppars() consumes a POST body under CBUFSIZE (4000) bytes
itself into POST_STRING, and a CGI reads a PUT body straight off the socket.
The end of http_in() precedes both, so no route type added later can forget
it. It also sits after the 414/505/400 request validation, so a request httpd is
about to reject never draws an interim response.

It does mean the expectation is answered before the auth gate runs, so a client
whose request will be refused still uploads its body. That is exactly what it
does today — after waiting out its own timeout first — so nothing gets worse,
and moving the call past the gate would put it after httppars() has already
read the POST body it was supposed to unblock.

Why not http_resp()

http_resp(httpc, 100) fails twice over. httpstat() carries no phrase for
100, so httpresp() would substitute 500 Internal Server Error (and WTO
HTTPD054E); and it sets httpc->resp, which is what httpprtv() tests when
deciding whether a bare \r\n terminates the final response's headers —
setting it here would change how the real response gets framed. One
http_printf() of the whole 25-byte interim response touches neither.

Guarded on HTTP/1.1 (RFC 7231 §5.1.1 — 1.0 has no Expect), on the value being
100-continue (caseless, via http_cmp), and on a body actually being
announced.

Verification

Downstream: mvslovers/mvsmf#313 carries a curl regression case that sends
Expect: 100-continue explicitly. Against a server without this change it costs
curl its full 1 s expect100-timeout before the body goes out; against one with
it, the body follows the interim response immediately. Both need a deployed
HTTPD to measure, so the live check is still outstanding — the change builds
clean and is unmeasured on MVS so far.

Two things checked rather than assumed

The env name resolves. httpshen() builds "HTTP_%s" from the header name
as the client spelled it, so Expect: is stored as HTTP_Expect, not
HTTP_EXPECT. The lookup survives that only because http_find_env() compares
with the caseless http_cmp() — checked in httpfenv.c, not inferred from the
existing HTTP_CONTENT-LENGTH call sites. Had it been strcmp, this PR would
have been a no-op that builds clean and reviews fine.

Answering before the auth gate does not strand a rejected upload. The worry
is real in shape: after this change a client with bad credentials gets 100,
starts sending, and ~1 ms later auth_gate() answers 401 while nodata: has
already cleared keepalive, so the socket closes with a large body in flight.
Simulated against a listener that reproduces exactly that sequence, 1.44 MB body:

Auth takes Result
~1 ms 401, 0.002 s
200 ms 401, 0.211 s
1 s 401, 1.011 s
3 s 401, 3.003 s

curl reads the response it already has rather than reporting the write error, in
every variant. So the early placement stands and the two-point alternative
(after auth_gate for PUT, inside postdata for POST) is not worth its extra
surface.

Verified on MVS

Built clean from this branch, deployed and activated in the running STC.

The interim response goes out, ahead of both the route lookup and the auth
gate, exactly as placed:

PUT /zosmf/nosuchroute HTTP/1.1 + Content-Length + Expect: 100-continue
  unauthenticated -> HTTP/1.1 100 Continue   (0.078 s)
  authenticated   -> HTTP/1.1 100 Continue   (0.079 s)

On the wire it is the intended 25 bytes and nothing else —
b'HTTP/1.1 100 Continue\r\n\r\n' — so httpc->resp and httpprtv()'s
header-terminator detection are untouched and the final response frames
normally.

What it cost the client before, and what it costs now (mvsmf#313's regression
case, a two-line body with an explicit Expect: 100-continue):

Without this change With it
PUT, explicit Expect 1.05 s 0.068 s (3 runs: .0679/.0678/.0688)
PUT 1.44 MB, curl adds Expect itself 12.2 s 11.1 s

The 1 s that disappears is curl's expect100-timeout running out because
nobody answered.

The auth-failure path holds up on the real server too, which is the part the
local simulation could only approximate — a client now starts sending
immediately and auth_gate() answers underneath it with keepalive already
cleared:

Case Result
unknown route, valid credentials 404, 0.072 s, curl exit 0
bad credentials, real route 401, 0.038 s, curl exit 0
bad credentials, 1.44 MB body in flight 401, 0.071 s, curl exit 0

A raw-socket probe does draw a ConnectionResetError here, but that is an
artefact of the probe: it printed between reading the interim response and
sending the body, long enough for the server to answer 404 and close first. No
real client inserts that pause, and curl reports the status cleanly in all three
rows.

Regression, all against the deployed HTTPD: curl-datasets.sh 195/195,
curl-binary.sh 10/10, curl-uss.sh 128/128, curl-jobs.sh 111/111 + 2
skipped, curl-console.sh 64/64.

A client sending "Expect: 100-continue" holds its body back until the
server says it will read one. httpd answered neither 100 nor 417, so the
client waited -- and the reader on this side called recv() on a socket
that was empty precisely because of that wait. Sockets are non-blocking,
so the call returned EWOULDBLOCK at once and the request died in under a
tenth of a second with no byte of the body sent.

The answer goes at the end of http_in() because that is the single point
preceding every body read in the server: httppars() consumes a POST body
under CBUFSIZE itself, a CGI reads a PUT body off the socket. It also
comes after the 414/505/400 validation, so a rejected request draws no
interim response.

Emitted as one http_printf() rather than through http_resp(): httpstat()
carries no phrase for 100 and would answer it with 500, and http_resp()
sets httpc->resp, which httpprtv() reads when deciding whether a bare
CRLF ends the final response's headers.

Fixes #207
@mgrossmann
mgrossmann merged commit 94901d2 into main Aug 19, 2026
1 check passed
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.

Expect: 100-continue is never answered: the client waits, the body reader gets EWOULDBLOCK

1 participant