Match the Connection close token case-insensitively and in a list - #47
Conversation
web-keep-alive? compared the whole Connection field value verbatim against "close". RFC 9110 §7.6.1 makes connection options a comma-separated list of case-insensitive tokens, so two legal shapes were missed: `Connection: CLOSE` / `Close`, and multi-token values like `close, TE` or `TE, close`. In both cases the server ignored an explicit close request — it held the fd open and answered `Connection: keep-alive` back at the client. The value is now lowercased and scanned as a comma-separated token list with OWS trimmed; keep-alive is the absence of a `close` token. Nothing else about the function changes: the version-dependent default when the header is absent (HTTP/1.1 -> keep-alive, otherwise close) is untouched, and a present-but-unrecognised value still means keep-alive. Requiring an explicit keep-alive token on HTTP/1.0 would be a separate behaviour change and is deliberately not done here. This is the value-side half of the defect the reviewer flagged when the header-name casing fix (#45) merged.
There was a problem hiding this comment.
Build & Tests
Checked out claude/connection-token-list (31851e6) and ran it on armhf.
carp -x test/web.carp→ 243 passed, 0 failed, matching the claim (+7 over the 236 baseline).- CI is green on the current head SHA (
31851e68, macOS — web's only matrix job), not on a stale commit. - Branch merge-base equals
origin/mainhead, so the CHANGELOG entry is filed against the live## Unreleased→### Fixedsection, not a shipped one.
I re-ran both non-vacuity mutations independently rather than taking the PR's word for it, and both reproduce exactly:
| mutation | result |
|---|---|
drop String.ascii-to-lower, keep the split |
241/2 — the two casing tests |
keep the lowercasing, drop the split (&[(String.ascii-to-lower &conn)]) |
241/2 — honors close as the first of several options, honors close as the last of several options |
Each mutation fails only its own tests and nothing else. The new assertions are load-bearing.
Findings
1. Repeated Connection: header lines still let a close token through, and the answer depends on line order — web.carp:1180
RFC 9110 §5.3 makes repeated field lines with the same name semantically identical to a single comma-joined value. So Connection: TE + Connection: close on two lines means exactly what Connection: TE, close means — which this PR now correctly answers false for. The two-line form still answers true:
repeated header: TE then close => keep-alive?=true <-- wrong
repeated header: close then TE => keep-alive?=false
repeated, mixed name case => keep-alive?=true <-- wrong
3 tokens, close in middle => keep-alive?=false
tab OWS: 'TE,\tclose' => keep-alive?=false
no space: 'TE,close' => keep-alive?=false
trailing comma: 'close,' => keep-alive?=false
substring trap: 'closed' => keep-alive?=true
substring trap: 'not-close' => keep-alive?=true
browser upgrade: 'keep-alive, Upgrade' => keep-alive?=true
The cause is one level up from the change: Request.header (http@0.2.0 header-lookup) returns (Array.unsafe-first v) — the first value of the first matching key — so the second close line is never seen. That's why close-then-TE accidentally answers correctly while TE-then-close does not: same request, two orderings, two answers. That order-dependence is the same defect shape this PR set out to kill, just in the repeated-line framing rather than the comma framing.
This is pre-existing and not a regression — the old (/= &conn "close") had it too — so it doesn't block the PR. Noting it because the fix is close at hand: header-values-ci at web.carp:1299 already folds all values for a case-insensitively matched name and is already used by the WebSocket upgrade path 25 lines below. Swapping the Request.header call for it (and scanning every returned value, not just one) would close the last shape. Worth its own PR if you want it, in the same way the value-side casing was left as a follow-up to #45.
2. Everything else I tried behaves correctly. The substring traps (closed, not-close) don't false-positive, tab OWS and missing OWS both trim, a trailing empty token is harmless, an empty value and an unrecognised token both stay keep-alive, and the HTTP/1.0-default and browser keep-alive, Upgrade paths are unchanged. The scope note in the PR description is accurate: this doesn't start requiring an explicit keep-alive token on HTTP/1.0, which would be a real behaviour change.
Style matches the surrounding code — String.ascii-to-lower is already the idiom in this file at lines 1300, 1302, 1327 and 1668.
Verdict: merge
Correct, tightly scoped, and the tests are proven non-vacuous by two independent mutations I reproduced myself; the one gap I found (repeated header lines) predates the PR and is a clean follow-up rather than a reason to hold this.
web-keep-alive?compared the wholeConnectionfield value verbatim againstclose:RFC 9110 §7.6.1 makes connection options a comma-separated list of
case-insensitive tokens, so two perfectly legal shapes slipped through:
Connection: CLOSEConnection: CloseConnection: close, TEConnection: TE, closeIn every one of those the server ignored an explicit close request: it held the
fd open and answered
Connection: keep-aliveback at a client that had justasked it to hang up.
The value is now lowercased and scanned as a comma-separated token list with
OWS trimmed; keep-alive is the absence of a
closetoken.This is the value-side half of the defect @carpentry-reviewer flagged as a
follow-up when the header-name casing fix (#45) merged.
Scope
Deliberately unchanged:
keep-alive, anything else → close;
In particular this does not start requiring an explicit
keep-alivetokenon HTTP/1.0. That is a real behaviour change and belongs in its own PR.
Verification
test/web.carp243/0 (baseline 236/0, +7),test/websocket.carp128/0— both on armhf, since CI is macOS-only.
Non-vacuity, by mutation. Two independent mutations, each caught by exactly
the tests that target it and nothing else:
String.ascii-to-lower→ 241/2, the two casing tests fail(
uppercase close token,capitalized close token); the multi-token testsstill pass, as they should — they are already lowercase.
(/= &(String.ascii-to-lower &conn) "close")→ 241/2, the two multi-token tests fail (
close as the first/last of several options); the casing tests still pass.So neither half of the fix is load-bearing for the other's tests.
The HTTP/1.0-default and unrecognised-token cases pass both before and after —
they are scope guards for the two behaviours listed above, not new-behaviour
tests.
End to end, against
test/smoke-server.carpdriven with curl (not just thepure function):
carp-fmt -cclean.anglerreports only the pre-existingnon-kebab-case-defnfindings on the HTTP-verb helpers (App.GET,App.POST,…), untouched here.
Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.