Skip to content

Match the Connection close token case-insensitively and in a list - #47

Merged
hellerve merged 1 commit into
mainfrom
claude/connection-token-list
Jul 24, 2026
Merged

Match the Connection close token case-insensitively and in a list#47
hellerve merged 1 commit into
mainfrom
claude/connection-token-list

Conversation

@carpentry-agent

Copy link
Copy Markdown
Contributor

web-keep-alive? compared the whole Connection field value verbatim against
close:

(/= &conn "close")

RFC 9110 §7.6.1 makes connection options a comma-separated list of
case-insensitive tokens
, so two perfectly legal shapes slipped through:

request header before after
Connection: CLOSE keep-alive close
Connection: Close keep-alive close
Connection: close, TE keep-alive close
Connection: TE, close keep-alive close

In every one of those the server ignored an explicit close request: it held the
fd open and answered Connection: keep-alive back at a client that had just
asked 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 close token.

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:

  • the version-dependent default when the header is absent — HTTP/1.1 →
    keep-alive, anything else → close;
  • a present-but-unrecognised value still means keep-alive.

In particular this does not start requiring an explicit keep-alive token
on HTTP/1.0. That is a real behaviour change and belongs in its own PR.

Verification

test/web.carp 243/0 (baseline 236/0, +7), test/websocket.carp 128/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:

  1. drop String.ascii-to-lower → 241/2, the two casing tests fail
    (uppercase close token, capitalized close token); the multi-token tests
    still pass, as they should — they are already lowercase.
  2. keep the lowercasing but drop the token split, i.e. (/= &(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.carp driven with curl (not just the
pure function):

CLOSE        -> Connection: close
Close        -> Connection: close
close, TE    -> Connection: close
TE, close    -> Connection: close
keep-alive   -> Connection: keep-alive
TE           -> Connection: keep-alive

carp-fmt -c clean. angler reports only the pre-existing
non-kebab-case-defn findings 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.

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.

@carpentry-reviewer carpentry-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Build & Tests

Checked out claude/connection-token-list (31851e6) and ran it on armhf.

  • carp -x test/web.carp243 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/main head, so the CHANGELOG entry is filed against the live ## Unreleased### Fixed section, 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 orderweb.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.

@hellerve
hellerve merged commit e798bb7 into main Jul 24, 2026
1 check passed
@hellerve
hellerve deleted the claude/connection-token-list branch July 24, 2026 08:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant