Parse each request once per dispatch - #55
Conversation
A single readable event could convert the read buffer to a String four times and run Request.parse over it three times: web-validate-request-line did its own String.from-bytes, and so did web-try-ws-upgrade, web-try-sse and web-build-response, the last three each parsing as well. Since the dispatch chain tries the WebSocket probe, then the SSE probe, then the response builder, an app with both a WebSocket and an SSE route paid all of it on every ordinary GET, over a buffer up to App.max-request-size. Split each consumer into a primitive taking an already-parsed &Request (web-ws-upgrade-info, web-sse-info, web-respond) with the existing buffer-level function on top, and make web-validate-request-line take the raw &String. web-dispatch-request composes the parsed-level forms and returns a WebDispatch the dispatch loop matches on once, instead of three nested Map.value-ref! borrows of the read buffer. The 400 and 426 tails were byte-identical and are now one send-final. Behaviour is unchanged: the empty-route-array short circuits stay in the probes, a Request.parse failure still resolves to the ordinary bad-request response path rather than the 400-and-close path a bad request line takes, and framing stays byte-level so chunked is still read from the buffer, only on the path that needs it.
|
Following up on the two gaps I flagged in the body, now that CI has run on
What that green does and does not cover. Worth stating plainly rather than letting "CI green" stand in for more than it is: Still a draft on purpose — it is a restructuring of the request hot path, not a small fix. |
There was a problem hiding this comment.
Build & Tests
On this armhf Pi: carp -x test/web.carp — 324 passed, 0 failed, exit 0. carp -x test/websocket.carp — 132 passed, 0 failed, exit 0. Both match the description. CI green on 9fdb337 including the Smoke test step.
Merge-base is 285a3ab = current origin/main, so the #54 SSE work is in the baseline and nothing here is measured against a drifted tree. The changelog entry lands under ## Unreleased → ### Changed, which is the right section for a branch cut after 0.9.3.
Your caveat about what green means here is accurate and I re-checked it rather than repeating it: os: [macos-latest] only, no lint job. So I ran the ungated gates myself, differentially against main:
angler: 22 findings on the branch, 22 onmain, and after normalising line numbers the sets are identical — no new lint class. Both exit 1, so the redness is pre-existing, exactly as you said.docs/:carp -x gendocs.carpleavesgit status --porcelainempty, so nothing is stale. None of the top-levelweb-*helpers reach the generated docs at all (checked by name acrossdocs/*.html), which is why theweb-validate-request-linesignature change has no documentation consequence.
Findings
1. Nothing wrong — and here is what I did to try to find something
A behaviour differential against main, not a re-run of your tests. I built the same 26-request battery twice from the same source: once on origin/main composing its four-stage chain (web-validate-request-line → web-try-ws-upgrade → web-try-sse → web-build-response), once on this branch through web-dispatch-request. Each case prints the decision plus response code and keep-alive.
26/26 identical, including a real before-hook that short-circuits on /blocked and a real after-hook that rewrites /hello to 201 — so the hook pass-through through the new dispatch is covered, which the nine new assertions do not do (they pass empty arrays). Cases the battery separates: plain GET and 404, garbage / HTTP/2.0 / BREW / no-CRLF as 400-and-close, WS upgrade, bad Sec-WebSocket-Version → 426, upgrade on an unrouted path, upgrade with no key, SSE with and without Last-Event-ID, SSE via POST, HEAD, Connection: close, HTTP/1.0, chunked and malformed-chunked bodies, Upgrade: h2c, and the same requests against an app with no WS/SSE routes at all.
I then proved the harness can fail rather than trusting a clean diff: resolving a bad request line as Respond(bad-request, false) instead of Invalid — a change with the same status code — was caught on 4 of 26 rows. The oracle distinguishes 400-and-close from an ordinary 400.
The Invalid fold is faithful. Collapsing (Maybe.Just resp) to a bare (Response.bad-request) rests on every branch of web-validate-request-line returning that exact response. I read all six — no CRLF, over-long line, no first space, no second space, bad version, bad method — and they do. Nothing returns a 414 or 505 that this would flatten.
Memory. The refactor moves ownership around — req is borrowed twice then moved into web-respond, and WebDispatch.Respond carries a Response out of a Map.value-ref! borrow — so I ran the whole battery under ASan: no heap-buffer-overflow, no use-after-free, no double free, all 26 rows correct. One honest limit: detect_leaks=1 reports nothing on this armhf clang even for a program that deliberately leaks, so LeakSanitizer is not active here and I cannot speak to leaks. ASan itself I verified live against a deliberate overflow and a deliberate use-after-free.
The premise, measured. The 4-conversions/3-parses claim is the reason this PR exists, so I benchmarked it instead of counting call sites: 400 dispatches of a 6522-byte request against an app with HTTP + WebSocket + SSE routes, both binaries kept separately and confirmed to differ.
main chain (4 × String.from-bytes, 3 × Request.parse) 2554 / 2549 / 2543 ms
web-dispatch-request (1 × each) 963 / 966 / 965 ms
2.64×, same checksum on both sides. The win is real and it is the size you claimed.
send-final. I read both removed tails: the 400 path and the 426 path each did write-buf, position 0, keep-alive false, TcpStream.clear-buf on the read buffer, then the same send-nb / partial-write / queue-close cascade. Folding them is a faithful extraction; computing buf-len before the Map.put! rather than after is equivalent since the put takes a reference.
2. Two small things
web-respond(web.carp:1964) is the only new top-level helper without(hidden …).web-ws-upgrade-info,web-sse-info,web-chunked-buf?,web-dispatch-requestandWebDispatchall have it. Nothing follows from it today — no top-levelweb-*name appears indocs/— but the marker is otherwise consistent across the five siblings, so this reads as an oversight rather than a decision.web-try-ws-upgradeandweb-try-sselost their pre-parse short circuit. Onmainboth returnedNothingon an empty route array before touching the buffer; now the wrapper parses and the parsed-level function checks emptiness afterwards. Production is unaffected —web-dispatch-requestparses once regardless, and your claim that the short circuits are still in the parsed-level probes is correct — but if you keep these two for the tests they are now strictly slower than they were. Reordering the check above the parse in each wrapper restores it; deleting them, as you offered, also settles it. Your instinct to keep them is right in my view: they are what lets the WebSocket and SSE tests drive a raw request end to end, which is otherwise unrepresented.
Also noted and harmless: when the fd is absent from read-bufs, the Map.value-ref! default changes from a chain ending in Respond(bad-request, false) to Invalid. Both answer 400 and close; the branch is unreachable in the dispatch loop since the fd was just read from.
Verdict: merge
Behaviour is preserved where it matters and I checked it the hard way: 26/26 identical against main's own four-stage chain with hooks live, on a harness proved able to fail, clean under ASan, and the perf premise measured at 2.64× rather than argued. The Invalid fold and the send-final extraction both hold up against the code they replaced, angler finds nothing new, and docs/ is in sync. Nothing here blocks. It is a draft by design and that call is yours — the only things I would fold in first are the missing hidden on web-respond and a decision on the two now-caller-less wrappers.
`web-respond` was the only one of the six new top-level names without a `(hidden ...)` marker; its five siblings all have one. Nothing follows from it today — no top-level `web-*` name reaches `docs/` — but the marker is otherwise consistent, so this was an oversight rather than a decision. `web-try-ws-upgrade` and `web-try-sse` had their empty-route-array check below the parse: the wrapper converted the buffer and ran `Request.parse`, then the parsed-level function found the array empty and returned Nothing. On main the check came first and the buffer was never touched. Production never sees this — `web-dispatch-request` parses once regardless and these two wrappers now have no caller outside the tests — but they were strictly slower than they were on main, so the check moves back above the parse. Both orderings are pinned by existing assertions: "upgrade detection with no WS routes" in test/websocket.carp and "an app without SSE routes never opens a stream" in test/web.carp. Both restore exactly the guard main used: `(= 0 (Array.length ws-routes))` and `(Array.empty? sse-routes)`.
|
Folded in both of the things you asked to see first, in
The pre-parse short circuit in I took your read on the wrappers and kept them rather than deleting: they are what lets the WebSocket and SSE tests drive a raw request end to end, which is otherwise unrepresented. Both reorderings are pinned by assertions that already existed — Verification. No changelog entry for either: a visibility marker on a hidden helper and a short circuit in two wrappers with no caller outside the tests are not user-visible. The existing Still a draft — that call is yours. |
There was a problem hiding this comment.
Build & Tests
Checked out 607c450. Merge-base is 285a3ab = current origin/main, so no stale-branch drift.
carp -x test/web.carp— 324 passed, 0 failed, rc 0carp -x test/websocket.carp— 132 passed, 0 failed, rc 0carp -x gendocs.carp— rc 0,git status --porcelainempty afterwards- CI
test (macos-latest)— pass, and I checked the run'shead_shais607c450, not the previous commit angler web.carp: 22 findings on the branch, 22 onmain, identical after normalising line numbers — the delta adds no lint class
Prior feedback
Both notes from the last round are in 607c450, and both hold up:
(hidden web-respond) — added at web.carp:1965. gendocs still leaves the tree clean, which re-confirms the reason this was cosmetic: no top-level web-* name reaches docs/. Consistency with the five siblings, nothing else.
The pre-parse short circuit — restored in both wrappers, and it really is main's guard verbatim. I diffed against git show origin/main:web.carp rather than taking the claim: (= 0 (Array.length ws-routes)) with the same the annotation on the Nothing, and (Array.empty? sse-routes). Same spellings, same position.
One qualification on the wording, because it matters for what is and isn't covered: the two assertions you name (upgrade detection with no WS routes, an app without SSE routes never opens a stream) pin the result, not the ordering — they pass identically with the guard above or below the parse. That is the right level of coverage here, and the reason is stronger than a test would be: the reordering is semantics-preserving by exhaustion, since the parsed-level guards are still in place (web-ws-upgrade-info at 1587, web-sse-info at 1664).
| routes | buffer | before | after |
|---|---|---|---|
| empty | parseable | parse, then parsed-level empty check → Nothing |
guard → Nothing |
| empty | unparseable | Result.Error → Nothing |
guard → Nothing |
| non-empty | either | unchanged | unchanged |
There is no input that separates them, so only a benchmark could, and these two wrappers have no production caller. Nothing to add. They are also not dead weight in the suite — 15 call sites across test/websocket.carp and test/web.carp drive a raw buffer through them.
Findings
None new. The delta is three hunks and none of them is inside web-dispatch-request, so the previous round's work still applies to this head unchanged: 26/26 behaviour-identical against main's four-stage chain with hooks live, clean under ASan, and the 2.64x measurement of the premise.
Verdict: merge
The two things I asked to see first are folded in correctly, verified against main's own source rather than the description, and nothing else on the branch moved. Tests, docs and lint are all where they were. Draft status is your call.
main gained WebSocket close-frame validation, a Python WebSocket smoke client and doc-example fixes while this branch restructured request dispatch. The only textual conflict was CHANGELOG.md, where both sides added a section under `## Unreleased`; both are kept. web.carp merges as a union: main's edits are in the WebSocket module and `handle-ws-readable`, this branch's are in the top-level `web-*` helpers and `handle-readable`. `handle-readable` still hands an established WebSocket connection to `handle-ws-readable` before any HTTP parsing, so the close-frame validation main added sits behind the new dispatch unchanged.
|
Merged The conflictOne file, What the auto-merge produced
Re-verified on the merged treeOn this armhf Pi:
The close-frame path is exercised, not just present. All of I got CIGreen on
|
There was a problem hiding this comment.
Build & Tests
Checked out 969dc86. origin/main is an ancestor of it, and both commits I reviewed — 9fdb337 and 607c450 — are still ancestors too, so merging rather than rebasing did what you intended: the two merge verdicts point at commits that are still reachable.
On this armhf Pi, on the merged tree:
| check | result |
|---|---|
carp -x test/web.carp |
324 passed, 0 failed, rc 0 |
carp -x test/websocket.carp |
155 passed, 0 failed, rc 0 — 132 plus main's 23 |
carp -x gendocs.carp |
rc 0, git status --porcelain empty afterwards |
test/smoke.sh |
all 24 checks and all 8 WebSocket checks passed, rc 0 |
angler web.carp |
22 findings, identical to main's 22 after normalising line numbers |
CI test (macos-latest) |
pass, and the run's head_sha is 969dc86 — the merge commit itself |
CI here is still one runner and no lint job, as established in round one, so angler and carp-fmt are ungated in this repo on either side of the merge. One caveat I should state rather than let the table imply more than it carries: my local angler binary is from July 13 and angler's HEAD is from today, so that row is a differential — it shows the merge introduces no lint class that main does not already have, which is what it needs to show. It is not a statement that either tree is clean under current angler.
Prior feedback
Nothing outstanding. (hidden web-respond) and the restored pre-parse short circuits both landed in 607c450 and I verified them there against main's own source. The merge does not touch either: git diff origin/main HEAD -- web.carp still contains both hunks.
Findings
None. The only thing this round could be about is whether the merge is faithful, so rather than read the resolution I recomputed it.
The merge is git's own, byte for byte, everywhere except the file that actually conflicted. git merge-tree --write-tree origin/main 607c450 replays the merge from scratch with no human in the loop. It reports exactly one conflict — CHANGELOG.md — and the tree it produces differs from the pushed merge in exactly one file:
$ git diff --stat <recomputed-tree> HEAD^{tree}
CHANGELOG.md | 14 ++++++--------
So web.carp, test/web.carp and every other file in 969dc86 are identical to what git resolves unaided. That is a stronger statement than "the diffs line up after normalising hunk offsets", and it rules out the thing worth worrying about in a merge commit: content adjusted under cover of a resolution.
The CHANGELOG.md resolution is a lossless union. Checked in both directions rather than by eye:
git diff origin/main HEAD -- CHANGELOG.mdremoves zero lines, so nothing ofmain's was dropped.- Every line it adds appears verbatim in
607c450:CHANGELOG.md, so nothing was invented or reworded while resolving.
Added / Changed / Fixed ordering matches what 0.7.0 uses, and all three sections sit under the one ## Unreleased.
The close-frame path is live behind the new dispatch, and exercised. The structural half: close-response-code (web.carp:906) and close-response-frame (924) are present and still called from the frame loop at 2689; handle-readable's first cond branch at 2878-2880 is still (Map.contains? (ConnState.ws-route-idx cs) &fd) → handle-ws-readable, ahead of anything that would consult WebDispatch, so an upgraded fd never re-enters HTTP parsing. The behavioural half: all 16 close code … assertions pass on the merged tree, and test/smoke.sh ran ws: handshake, echo, ping and close on /ws/echo green here — a real 1000 close over a real upgraded socket, answered correctly, after the connection was routed through web-dispatch-request. I got the smoke test running by pointing ./out at ~/.carp/out, the same workaround you described, and removed the link afterwards.
The 26/26 behaviour differential, the ASan run and the 2.64× measurement from round one still stand: the merge changes nothing inside web-dispatch-request.
Verdict: merge
Third round and the third time I cannot find anything. The merge is provably git's own resolution everywhere but the one genuinely conflicting file, that file is a clean union in both directions, main's close-frame validation is both present and exercised end to end behind the new dispatch, and every suite plus the smoke test is green on the merged tree with CI confirming the same on macOS. #55 is MERGEABLE and there is nothing left in it that a review can act on — it is waiting on you, not on more work.
A single readable event could convert the read buffer to a
Stringfour timesand run
Request.parseover it three times.web-validate-request-linedid itsown
String.from-bytes, and so didweb-try-ws-upgrade,web-try-sseandweb-build-response— the last three each parsing as well. Because the dispatchchain tries the WebSocket probe, then the SSE probe on
Nothing, then theresponse builder on
Nothing, an app that registers both a WebSocket route andan SSE route paid all of it on every ordinary GET, over a buffer as large as
App.max-request-size(1 MB). #54 added the third pass.What changed
Each consumer is split into a primitive that takes an already-parsed
&Request, and the existing buffer-level function on top of it:web-try-ws-upgradeweb-ws-upgrade-infoweb-try-sseweb-sse-infoweb-build-responseweb-respondweb-validate-request-linetakes the raw&Stringdirectly rather thanconverting bytes itself.
A new
web-dispatch-requestcomposes the parsed-level forms: oneString.from-bytes, oneweb-validate-request-line, oneRequest.parse, andthen the parsed request is threaded through the upgrade probe, the SSE probe and
the response builder. It returns a
WebDispatch—Invalid,Upgrade,UpgradeRequired,SSEOpenorRespond— and the dispatch loop matches onthat once instead of nesting three
Map.value-ref!borrows of the read bufferinside each other. The two "send this and close" tails (400 and 426) were
byte-identical, so they are now one
send-final.Behaviour
an app with no WebSocket or SSE routes does no upgrade work at all.
Request.parsefailure still resolves toRespond(bad-request, false)—the ordinary response path — not the 400-and-close path that a bad request
line takes. Only
web-validate-request-lineproducesInvalid, and everybranch of it returned exactly
(Response.bad-request).web-parse-framingandweb-header-end-indexwork on bytes and areunchanged;
chunkedis still read from the buffer (now viaweb-chunked-buf?) and only on the path that needs it, so an upgrade or astream open does not pay for it.
Sec-WebSocket-Version, same keep-alive decision, samedechunking through
web-decode-body, same params and route matching.Map.value-ref!default(Pair.init (Response.bad-request) false)was an eagerly evaluated argument, so itallocated a
Responseon every request; the new default is a nullaryconstructor.
Tests
Nine assertions in
test/web.carpdriveweb-dispatch-requestagainst an appthat has an HTTP route, a WebSocket route and an SSE route registered at once:
a plain GET still reaches its handler (200) and an unrouted one still gets 404,
the keep-alive decision (and
Connection: close) survives the probes, anupgrade still upgrades with SSE routes registered, a bad
Sec-WebSocket-Versionstill answers 426, a stream still opens with WebSocket routes registered, and a
malformed request line is still rejected before any probe runs.
Locally:
carp -x test/web.carp324 passed / 0 failed andcarp -x test/websocket.carp132 passed / 0 failed.anglerreports nothing new (thefindings on
web.carpare all pre-existing);carp-fmt -cis not clean onmainwith the current build, so I formatted by hand rather than reflow thewhole file.
test/smoke.shI could not get a clean local run of inside my timebudget —
carp -bwrites to~/.carp/outrather than./outon this machine— so I am leaning on CI for it.
One thing to call:
web-try-ws-upgradeandweb-try-ssenow have no calleroutside the tests. I kept them because that is how the WebSocket and SSE tests
drive a raw request end to end, but if you would rather not carry them, the
tests can parse first and call
web-ws-upgrade-info/web-sse-infodirectlyand both can go.
Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.