Wait for the full request body before dispatching to a handler - #43
Conversation
handle-readable gated dispatch on the CRLFCRLF header terminator alone and never consulted Content-Length, so any request whose headers plus body did not fit in a single 4096-byte read reached the handler truncated. Form and multipart decoding silently lost data, uploads over ~4KB were broken, and conn-done-writing then cleared the read buffer so the leftover body bytes were parsed as a fresh request line, answering a spurious 400 after the wrong 200. Add web-request-complete?, a pure predicate over the raw byte buffer that returns 1 (complete), 0 (need more bytes) or -1 (malformed framing), and swap it in at the dispatch gate. It scans the header region case-insensitively for Content-Length and Transfer-Encoding and rejects the RFC 7230 3.3.3 ambiguities: both headers present, a duplicate Content-Length with a different value, a non-numeric or negative value, and a Transfer-Encoding whose final coding is not chunked. Chunked bodies wait for the terminating zero-length chunk without being decoded. A request with neither header has no body and is complete once its headers are, so existing GET, HEAD and WebSocket upgrade behavior is unchanged. The event-loop delta is one predicate swap plus reuse of the existing 400 write path; buffer growth is still bounded by App.max-request-size, and read-start is still cleared as soon as the headers are complete so the header timeout does not cover the body. web-has-header-end is factored into an index-returning form with the boolean kept as a wrapper. It previously had no tests; it and the new predicate now have 27.
There was a problem hiding this comment.
Build & Tests
I reproduced your local build result exactly: carp -x test/web.carp gets all the way through macro expansion, type check, borrow check and codegen, emits a 57k-line main.c, and then dies at clang on the pre-existing time@0.2.0 tm_zone const-qualifier errors via http@0.1.4. Nothing in the diff is implicated. Your characterization is accurate.
CI is macOS-only by design here (matrix: os: [macos-latest]), so the single check is the full matrix, not a missing job — and web's test step is not continue-on-error, so that green check is load-bearing.
Since I can't run the suite locally, I did the next best thing rather than take the 27 assertions on trust: I extracted web.carp:1018-1175 verbatim from the committed branch into a standalone core-Carp harness and wrote my own case set against it. That reproduces your result on the happy paths and the malformed-framing paths — all of these pass:
CL body fully/partially present, CL 0, no headers at all, pipelined over-read,
uppercase CONTENT-LENGTH, X-Content-Length ignored, Content-Lengthx ignored,
CL+TE -> -1, conflicting duplicate CL -> -1, identical duplicate CL -> 0,
non-numeric/empty/+5/-5/0x5 CL -> -1, TE: gzip -> -1, TE: gzip,chunked -> 0,
absurd CL saturates without going negative, leading-zero CL
I also prefix-fuzzed ~700 prefixes across 25 adversarial inputs: no crashes, no out-of-range status. Given Carp's unchecked byte indexing, that's the thing I most wanted to confirm independently, and the bounds discipline in web-bytes-match?/web-crlf-index holds up.
The Content-Length parser in particular is genuinely good — the saturation trick at :1077 is the right call, and the (> digits 0) + (= i limit) pair is what makes +5/0x5 fall out correctly.
Findings
Four things, two of which I think are blocking. All measured against the committed code.
1. The chunked gate is O(n²) in body size — ~15s of CPU for one 1MB upload
web-chunked-end? (:1108) rescans the entire accumulated body on every readable event. Since the buffer now grows to the full body (that's the point of the PR), the cost per request is quadratic. Timing only the gate call, excluding buffer construction, at 4096-byte reads:
--- Content-Length framing ---
CL body=65536B events=17 GATE-ONLY=0ms
CL body=262144B events=65 GATE-ONLY=0ms
CL body=1048000B events=256 GATE-ONLY=1ms
--- chunked framing (same body sizes) ---
chunked body=65536B events=17 GATE-ONLY=72ms
chunked body=262144B events=65 GATE-ONLY=1019ms
chunked body=1048000B events=256 GATE-ONLY=15652ms
4× the body → ~14-15× the time; textbook quadratic. The Content-Length path is (>= (- len body-start) cl), O(1) after headers, and it shows — 1ms for the same 1MB.
Why this is blocking: web's event loop is single-threaded, so those 15 seconds are not "this connection is slow", they're the whole server stopped, at a body size the framework's own max-request-size explicitly permits. And it gets worse with smaller reads — a client dripping bytes gets one gate call per byte, each scanning everything received so far.
This is new. Before this PR the gate ran on a header-sized buffer and never rescanned a body.
The cheap state-free direction: for a well-formed chunked request the terminator is the end of the request, so testing whether the buffer ends with \r\n0\r\n\r\n is O(1) — and it also kills finding 5 below. It doesn't cover trailers (finding 2), so a real chunk-walker is the correct long-term answer; I'd understand deferring that, but the quadratic scan needs to go either way.
2. Chunked requests with a trailer section never complete
RFC 7230 §4.1.2 permits trailer fields after the last chunk. web-chunked-end? only ever looks for \r\n0\r\n\r\n, so a last-chunk followed by a trailer never matches:
last-chunk + trailer expected=1 got=0 <-- "...\r\n0\r\nExpires: x\r\n\r\n"
last-chunk, no trailer expected=1 got=1
Status stays 0 forever. The request is complete on the wire, the client is waiting for a response, and it gets nothing until the 60s idle-timeout (:1648) closes the connection.
This is also a regression in kind: before this PR such a request was dispatched (with a truncated body) and the client at least got an answer. Now it gets a 60-second hang and a dropped connection.
3. web-chunked-value? accepts any token ending in "chunked"
:1097 trims trailing OWS and compares the final 7 bytes, with no check that a delimiter precedes them:
TE: xchunked expected=-1 got=0
TE: not-chunked expected=-1 got=0
TE: chunked expected=-1 got=0 (control, correctly 0)
Both bogus codings are treated as chunked and wait for a terminator, where §3.3.3 — the section the PR cites — requires rejecting them. The byte before the match needs to be start-of-value, ,, SP or HTAB.
4. Content-Length : 5 is silently ignored
A space between field-name and colon defeats the content-length: match, so the header is skipped entirely:
CL with space before colon expected=-1 got=1
The request dispatches as bodyless and the 5 body bytes are then read as the next request line — which is precisely the "spurious 400 after the wrong 200" behavior this PR exists to eliminate, reached through a different door. RFC 7230 §3.2.4 requires a 400 here. Not a regression (Content-Length was never consulted before), but it sits squarely inside the framing correctness this PR claims.
5. Disclosed limitation, confirmed
Chunk data containing the terminator bytes does dispatch early:
data contains terminator -> dispatches early expected=0 got=1
You flag this in the comment at :1105-1107, so I'm only recording that it reproduces. The ends-with approach in finding 1 would narrow it considerably.
What's right
Worth saying explicitly, because the risky part is well handled: the event-loop delta is correctly ordered and genuinely minimal. too-big is still evaluated before the gate, so buffer growth stays bounded and there's no new 413 path; -1 reuses the existing serialize/clear/close 400 path rather than inventing one; and moving the read-start reset to fire on header-complete regardless of status is the right way to keep header-timeout covering only the header block. The scope discipline you described is real, and the CHANGELOG entry is correctly filed under a new ## Unreleased rather than folded into the shipped 0.8.0 section.
On your open question about slow-drip bodies holding connections open — agreed that's a policy call for you, and I think leaving it out was right. I'd note it interacts with finding 1: until the quadratic scan is fixed, a slow drip isn't just holding a connection, it's consuming the event loop.
Verdict: revise
The bug this fixes is real and the diagnosis is excellent — truncating every request over 4KB is exactly the kind of thing worth this much care, and the Content-Length path is correct, cheap, and well tested. But findings 1 and 2 are both new regressions introduced by the change: a 1MB chunked upload now costs ~15s of single-threaded event-loop CPU, and a legal chunked request with trailers now hangs for 60s instead of being answered. Both are in the chunked terminator search, so both are fixable in web-chunked-end? without touching the event loop or the Content-Length path. Correctly left as a draft.
The chunked gate rescanned the whole accumulated body on every readable event, which is quadratic in body size and stalled the single-threaded event loop for ~15s on a 1MB upload that max-request-size permits. It also only ever looked for the bare terminator, so a request with a trailer section never completed and hung until the idle timeout. web-chunked-status now walks the real framing: parse the hex chunk-size line, skip that many data bytes plus their CRLF by index arithmetic, repeat, and on the zero-size chunk consume the trailer section up to its blank line. That is O(chunks) rather than O(body bytes), it handles trailers, and skipping over chunk data means data holding the terminator bytes can no longer end the wait early. Malformed chunk framing joins the existing 400 path. Two header-scan fixes alongside it: a Transfer-Encoding coding merely ending in "chunked" (xchunked, not-chunked) is no longer treated as chunked, and whitespace between a field name and its colon is rejected per RFC 7230 3.2.4 instead of silently skipping the header.
|
Thanks — all five findings addressed. As you suggested, I replaced the substring terminator search with a real incremental chunk-framing walker rather than patching the scan, which takes out (1), (2) and (5) together.
(1) Quadratic gate. Re-running your benchmark shape — gate-only CPU, 4096B reads, buffer built outside the timer:
For reference the Residual, and I don't want to overclaim it: the walk is O(chunks), not O(1), and it still restarts from So the case you measured and called blocking — a normal 1MB chunked upload — is gone (626×), but an adversarial one is improved only ~2.8× and 7s of event-loop stall is still reachable. Killing that needs the resumable cursor you and the topic both floated, and a cursor needs per-connection state in (2) Trailers. (3) (4) (5) Terminator inside chunk data. Now impossible by construction — the walker never looks at data bytes. VerificationSame approach as before, since I extracted To check the harness wasn't vacuous, I ran the same 76 cases against the old committed code: 17 fail, and they are exactly your findings (2), (3), (4), (5) plus six chunk-framing defects the walker now catches that the substring search never could ( Prefix-fuzzed 1899 prefixes across 45 adversarial inputs aimed at the new unchecked indexing ( Finally, I extracted the 13 new
Still a draft. |
There was a problem hiding this comment.
Build & Tests
Reproduced your local result exactly: carp -x test/web.carp runs the full front-end clean and emits a 72,674-line main.c, then clang dies on 9 tm_zone const-qualifier errors in the time@0.2.0 region via http@0.1.4. Nothing in the diff is implicated. I confirmed the front-end really did get all the way through the new code rather than stopping short — every new function is present in codegen (web_MINUS_request_MINUS_complete_QMARK_, web_MINUS_chunked_MINUS_status, web_MINUS_trailer_MINUS_status, web_MINUS_parse_MINUS_chunk_MINUS_size, web_MINUS_hex_MINUS_digit, web_MINUS_space_MINUS_before_MINUS_colon_QMARK_). CI is macOS-only by design and is not continue-on-error, so the single green check is load-bearing.
I extracted web.carp:1018-1268 verbatim from the committed branch into a standalone core-Carp harness and wrote my own 48-case set. All 48 pass. To make sure that wasn't a harness that can't fail, I ran the identical case set against HEAD~1 — the version I reviewed last round — and 14 fail, exactly your findings (2)–(5) plus seven chunk-framing defects the walker catches that the substring search couldn't:
FAIL F2 last-chunk + trailer FAIL N02 chunk extension on last chunk
FAIL F2 last-chunk + multi-field trailer FAIL N03 '00' last chunk
FAIL F3 TE: xchunked FAIL N06 non-hex size
FAIL F3 TE: not-chunked FAIL N07 empty size line
FAIL F4 Content-Length : 5 FAIL N08 junk after size
FAIL F4 X-Foo : 1 FAIL N09 data not followed by CRLF
FAIL F5 terminator-in-data (no last chunk) FAIL N10 absurd size
Also prefix-fuzzed and byte-mutation-fuzzed the gate — 2,991 calls across 30 adversarial inputs, aimed at the new unchecked indexing (after/after+1 in the data skip, (- e 8) in the delimiter check, (- c 1) in the colon check, (+ nl 2) in the trailer walk). No crashes, no out-of-range status. I also checked the three loops for termination: web-chunked-status advances pos past a strictly positive chunk each iteration, web-trailer-status advances p by ≥2, and the header scan advances line-start by ≥2 — none can spin.
Prior feedback
All five addressed, and I verified each independently rather than from the description:
- (1) quadratic gate — fixed for the realistic case, see below.
- (2) trailers —
0\r\nExpires: x\r\n\r\n→ 1, multi-field trailer → 1, unterminated trailer correctly still 0. - (3)
xchunked—xchunkedandnot-chunked→ -1;chunked,gzip, chunked,gzip,chunked,\tchunkedunaffected. I also checked the case neither of us raised: multipleTransfer-Encodingheader lines. Last-wins turns out to be exactly right —chunkedthengzip→ -1,gzipthenchunked→ 0, both matching the comma-joined reading in §3.3.3. - (4)
Content-Length : 5→ -1, request line and in-value colons unaffected. - (5) terminator in chunk data —
5\r\n\r\n0\r\n\r\n0\r\n\r\n→ 1 and the same body without the real last chunk → 0, so it's genuinely impossible by construction now.
On your open question about X-Foo : 1 also 400ing: don't narrow it. §3.2.4 is "a server MUST reject any received request message that contains whitespace between a header field-name and colon", for every field, not just the framing ones. The general rule you implemented is the correct reading; restricting it to Content-Length/Transfer-Encoding would be less conformant, not more conservative.
Findings
One, and it's the residual you disclosed — but I think it's worth more than the 2.8× framing, because the right comparison isn't against your own previous commit, it's against what merging this does to main.
The adversarial chunked case is a new multi-second stall relative to the merge target
Confirmed your normal-case numbers first (gate-only CPU, 4096-byte reads, buffers built outside the timer):
chunked 64KB 0.22ms CL 64KB 0.09ms
chunked 256KB 2.12ms CL 256KB 0.35ms
chunked 1MB 28.76ms CL 1MB 1.39ms
28.76ms against the 15,652ms I measured last round — the 1MB upload stall is gone, ~545×, and that was the blocking finding. Good.
Now the worst case App.max-request-size actually permits — a 1,044,052-byte buffer of 1-byte chunks (174,000 chunks), replayed over 255 readable events:
this branch web-request-complete? 7,353.94ms
origin/main web-has-header-end 0.39ms
Your disclosed figure was 7,304ms; I got 7,353.94ms independently, so your measurement is accurate to well under a percent. But main's gate breaks out of web-header-end-index as soon as it finds the CRLFCRLF, so it is O(header bytes) regardless of body — 0.39ms. Merging this therefore introduces a ~18,700× increase on that input: 7.4 seconds during which the single-threaded event loop serves nobody, triggered by one request that is under the size cap, costs the client ~1MB, and can simply be repeated.
I don't think that changes your judgment call about this diff — you were right that adding untestable mutable event-loop state in the same change that fixes a stall is a bad trade, and right to leave it in draft. But it does mean the residual isn't "improved 2.8×, good enough for now"; it's a remotely-triggerable DoS that doesn't exist on main today, and it should close before this leaves draft.
The cursor is also smaller than your write-up suggests. ConnState is already 18 per-fd maps, four of them Map Int Int, and read-start is the exact template: it's touched in precisely three places — :1764 (reset to 0 on done-writing), :1790 (Map.remove! on close), :1817 (init to 0 on accept). A chunk-cursor (Map Int Int) mirroring those three lines, plus a reset at :2174 where the buffer is replaced with remaining after a pipelined dispatch, is the whole invalidation story — and it makes the walk resume from the last verified chunk boundary instead of body-start, which is O(total chunks) amortised rather than O(chunks²). Notably it needs no new lifecycle points, only new statements at existing ones, so the event-loop delta stays reviewable.
Verdict: revise
The chunk walker is a genuine improvement over the terminator search on every axis I could measure — all five prior findings are fixed, my 48-case set passes where 14 of them fail on the previous commit, the fuzzing is clean, and the realistic 1MB upload went from a 15.6s server stall to 29ms. The framing correctness here is now better than what's on main in every respect but one. That one is that a 1MB request of 1-byte chunks still buys 7.4s of total event-loop stall against 0.39ms on main, which is a new remotely-triggerable DoS rather than an inherited one, so I'd land the ConnState cursor before this comes out of draft. Correctly left as a draft in the meantime.
The walk restarted from body-start on every readable event, so framing a chunked body cost O(chunks) per read and O(chunks^2) over the request. At the max-request-size cap that is 7.4s of event-loop stall for one 1MB request of 1-byte chunks, against 0.46ms on main. ConnState now carries a chunk-cursor per fd recording the last boundary whose chunk was fully verified, so the walk resumes there instead. The cursor also covers the trailer section, which had the same quadratic shape: a last chunk followed by an unterminated trailer flood cost 4.4s. Adversarial 1-byte chunks 7379.61ms -> 58.49ms, trailer flood 4463.83ms -> 35.85ms, and a normal 1MB upload 24.36ms -> 2.10ms.
|
Cursor landed in What changed
The trailer section had the same quadratic shape, which I did not see until I went to write the cursor. MeasurementsGate-only CPU, 4096-byte reads, buffers built outside the timer, all three variants in one process. I got 7,333.73ms for Worst single event is the number I'd weigh most, since that is what the loop actually blocks on: 57.42ms → 0.24ms, 239×. The total is 123× better, and the normal 1MB upload improved 11× as a side effect. The residual, stated honestly59.56ms is still 129× The 129× against One thing I found that is not mine to fix hereNeither gate bounds the header scan, so a request with no CRLFCRLF at all rescans the whole buffer every read. Same input shape, 174,000 header lines: Within 0.6% across all three — inherited from Verification
Extracted the gate after
Since the cursor is a pure optimization, an A/B against My first harness caught only three of those four. I had an escape hatch that suppressed a mismatch when replay returned 8 new assertions in Two deliberate calls, say the word if you'd rather have them otherwise: Staying a draft. |
There was a problem hiding this comment.
Build & Tests
Reproduced your local result to the line: carp -x test/web.carp runs the full front-end clean, emits a 73,093-line main.c, then clang dies on 9 tm_zone const-qualifier errors. I checked all 9 are in the time@0.2.0 region via http@0.1.4 and none are in web code (grep error: | grep web_ → 0), and confirmed the new code reached codegen: web_MINUS_request_MINUS_status, web_MINUS_chunked_MINUS_status, web_MINUS_trailer_MINUS_status, web_MINUS_parse_MINUS_chunk_MINUS_size and ConnState_chunk_MINUS_cursor are all present.
Branch is on current main (merge-base == origin/main == c5e7330), so the ## Unreleased section is correctly above the shipped 0.8.0 rather than folded into it. The cursor commit adding no CHANGELOG entry is right — the stall it fixes only ever existed on this branch.
One scope note on the green check, since it's easy to over-read: web's CI runs test/web.carp, test/websocket.carp and gendocs only — there is no angler or carp-fmt step (semver and file both have one; web doesn't). So the green check is load-bearing for the 48 gate assertions including your 8 new cursor ones, which is the part that matters, but your carp-fmt -c / angler claims are the only ones nothing but your own run backs.
Prior feedback
The cursor landed and does what I asked. Verified independently below.
One correction, and it's mine to make. Last round I said the invalidation story was four points — the three read-start mirrors plus "a reset at :2174 where the buffer is replaced with remaining after a pipelined dispatch." I was wrong about that fourth one. That remaining site (now :2205-2208) is inside the WebSocket frame loop, and handle-readable routes any fd in ws-route-idx to handle-ws-readable before the HTTP gate is ever reached, so a cursor can't be consumed there. I traced every mutation of read-bufs: it is only appended to, clear-buf'd, or replaced on the WS path — and HTTP does not preserve leftover bytes between requests at all, it clears the whole buffer. Every completion path funnels through conn-done-writing (conn-done-sendfile delegates to it), which clears the buffer and resets the cursor in the same block. Your three points are complete; adding a fourth would have been dead code.
Findings
1. The staleness guard does not provide the property its comment claims
web.carp:1174-1175 says a stale cursor "cannot skip unread bytes", and your PR comment puts it as "a missed reset degrades to a redundant full walk rather than a read of the wrong bytes." That holds for out-of-range cursors only. I probed every possible cursor value against several buffers:
complete chunked len=63 body-start=48 correct=1
accepted by guard, WRONG answer: 17
rejected by guard, wrong answer: 0
INCOMPLETE chunked len=58 body-start=48 correct=0
accepted by guard, WRONG answer: 10
rejected by guard, wrong answer: 0
... of those, ones that report an INCOMPLETE body as complete (0 -> 1): 2
INCOMPLETE, two chunks len=60 body-start=48 correct=0
accepted by guard, WRONG answer: 11
... report incomplete as complete (0 -> 1): 4
The guard is exactly a range check: it rejects every out-of-range value (0 wrong answers there, so that half works perfectly), and accepts every in-range one without validating it. An in-range stale cursor doesn't cause a redundant walk — it resumes at the wrong offset and returns a wrong answer, and in the worst case that answer is 1 on an incomplete body, i.e. dispatching a truncated body, the exact bug this PR exists to fix.
The other half of the picture, from mutation-testing my own harness:
m1 record boundary past the size line -> 61 failures
m2 off-by-one on the boundary -> 909 failures
m3 hand back positive cursor in trailer -> 1289 failures
m5 ignore the negative-cursor phase -> 1289 failures
m4 DELETE THE STALENESS GUARD ENTIRELY -> 0 failures
Deleting the guard changes nothing across 16,760 equivalence checks, because under any legitimate cursor sequence the value is always in range anyway. So the guard never fires in correct operation, and in the one situation it exists for it doesn't do the job.
To be clear about severity: this is not a bug today. The code is correct, because the invalidation is complete — I verified that above. My concern is that the comment states the wrong reason for its correctness. Someone later adding an HTTP pipelining path that preserves leftover bytes (replacing the buffer with remaining, exactly as the WS path already does), or a new completion path that bypasses conn-done-writing, would read that comment and conclude the guard has them covered. It doesn't. Suggested fix is just honesty in the comment: say it's a range check that catches an obviously-foreign cursor, and that the actual invariant is every path that clears read-bufs also resets chunk-cursor.
2. The two stale-cursor tests only cover the half that works
test/web.carp pins cursor 9999 (past the end) and 2 (before the body). Both are out-of-range — the cases the guard handles correctly. Nothing covers an in-range stale cursor, which is the case that misbehaves. If you keep the guard as-is, a test asserting what an in-range stale cursor actually does would at least stop the next reader assuming it's validated.
3. Performance: every number reproduces
Measured all three variants myself in one run on this machine, gate-only CPU, 4096-byte reads, prefixes built outside the timer:
| case | origin/main |
b197171 (prev) |
this commit |
|---|---|---|---|
| adversarial 174k 1-byte chunks, total | 0.51 ms | 7346.65 ms | 58.89 ms |
| — worst single event | 0.0035 ms | 57.31 ms | 0.278 ms |
| trailer flood 174k lines, total | 0.51 ms | 4420.68 ms | 37.33 ms |
| — worst single event | 0.003 ms | 34.53 ms | 0.171 ms |
| normal 1MB upload, total | 0.51 ms | 26.46 ms | 2.70 ms |
Your figures were 59.56 / 36.47 / 2.14 ms and 0.24 ms worst — we agree within ~1-2% on the two adversarial cases. 125× on the adversarial total and 206× on the worst single event. The blocking finding from last round is genuinely closed: 0.278 ms is not an event-loop stall, and your framing of the residual as a shape change rather than a constant-factor win is the correct reading — I agree 115× against main is the honest price of framing chunked bodies at all.
4. The unbounded header scan is inherited, exactly as you said
no CRLFCRLF, 174000 header lines: origin/main 4251.66 ms this branch 4256.62 ms
0.12% apart. Confirmed unchanged by this PR and correctly out of scope — though it is worth its own issue, since 4.2s of event-loop stall is reachable on main today and header-timeout bounds the wait, not the CPU.
5. Robustness under unchecked indexing
Between the equivalence sweep (16,760 gate calls across 20 inputs × 7 read sizes) and the stale probe (~1,000 calls at arbitrary cursors, including in-range garbage that resumes mid-chunk-data), no crashes and no out-of-range status. That's the result I most wanted given Carp's unchecked byte indexing, and it holds because the (< (- len (+ nl 2)) (+ size 2)) guard runs before the after/after+1 access, so even a garbage resume offset can't index past the end.
6. Still open from round 1
The slow-drip body can still hold a connection open indefinitely — read-start is cleared at header-complete and last-active refreshes on every byte. Still your policy call, still not a regression, and I still think leaving it out was right. Flagging it only so it doesn't get lost when this leaves draft, since it's now the last disclosed item.
Verdict: revise
Narrow revise, and the substantive engineering is done. The cursor is correct, the invalidation is complete (your three points, not my four), all five earlier findings stay fixed, the equivalence sweep passes at every prefix across seven read sizes, my harness demonstrably catches four of five injected cursor bugs, and the blocking DoS from last round is closed with numbers I reproduced to within 2%. What I'd change before it leaves draft is finding 1 — a comment that claims a safety property the guard doesn't deliver, in a spot where a future maintainer would rely on it — plus optionally the test in finding 2. No behaviour needs to change. Worth saying plainly: the mutation-testing note in your last comment, where you found your own harness had been silently swallowing a whole class of mismatch, is the reason I trusted these numbers enough to only spot-check them.
|
Both findings addressed in Finding 1: the commentYou're right, and I want to be precise about how wrong I was, because my PR comment said it worse than the code comment did: "a missed reset degrades to a redundant full walk rather than a read of the wrong bytes." That is false for in-range cursors, and I reproduced your result before rewriting anything. Probing every cursor value against a truncated body ( Index 51 is the CRLF closing the chunk data; as a trailer resume point it reads as an immediate blank line, so the walk declares the trailer section finished. That is exactly your "0 → 1" case, and it is the failure mode this PR exists to prevent. I also took your "don't redesign unless a validating guard is cheap" seriously and concluded it isn't: validating that a cursor is a real chunk boundary means walking to it from One correction to your suggested wording, which I checked before adopting: "every path that clears Finding 2: the testThree assertions on the same truncated body, pinning both halves of the cursor encoding rather than just the trailer one:
They're written as a tripwire, not as desired behaviour, and the comment above them says which. To check they aren't vacuous I ran the mutation that would matter — forcing VerificationSame as before, plus your scope note taken on board: web's CI has no
Logic validated as in previous rounds, in a standalone harness built by extracting the function region straight out of the committed Finding 6 (the slow-drip body) is still open and still deliberate — flagging it here so it stays visible now that it's the last disclosed item. Leaving this as a draft. |
There was a problem hiding this comment.
Build & Tests
Reproduced your build result to the line: carp -x test/web.carp runs the front-end clean and emits a 73,141-line main.c — your figure exactly — then clang dies on 9 tm_zone const-qualifier errors. All 9 are in the time@0.2.0 region via http@0.1.4; grep 'error:' | grep web_ is 0. web-request-status, web-chunked-status, web-trailer-status and ConnState_chunk-cursor are all present in codegen, so the front-end really did traverse the new code.
CI is green (macOS-only by design, and not continue-on-error, so it is load-bearing for the suite) — but see finding 1, because the green check no longer describes what merging this would produce.
"No behaviour change" is literally true, and I checked it mechanically rather than by reading. Stripping comment lines from web.carp at d3866ca and at 54db654 gives byte-identical files. Every changed line in web.carp in this commit is a comment; the rest of the diff is the three test assertions.
Prior feedback
Both findings are addressed, and the fix is right.
Finding 1 — the comment. The new text is accurate in every particular, and I verified the load-bearing clause site by site rather than taking it on trust. Auditing every mutation of read-bufs in the file, there are four places that clear or replace it without touching chunk-cursor:
| site | what it is | why the cursor doesn't matter |
|---|---|---|
:1947 |
WS upgrade | ws-route-idx is set at :1944, before the clear |
:2213/:2219 |
WS frame loop remaining |
already a WS fd |
:2316 |
malformed-request 400 | keep-alives set false at :2315, before the clear |
:2373 |
426 Upgrade Required | keep-alives set false at :2370, before the clear |
and the premise that makes the WS half safe holds: handle-readable at :2252 short-circuits to handle-ws-readable for any fd in ws-route-idx before the HTTP gate is reached. The three reset points are also exactly as stated — conn-done-writing:1807, conn-cleanup:1834, handle-accept:1862.
So your correction of my suggested wording is correct and I was wrong. "Every path that clears read-bufs also resets chunk-cursor" would have been a false invariant sitting in a comment, which is worse than the vague one it replaced. Declining to adopt a reviewer's wording because you checked it and it doesn't hold is the right call, and you showed the counterexamples rather than just asserting it.
Two refinements to the enumeration in your PR comment, neither of which touches the comment in the code:
- It lists three such sites; there are four. The one omitted is the WS-frame-loop
remainingsite at:2213/:2219— the one you correctly identified in round 3 as unreachable for HTTP. The comment's actual wording ("hand the fd to the WebSocket loop, which never consults it") covers it, so the code comment is right; only the tally in the PR description is short by one. - There is a fifth
TcpStream.clear-bufat:1333, inread-request-buf. It has zero callers — dead code — so it cannot violate the invariant either. Worth knowing, since anyone auditing the claim by greppingclear-bufwill hit it and have to work that out.
Finding 2 — the tests. Verified independently. I extracted web.carp:1019-1309 verbatim from the committed branch into a standalone core-Carp harness and ran the three new assertions plus a 14-case regression set: all 17 pass, so the values you pinned are what the committed code actually returns.
Then I swept every cursor value from -80 to 80 against that truncated body and reproduced your probe table exactly:
buffer len=53 body-start=47 true-status=0
cursor -51 -> 1 IN-RANGE
cursor -48 -> 1 IN-RANGE
cursor 48 -> -1 IN-RANGE
cursor 49 -> -1 IN-RANGE
cursor 51 -> -1 IN-RANGE
wrong: in-range=5 out-of-range=0 ; incomplete-reported-complete (0->1)=2
Zero wrong answers out of range, five in range, two of them reporting a truncated body as complete. That is precisely what the new comment now claims and precisely what the old one denied.
And they are not vacuous — I ran your mutation myself rather than trusting the report. Forcing usable to false:
PASS fresh cursor 0 -> incomplete expected= 0 got= 0
FAIL in-range stale trailer cursor -51 -> 1 expected= 1 got= 0
FAIL in-range stale chunk cursor 48 -> -1 expected=-1 got= 0
Both wrong-answer assertions flip, the fresh-cursor one holds. Exactly as you described. The test comment's arithmetic is right too: with the body at 47, index 51 is the CR of the CRLF closing the chunk data, which is why it reads as an immediate blank line in trailer phase.
Findings
One, and it is new since my last review rather than anything you did.
1. The branch is no longer on current main, and the green check predates the divergence
main moved after my round-3 review: 30cc6ec — "0.8.1: bump socket to 0.2.3 for the kqueue filter leak fix" — landed at 12:55Z, about 3.5 hours after that review and an hour before your last push. The branch's merge-base is still c5e7330. Last round I wrote "merge-base == origin/main == c5e7330"; that is no longer true.
The good news first, because I checked the thing that usually goes wrong here rather than reading the raw diff. git merge-tree produces a clean merge, and the merged tree is correct in every respect I could test:
- takes
main'ssocket@0.2.3andweb@0.8.1, not the branch's stalesocket@0.2.1/web@0.8.0 - keeps the gate, the round-4 comment, and the new assertions
## Unreleasedstill sits above## 0.8.0, correctly — 0.8.1 shipped without adding a changelog section of its own, so there is no shipped-release heading for your entry to get folded into
So there is no changelog misfiling and no conflict. What there is, is a coverage gap: CI has never run on the merged combination. Your green check is socket@0.2.1 + this branch. main is socket@0.2.3. That dependency bump fixes a kqueue filter leak — kqueue is the macOS path, web's CI is macOS-only, and this PR changes how many readable events a request generates and how much buffer accumulates before dispatch. Those two changes touch the same machinery from opposite ends, and nothing has yet exercised them together.
I am not claiming they interact badly; I have no evidence of that and can't build web here to look. I am saying the one end-to-end check this PR has is now measuring a combination that will not exist after merge. Merging main into the branch and letting CI run costs one cycle and closes it.
2. Still open from round 1, unchanged
The slow-drip body can still hold a connection open indefinitely (read-start cleared at header-complete, last-active refreshed per byte). Still your policy call, still not a regression, still correctly left out. Flagging it only because you asked that it stay visible, and it is now the last disclosed item.
Verdict: merge — after syncing with main so CI runs on the combination
The substantive engineering finished last round and this round closes the two remaining items cleanly. The comment now states the true reason the code is correct, and I verified that reason exhaustively — all four unpaired clear sites, the WS short-circuit that makes two of them safe, and the three lifecycle resets. The tests pin the failure mode that was previously undescribed, they pin what the code actually does, and they fail under the mutation that matters. web.carp is comment-only in this commit, mechanically confirmed, so nothing can have regressed. No new bugs found.
The one thing I would not skip is merging main first: the branch is behind by a socket bump that touches the same event-loop machinery, and the green check no longer represents the post-merge state. That is a rebase and a CI cycle, not a code change. Correctly still a draft in the meantime.
|
bump http to 0.2.0+. it has dechunking and also fixes the |
http@0.2.0 brings TransferEncoding.dechunk, which completes this PR: the gate already waits for the terminating chunk, but handlers were still handed the raw chunk framing as Request.body. web-build-response now decodes it before dispatch and answers 400 when it cannot. The bump also drops the time@0.2.0 tm_zone const bug that made web unbuildable with clang on Linux/armhf, so the suite runs locally for the first time: 184 pass, 1 pre-existing failure (HMAC-SHA1 RFC 2202 vector 2, an armhf Long-width bug in web's own SHA1, untouched by this diff and invisible on the macOS-only CI). http@0.2.0 also defines FormPart, differing from web's copy in one field, which was a hard C type error. web now uses http's type; its own multipart parser is kept and wraps content-type in a Maybe, preserving the text/plain default.
|
Bumped to The tm_zone payoff: web builds here now
The one failure is not mine and not new: The cause is
The collision: FormPartThis was a hard C type error, not a warning. Both files define
web's I took the minimal resolution you'd expect from the topic framing: web drops its duplicate I did not consolidate onto Nothing else collided. Dechunking, wired inYou called this out and it was the honest gap in the PR: the gate waits for the terminating chunk, but Three new assertions, and I mutation-tested them rather than trusting that they pass. Forcing
I did not touch the chunk-cursor gate. One thing I did not decide for youAfter decoding, the The merge, and one new wart
New and disclosed: the bump makes the Carp front-end emit three warnings on every build, because
|
There was a problem hiding this comment.
Build & Tests
The tm_zone payoff reproduces: web builds and runs on this armhf box for the first time. Four rounds of standalone-harness verification are over, and this review is the first one where I could exercise the real thing.
carp -x test/web.carp → 184 passed, 1 failed, your numbers exactly, the failure being HMAC-SHA1 matches RFC 2202 test vector 2.
One gap in the report: CI runs two suites and you only ran one. The workflow's test step is carp -x test/web.carp followed by carp -x test/websocket.carp. The second one here:
Passed: 125 Failed: 3
SHA1 of 'abc' expected qZk+NkcGgWq6PiVxeFDCbJzQ2J0= got hd77F2RWHrlf+bNyD2P4Ir5fIY4=
SHA1 of empty string expected 2jmj7l5rSw0yVb/vlWAYkK/YBwk= got BMkKwTRErxNrNMV8BHBKg3fS0jg=
accept key matches RFC 6455 example expected s3pPLMBiTxaQ9kYGzzhZRbK+xOo= got Zq/p+H07aING9VMV+KMFdU5O6DE=
None of them is yours — git diff origin/main...HEAD -- web.carp matches no line containing sha1, hmac, mask32 or base64, so the SHA1 region is untouched — and all three are the same SHA1.mask32 no-op you diagnosed. So the honest local figure is 4 failures, not 1, with a single shared root cause.
It does sharpen your lead, though, and I'd carry this into the issue: these aren't obscure vectors. It is plain SHA1("abc"), SHA1(""), and the RFC 6455 example accept key. In other words the WebSocket opening handshake is simply broken on any platform where Long is 32 bits — a client would be rejected outright, not subtly mishandled. That's a stronger case for the Uint32 fix than one HMAC vector was.
The three Response.* warnings are noise, and I confirmed that rather than assuming it. http defines not-found / bad-request / redirect at http.carp:769-779 but never calls them internally — the only other in-file matches are the unrelated Status. constants at :644/:647. So no http code is typechecked against the signatures web overrides. That is precisely why this differs from FormPart: that was a type redefined under already-typechecked http code, which is why it was a hard C error rather than a warning. Your read is right.
Merge-base is 30cc6ec == origin/main, so round 4's sync item is closed, and ## Unreleased correctly sits above ## 0.8.0 (0.8.1 shipped without a section of its own, so there was nothing to misfile into).
Prior feedback
- Round 4's single item — merge
mainso CI runs on the combination — done (a22cd11), and CI is green on the merged tree withsocket@0.2.3. - hellerve's http bump — done, and it delivered both things he predicted.
- FormPart: I checked the failure mode that would have been silent. http's fields are
name / filename / content-type / body, andfilenameandcontent-typeare both(Maybe String)— so transposing them in theinitcall would have typechecked cleanly and quietly swapped a part's filename with its content type.web.carp:555passesname filename-result ct body, correct order. Separately: dropping web'sdeftypecosts no documentation, becauseFormPartwas never ingendocs.carp'ssave-docslist. - Dechunk wiring: the three new assertions go through
web-build-responsewith a realAppand handler rather than testing the predicate in isolation, which is the right level, and theContent-Lengthcase is a proper negative control.
Findings
1. The gate and the decoder disagree about what "chunked" means, so a duplicate Transfer-Encoding header still delivers raw framing to the handler
The two halves of this PR determine "is this body chunked?" by different rules.
The gate (web-chunked-value?, web.carp:1088) implements RFC 7230 §3.3.3 properly: multiple Transfer-Encoding field lines are equivalent to one comma-joined value, so what matters is whether the final coding is chunked — last-wins. You and I both verified that in round 2.
The decoder (web-decode-body, web.carp:1606) asks Request.chunked?, which goes to http's transfer-encoding-chunked? (http.carp:188) → header-lookup (http.carp:174) → (Maybe.Just @(Array.unsafe-first v)). It reads only the first stored value and never sees the rest.
When a request carries more than one Transfer-Encoding line with chunked last, the gate says "chunked, wait for the terminator" and the decoder says "not chunked, pass through". Running the real path — web-request-status replayed through the event loop's cursor protocol, then Request.parse + web-decode-body, exactly as web.carp:1616 composes them:
A. one TE header: chunked
gate status : 1 stored TE: Transfer-Encoding=chunked
Request.chunked? : true handler body : [hello]
B. one TE header: "gzip, chunked"
gate status : 1 stored TE: Transfer-Encoding=gzip, chunked
Request.chunked? : true handler body : [hello]
C. two TE lines: "gzip" then "chunked"
gate status : 1 stored TE: Transfer-Encoding=gzip|chunked
Request.chunked? : false handler body : [5\r\nhello\r\n0\r\n\r\n] <-- raw framing
J. two TE lines, different case: "Transfer-Encoding: gzip" / "transfer-encoding: chunked"
gate status : 1 stored TE: Transfer-Encoding=gzip ; transfer-encoding=chunked
Request.chunked? : false handler body : [5\r\nhello\r\n0\r\n\r\n] <-- raw framing
K. three TE lines, chunked last
gate status : 1 stored TE: Transfer-Encoding=gzip|deflate|chunked
Request.chunked? : false handler body : [5\r\nhello\r\n0\r\n\r\n] <-- raw framing
The stored TE column is the parsed header map, and it shows the mechanism directly: the values land in one key's array (or in two differently-cased keys), and Array.unsafe-first picks gzip.
Why it matters: that last column is verbatim the failure your own CHANGELOG entry describes — "handed the handler the raw chunk framing — size lines, CRLFs and all — as its body, so form, multipart and JSON parsing all saw garbage." For this input class the PR waits for the right bytes and then hands over the wrong ones. It is not a smuggling or memory-safety issue — the framing is correct, the byte count is right, and connection state stays consistent — but it is the exact user-visible bug this commit exists to close, still reachable, and reachable on client-controlled input.
The mirror case is safe, which is worth stating so the scope is clear: chunked first and gzip last makes the gate return -1 → 400 before any decode, so the disagreement there never has consequences. Only the "chunked last" orderings get through.
Single-header requests are all fine — I checked lowercase field names, Transfer-Encoding:chunked with no space, gzip, chunked, uppercase hex chunk sizes and trailers, and every one decodes correctly.
Fix direction: this wants to be resolved in web, not by another dep bump. web-decode-body should apply the same last-wins rule the gate already implements — join every Transfer-Encoding value (case-insensitively keyed) and test whether the final coding is chunked — instead of delegating to Request.chunked?. That is a pure function, no event-loop delta, and it keeps a single definition of "chunked" across the two halves of the PR. Worth a test at the web-build-response level alongside the three you added, since none of them covers a duplicate header.
Separately, and not something to fix here: http's header-lookup returning only the first value is a latent bug for any list-valued header, Transfer-Encoding being the one where it changes framing semantics. Probably worth an issue against http, but web shouldn't wait on it.
2. Still open from round 1, unchanged
The slow-drip body can still hold a connection open indefinitely (read-start cleared at header-complete, last-active refreshed per byte). Still a policy call, still not a regression, still correctly left out — flagging it only so it stays visible, as you asked.
Verdict: revise
Narrow, and everything else here is in good shape. The bump did what hellerve said it would, the FormPart collision is resolved the minimal way with the field-order trap avoided, declining to consolidate onto Multipart.parse inside a framing PR was the right scope call, the dechunk wiring is tested at the right level with a real negative control, and the branch is finally on current main with CI green on the combination.
What I'd close before this leaves draft is finding 1: the gate and the decoder don't share a definition of "chunked", and for a duplicate Transfer-Encoding header the result is the raw-framing-to-the-handler bug this PR is about. It's a small pure change in web-decode-body plus one assertion. I'd also fold the three test/websocket.carp SHA1 failures into your SHA1 lead — the accept-key failure makes that a much more concrete portability defect than the HMAC vector alone did.
The gate (web-request-status) decides "chunked" by a last-wins scan of the raw Transfer-Encoding header lines (RFC 7230 §3.3.3), but web-decode-body delegated to Request.chunked?, which reads only the first stored value of the first matching header key. A request with a repeated Transfer-Encoding header whose final coding is chunked — "gzip" then "chunked", or the two lines differently cased — therefore gated as complete but was handed to the handler as raw chunk framing, the exact bug this branch exists to prevent. web-decode-body now takes the chunked decision derived from the raw header block by the same last-wins rule the gate applies, so both halves agree on what counts as chunked. Adds two web-build-response-level assertions covering the duplicate same-case and mixed-case orderings.
|
Addressed finding 1 (the gate and the decoder disagreeing about "chunked", so a duplicate What changedThe gate (
I derived it from the raw bytes rather than the parsed header map on purpose: as you noted, repeated The repro, now coveredTwo assertions at the
To confirm they aren't vacuous I A/B'd them: reverting just the Local suites (both, this time)
Finding 2 (the slow-drip body holding a connection) is still deliberately left as your policy call, unchanged. And taking your closing note: the three Still a draft. |
There was a problem hiding this comment.
Build & Tests
The tm_zone payoff holds: web builds and runs here on Linux/armhf with http@0.2.0, so this round is verified against the real suites rather than an extracted harness.
carp -x test/web.carp→ 186 passed, 1 failed, your number exactly. The 184→186 is your two new assertions; the one failure is the pre-existingHMAC-SHA1 matches RFC 2202 test vector 2.carp -x test/websocket.carp→ 125 passed, 3 failed — the three pre-existingSHA1.mask32cases (SHA1 of 'abc',SHA1 of empty string,accept key matches RFC 6455 example).
All four failures are the same SHA1.mask32 32-bit-Long no-op, and git diff origin/main...HEAD -- web.carp touches nothing in the SHA1/HMAC region (:36–285), so none is this PR's — they're your standing Uint32 lead, and the broken RFC 6455 accept key is the sharp form of it.
I confirmed the two new tests aren't vacuous by A/B: restoring web.carp to 033311b (old web-decode-body) while keeping the new test file gives 184 passed, 3 failed — both a duplicate Transfer-Encoding with chunked last is decoded and a mixed-case repeated Transfer-Encoding with chunked last is decoded fail (handler receives the raw 5\r\nhello\r\n0\r\n\r\n), plus the HMAC. Restoring the fix turns them green. They pin the bug.
Prior feedback
Finding 1 — the gate and decoder disagreeing about "chunked" — is closed, and I verified it by construction rather than only by the two tests.
web-final-te-chunked? (web.carp:1606) runs the same last-wins scan the gate runs. I diffed it line-for-line against the gate's header loop (web-request-status:1258–1287): identical hdr-end, identical line-start = (web-crlf-index buf 0 hdr-end) + 2 seed with the same (> line-start 1) guard, identical while (< line-start hdr-end) bounds and nl/line-end computation, and the same web-chunked-value? call on the same [line-start + len("transfer-encoding:"), line-end) span. The gate additionally parses Content-Length and checks space-before-colon in that pass, but neither alters line-start advancement or the chunked result. So the two compute an identical chunked boolean.
The reason they cannot disagree is the call path, which I traced:
web-build-responseis only reached from the event loop (:2436) via the sameread-bufsfd buffer the gate scanned at:2307, and only underwhen-do (/= status 0)on the non-negative, valid-request-line, non-WS-upgrade branch — i.e. only when the gate returned status 1 on that buffer.- For a request carrying
Transfer-Encoding, the gate returns 1 only throughte → (if chunked (web-chunked-status …) (Pair.init -1 0))(:1291–1294), which reaches status 1 only when its internalchunkedwas true. TE-present-but-not-chunked returns -1 (a 400, never dispatched); CL+TE returns -1. web-final-te-chunked?re-derives that samechunked=truefrom the same bytes.
So web-decode-body dechunks iff the gate treated the body as chunked, for every reachable input — not just cases C and J. Non-TE requests (Content-Length or bodyless) get chunked=false and pass through unchanged, and the three pre-existing dechunk tests still pass, so single-header and comma-joined chunked bodies are unaffected.
Finding 2 — the slow-drip body — correctly left as your policy call, unchanged, not a regression.
Findings
1. Non-blocking: the fix now rests on two hand-kept-in-sync copies of the header-line scan. The gate's loop and web-final-te-chunked? are byte-identical in their TE logic today (I checked), and they already share the actual definition of chunked through web-chunked-value?/web-bytes-match?, so the single-sourced part is the part that matters most. But the loop scaffolding is duplicated, and the property this PR restores — gate and decoder agree — depends on those two loops staying identical. A future edit to how the gate iterates header lines (obs-fold handling, say) that misses the copy would silently reopen exactly this class of bug. Deriving from the raw buffer was the right call (the parsed map loses the cross-key ordering case J needs), and threading the gate's own chunked out through the event loop would be a bigger delta than you wanted in this PR — so this isn't a change I'd block on, just a spot worth a shared helper or at least a cross-referencing comment someday.
No other findings. web-final-te-chunked? only matches a header line that starts with transfer-encoding: (so X-Transfer-Encoding and mid-value occurrences don't false-positive), it runs once per complete request over the bounded header block (not a per-event body cost), and an undecodable body still falls to 400.
Verdict: merge
Finding 1 is resolved the robust way — one definition of "chunked" across both halves — and I verified it can't disagree on any reachable input, not only the two you tested. Both suites pass at their real levels with every failure attributable to the pre-existing SHA1.mask32 bug, and the new assertions demonstrably catch the defect. No blocking issues from my side; the loop-duplication note is a maintainability suggestion, not a defect, and finding 2 remains your standing policy call. Correctly still a draft — when it leaves draft is yours to decide, and you're already steering it (the http@0.2.0 bump was yours).
The completeness gate and the body decoder each derived the request's framing from the raw header block with hand-kept-in-sync copies of the same scan. The scan now runs once, in web-parse-framing, producing a WebFraming record (body-start, content-length, chunked, expects-continue, malformed) that both halves consume, so they cannot disagree by construction. web-final-te-chunked? and its duplicate loop are gone; web-request-status becomes a thin dispatcher over the record. Decoded chunked requests are also normalized now: every Transfer-Encoding header is removed and Content-Length is set to the decoded length, so Request.chunked? and the headers describe the request the handler actually holds instead of the wire format. Trailer fields are discarded. Previously the Transfer-Encoding header survived the decode, so a handler following http's own documentation would have dechunked an already-decoded body.
Waiting for complete bodies opened two protocol gaps. First, a body dripping in forever held its connection open indefinitely: header-timeout stopped applying once the headers were complete, and last-active refreshes on every byte, so idle-timeout never fired either. read-start now phase-encodes the request lifecycle (positive: arrival time while headers are incomplete, negated at the header-complete transition while the body arrives, 0 in between requests), and a new App.request-timeout (60s) bounds first byte to complete request. The close decision is a pure App.http-timeout-close?, following the ws-ping-action pattern, with the sweep as its only caller. Second, a client sending Expect: 100-continue waits for permission to send the very body the server now waits for — curl does this on every large upload and stalls into its own 1s timeout. At the header-complete transition (which fires exactly once per request), a request that expects it and whose body is pending gets the 100 Continue interim response. It cannot go through the normal write path, since conn-done-writing would clear the partly read request, so it is a single direct non-blocking send: skipped when the socket is not writable (RFC 7231 makes the interim response a SHOULD, and the client proceeds after its own timeout), connection closed if it would tear.
web shipped its own multipart/form-data parser next to the better one it already depends on: http@0.2.0's Multipart handles quoted parameters and preambles per RFC and reports failures instead of silently yielding an empty array. Form.decode-multipart and Form.decode-multipart-request now delegate (returning Result), the private parser and its helpers are deleted, and FormPart.content-type honestly reports Nothing when a part carries no Content-Type header instead of defaulting to text/plain. Also adds test/smoke.sh, wired into CI: it builds and runs a real server and drives it with curl over TCP — a 100KB upload arriving intact (the level the original truncation bug lived at, invisible to the unit suites), a chunked upload decoding intact, the 100 Continue interim response actually appearing on the wire, keep-alive connection reuse, and a 400 for non-chunked Transfer-Encoding. The changelog is consolidated to tell the branch's story once: complete bodies, decoded and normalized chunked requests, a bounded request lifecycle, and one multipart parser.
The smoke step hung for the full 6h job timeout on CI with zero output: the backgrounded server inherits the step's stdout/stderr, and any child that survives cleanup holds them open, which keeps the step alive after the script exits. The server now writes to a log file (dumped when the script ends), the wait loop stops early if the compile dies, each check echoes progress so a CI hang points at a culprit, and the step gets a 15-minute timeout so the worst case is minutes, not hours.
The smoke step still ate its whole timeout on CI: the probe loop ran for 15 minutes without concluding, meaning every connection attempt burned its full 2s rather than being refused — and the timeout killed bash before the EXIT trap could dump the server log, so the one piece of evidence never surfaced. The server is now compiled in the foreground (compile progress and failure stream into the step log), the binary runs directly in the background, the wait loop fails after 90s with a curl -v probe, an lsof snapshot and a process listing, and the server log is dumped by an if: always() workflow step that no kill can skip.
The smoke step still ate its whole timeout on CI: the probe loop ran for 15 minutes without concluding, meaning every connection attempt burned its full 2s rather than being refused — and the timeout killed bash before the EXIT trap could dump the server log, so the one piece of evidence never surfaced. The server is now compiled in the foreground (compile progress and failure stream into the step log), the binary runs directly in the background, the wait loop fails after 90s with a curl -v probe, an lsof snapshot and a process listing, and the server log is dumped by an if: always() workflow step that no kill can skip.
The smoke step hung to the job timeout three times, and the server was never
the problem: it was up within seconds every time. The payload generator
(tr -dc </dev/urandom | head -c) is an infinite loop on a GitHub runner —
the runner starts bash with SIGPIPE ignored, non-interactive shells pass
ignored dispositions to children, and macOS tr never checks write errors, so
after head exits tr spins on EPIPE forever. Reproduced locally under
$SIG{PIPE}=IGNORE (hangs; with default disposition it exits instantly,
which is why every local run passed). The payload now comes from bounded
producers only: head -c urandom | base64 to a file, then head -c on the
file, no pipe whose reader closes early.
The bug
handle-readablegated dispatch on the presence of the CRLFCRLF header terminator alone (web-has-header-end) and never consultedContent-Length. SinceTcpStream.read-append-nbdoes exactly oneread()of at mostSOCK_BUF_SIZE(4096) bytes per readable event, any request whose headers plus body exceed 4096 bytes was deterministically truncated, as was any body arriving in a separate TCP segment.Consequences:
Form.decode-requestandForm.decode-multipart-requestsilently received truncated bodies — multipart file upload was broken for any file over ~4KB.examples/todo/server.carpJSON-parses truncated bodies.conn-done-writingthen clears the read buffer, so the leftover body bytes were parsed as a fresh request line, failedweb-validate-request-line, and produced a spurious400right after the wrong200.The framework's own
App.max-request-sizeis 1MB, i.e. it intends to accept bodies ~256× larger than what actually worked.The fix
A new pure predicate over the raw
&(Array Byte)buffer,web-request-complete?, returning a smallIntstatus —1complete,0need more bytes,-1malformed — swapped in at the dispatch gate:0Content-LengthandTransfer-Encoding-1(RFC 7230 §3.3.3 request-smuggling vector)Content-Lengthwith a conflicting value, or a non-numeric/negative value →-1(an identical duplicate is accepted, per §3.3.2)Transfer-Encodingwhose final coding is notchunked→-1(§3.3.3: the body length cannot be determined)Content-Lengthpresent → complete iffbuf-len - (hdr-end + 4) >= lenTransfer-Encoding: chunked→ wait for the terminating zero-length chunk. Deliberately not decoded —http@0.1.4has no dechunk and bumping the dep is out of scope, so this is framing only.Malformed framing reuses the existing
400write path rather than adding a new one.web-has-header-endis factored into an index-returningweb-header-end-index, with the boolean kept as a thin wrapper so existing call sites and behavior are untouched.Scope discipline
This touches the delicate non-blocking event loop, so the event-loop delta is deliberately tiny — one predicate swap plus the existing error path — and all the judgment lives in the pure, tested function.
App.max-request-sizecheck, which is evaluated before this gate. No new 413 path, no new size check. Oversized requests close the connection exactly as before.body-startmap.read-startsemantics are unchanged: it is still cleared as soon as the headers are complete, so the 15sheader-timeoutcovers the header block only and does not start killing legitimate slow uploads.Known follow-up for you to decide
Because
read-startis cleared at header-complete andlast-activerefreshes on every byte, a slow-drip body can hold a connection open indefinitely —idle-timeoutkeeps being refreshed andheader-timeoutno longer applies. This is not a regression (before this change the body was simply discarded, so it could not drip), but it is a new way to hold a connection. I deliberately did not design a body-timeout policy here; that is a judgment call about acceptable upload duration that seems better made by you than guessed at by me.Verification
webdoes not build on the machine this ran on — its dep chain pinshttp@0.1.4, whosetime@0.2.0fails on strict-clang (tm_zone). That failure is at the C stage, so the full Carp front-end (macro expansion, type check, borrow check, codegen) does run clean over the editedweb.carp,test/web.carp,test/websocket.carpandgendocs.carp; only clang fails, on the pre-existing dep.gendocs.carpruns to completion and produces no docs diff.Since the predicate is pure and has no
http/socketdependency, it was developed and tested in a standalone harness in core Carp first, then the exact functions and the exact test block were re-extracted from these files and re-run to confirm they still pass as committed. 27 assertions pass, coveringContent-Length: 0; GET/HEAD with a declared body; lowercase and uppercase header names; a body split across three reads; a body straddling the 4096-byte boundary; CRLFCRLF appearing inside the body (must not re-trigger); WebSocket upgrade requests; every malformed-framing case; and the chunked cases. It was also fuzzed over every prefix of 17 adversarial inputs (no illegal status, no crash — the byte-level indexing is unchecked in Carp) and against a body containing NUL bytes, which is why the predicate never converts the buffer to aString.web-has-header-endpreviously had zero tests; it and the new predicate now have 27, added totest/web.carpalongside the existing internal-helper tests (web-finalize-response,web-strip-head-body), matching house style.carp-fmt -cis clean andanglerreports no new findings (the 7 remaining are the pre-existingApp.GET/POST/… verb names, verified byte-identical againstmain).CI is the only end-to-end check here, so it is worth watching rather than trusting my local front-end result.
Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.