From: Maxime Henrion Date: Tue, 2 Jun 2026 13:08:23 +0000 (-0400) Subject: REGTESTS: cache: validate the emission of 103s X-Git-Url: http://git.kaiwu.me/web/%22data:,/static/gitweb.js?a=commitdiff_plain;h=66d5d6a54b909e603795d0cd7a0729dc42b80579;p=haproxy.git REGTESTS: cache: validate the emission of 103s Add four regtests to validate the behavior of the HTTP cache when early hints are enabled. - early_hints.vtc: covers 103 emission on stripped entries and that the "no-early-hints" rule keyword suppresses it. - early_hints_ratio.vtc: covers cap-driven eviction of hints entries when the ratio is reached. - early_hints_parsing.vtc: pins down the (deliberately strict) Link header parsing behaviour of link_is_hint() so future relaxations cannot silently change hint detection. - early_hints_multiblock.vtc: checks that hints are still extracted when the relevant Link headers span more than one cache block. --- diff --git a/reg-tests/cache/early_hints.vtc b/reg-tests/cache/early_hints.vtc new file mode 100644 index 000000000..4e53c0dda --- /dev/null +++ b/reg-tests/cache/early_hints.vtc @@ -0,0 +1,147 @@ +varnishtest "Cache HTTP 103 Early Hints test" +# When the cache has the early-hints feature enabled, hint-worthy Link headers +# (rel=preload, rel=preconnect, rel=dns-prefetch, rel=modulepreload, rel=prefetch) +# are extracted from cached responses and stored alongside the entry. When the +# cache evicts the body of a Link-bearing entry under capacity pressure, the +# entry is stripped (kept as hints-only). A subsequent request for the same URL +# emits an HTTP 103 Early Hints response carrying the cached Link headers +# before forwarding the request to the backend. +# +# Also verifies that the "no-early-hints" keyword on a cache-use rule +# suppresses 103 emission even when the lookup hits a hints entry. + +feature ignore_unknown_macro +feature cmd "command -v socat" + +server s1 { + # Initial population: three large entries to force cache pressure. + # /page1 is stripped when /page3 is stored. + rxreq + expect req.url == "/page1" + txresp \ + -hdr "Cache-Control: public, max-age=300" \ + -hdr "Link: ; rel=preload; as=style" \ + -hdr "Link: ; rel=preconnect" \ + -bodylen 400000 + + rxreq + expect req.url == "/page2" + txresp \ + -hdr "Cache-Control: public, max-age=300" \ + -hdr "Link: ; rel=preload; as=style" \ + -hdr "Link: ; rel=preconnect" \ + -bodylen 400000 + + rxreq + expect req.url == "/page3" + txresp \ + -hdr "Cache-Control: public, max-age=300" \ + -hdr "Link: ; rel=preload; as=style" \ + -bodylen 400000 + + # /page1 replayed through the no-early-hints rule: 103 is suppressed, + # request still falls through to backend, and the response is stored as + # a new full entry (stripping /page2 to make room). + rxreq + expect req.url == "/page1" + txresp \ + -hdr "Cache-Control: public, max-age=300" \ + -hdr "Link: ; rel=preload; as=style" \ + -bodylen 400000 + + # /page2 was stripped above. A replay hits the hints entry: a 103 is + # emitted from the cache, then the request is forwarded to the backend. + rxreq + expect req.url == "/page2" + txresp \ + -hdr "Cache-Control: public, max-age=300" \ + -hdr "Link: ; rel=preload; as=style" \ + -bodylen 400000 +} -start + +haproxy h1 -conf { + global + .if feature(THREAD) + thread-groups 1 + .endif + stats socket "${tmpdir}/h1/stats" level admin + + defaults + mode http + timeout connect "${HAPROXY_TEST_TIMEOUT-5s}" + timeout client "${HAPROXY_TEST_TIMEOUT-5s}" + timeout server "${HAPROXY_TEST_TIMEOUT-5s}" + + frontend fe + bind "fd@${fe}" + default_backend test + + backend test + # Conditional cache-use rules: requests carrying X-No-EH use the + # no-early-hints opt-out (suppressing 103 emission on lookup); all + # others use the default behavior. + http-request cache-use my_cache no-early-hints if { req.hdr(X-No-EH) -m str 1 } + http-request cache-use my_cache unless { req.hdr(X-No-EH) -m str 1 } + server www ${s1_addr}:${s1_port} + http-response cache-store my_cache + + cache my_cache + total-max-size 1 + max-age 300 + max-object-size 500000 + early-hints on ratio 25 +} -start + + +client c1 -connect ${h1_fe_sock} { + # Populate the cache. /page1 will be stripped to hints-only when /page3 + # is stored because the three full bodies do not fit simultaneously. + txreq -url "/page1" + rxresp + expect resp.status == 200 + expect resp.bodylen == 400000 + + txreq -url "/page2" + rxresp + expect resp.status == 200 + expect resp.bodylen == 400000 + + txreq -url "/page3" + rxresp + expect resp.status == 200 + expect resp.bodylen == 400000 + + # Replay /page1 through the no-early-hints rule. Cache finds the hints + # entry but the no-early-hints opt-out suppresses 103 emission; request + # falls through to backend and we receive only the 200. The new /page1 + # store strips /page2 to make room. + txreq -url "/page1" -hdr "X-No-EH: 1" + rxresp + expect resp.status == 200 + expect resp.bodylen == 400000 + + # Replay /page2 normally: hits the now-stripped /page2 entry, expect 103 + # with the cached Link hints, then the 200 from the backend. /page2 was + # stored with two hint-worthy Link headers; vtest exposes only the first + # via resp.http.link, so only that record is asserted here (multi-value + # hint content is covered in early_hints_parsing.vtc). + txreq -url "/page2" + rxresphdrs + expect resp.status == 103 + expect resp.http.link == "; rel=preload; as=style" + rxresp + expect resp.status == 200 + expect resp.bodylen == 400000 +} -run + +# Exactly one 103 was emitted in the whole scenario (the /page2 replay); +# the /page1 replay through the no-early-hints rule must not have counted. +shell { + hits=$(echo "show stat" | socat -t 5 "${tmpdir}/h1/stats" - | \ + awk -F, 'NR==1 { for (i=1; i<=NF; i++) if ($i=="cache_hint_hits") c=i } + $1=="test" && $2=="BACKEND" { print $c }') + if [ "$hits" != "1" ]; then + echo "expected 1 cache hint hit, got '$hits'" + exit 1 + fi +} diff --git a/reg-tests/cache/early_hints_multiblock.vtc b/reg-tests/cache/early_hints_multiblock.vtc new file mode 100644 index 000000000..a315f6bff --- /dev/null +++ b/reg-tests/cache/early_hints_multiblock.vtc @@ -0,0 +1,75 @@ +varnishtest "Cache HTTP 103 Early Hints: multi-block hint extraction" +# Hint records are extracted from the stored entry by walking its HTX header +# blocks. When the headers span more than one shctx block, extraction must +# cross the block boundary. This test places a hint-worthy "Link" header past +# the entry's first 1KB block (behind a large filler header) and checks that +# its hint is still emitted in the 103, i.e. extraction does not stop at the +# first block. +# +# The strip-on-read path is used (short max-age + a delay): the entry is +# stored complete, expires, and the next lookup re-extracts its hints on the +# fly, which is deterministic and does not depend on cache-capacity eviction. + +feature ignore_unknown_macro + +server s1 -repeat 2 { + rxreq + expect req.url == "/multiblock" + # The X-Pad header pushes the following Link header beyond the entry's + # first 1KB block, so extraction has to read into the next block(s). + txresp -hdr "Cache-Control: public, max-age=1" \ + -hdr "X-Pad: PPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPP" \ + -hdr "Link: ; rel=preload; as=style" \ + -bodylen 100 +} -start + +haproxy h1 -conf { + global + .if feature(THREAD) + thread-groups 1 + .endif + + defaults + mode http + timeout connect "${HAPROXY_TEST_TIMEOUT-5s}" + timeout client "${HAPROXY_TEST_TIMEOUT-5s}" + timeout server "${HAPROXY_TEST_TIMEOUT-5s}" + + frontend fe + bind "fd@${fe}" + default_backend test + + backend test + http-reuse never + http-request cache-use my_cache + server www ${s1_addr}:${s1_port} + http-response cache-store my_cache + + cache my_cache + total-max-size 1 + max-age 300 + max-object-size 4096 + early-hints on +} -start + +client c1 -connect ${h1_fe_sock} { + # Populate: a fresh store never replays hints, so a plain 200. + txreq -url "/multiblock" + rxresp + expect resp.status == 200 +} -run + +# Let the entry expire (max-age=1) so the lookup takes the hint-replay path. +delay 2 + +client c2 -connect ${h1_fe_sock} { + # The hint-worthy Link sits past the first block; extraction must walk + # into the following block to find it, otherwise no hint is extracted + # and no 103 is emitted. + txreq -url "/multiblock" + rxresphdrs + expect resp.status == 103 + expect resp.http.link == "; rel=preload; as=style" + rxresp + expect resp.status == 200 +} -run diff --git a/reg-tests/cache/early_hints_parsing.vtc b/reg-tests/cache/early_hints_parsing.vtc new file mode 100644 index 000000000..040233c95 --- /dev/null +++ b/reg-tests/cache/early_hints_parsing.vtc @@ -0,0 +1,290 @@ +varnishtest "Cache HTTP 103 Early Hints: Link header parsing" +# Pins down the (deliberately strict) parsing behaviour of the Link-header hint +# extractor, i.e. link_is_hint() on top of http_get_hdr_param() and +# http_next_hdr_value(). The point is to make the intended behaviour explicit so +# a future relaxation of the parser cannot silently change hint detection. +# +# A 103 is emitted not only for hints entries but also when a *complete* entry +# has expired: the lookup re-extracts the Link hints from the stale entry and +# replays them before forwarding the request to the backend. We use that path +# here (short max-age + a delay) to exercise the parser deterministically, +# without depending on cache-capacity eviction. +# +# Positive cases must yield a 103 carrying the hint-worthy Link value verbatim; +# negative cases must yield no 103 at all (the 200 is then read directly, which +# fails if an unexpected 103 was emitted first). + +feature ignore_unknown_macro + +# The same fourteen responses are served twice: once to populate the cache +# (client c1) and once on replay after expiry (client c2). With "http-reuse never" each +# client session uses its own backend connection, so the server sees exactly two +# connections, one per "-repeat" iteration. +server s1 -repeat 2 { + rxreq + expect req.url == "/quoted" + txresp -hdr "Cache-Control: public, max-age=1" \ + -hdr "Link: ; rel=\"preload prefetch\"" -bodylen 100 + + rxreq + expect req.url == "/valueless" + txresp -hdr "Cache-Control: public, max-age=1" \ + -hdr "Link: ; crossorigin; rel=preload" -bodylen 100 + + rxreq + expect req.url == "/badws" + txresp -hdr "Cache-Control: public, max-age=1" \ + -hdr "Link: ; rel = preload" -bodylen 100 + + rxreq + expect req.url == "/comma" + txresp -hdr "Cache-Control: public, max-age=1" \ + -hdr "Link: ; rel=stylesheet, , ; rel=preload" -bodylen 100 + + rxreq + expect req.url == "/unquoted" + txresp -hdr "Cache-Control: public, max-age=1" \ + -hdr "Link: ; rel=preload prefetch" -bodylen 100 + + rxreq + expect req.url == "/nonhint" + txresp -hdr "Cache-Control: public, max-age=1" \ + -hdr "Link: ; rel=stylesheet" -bodylen 100 + + rxreq + expect req.url == "/dup-first" + txresp -hdr "Cache-Control: public, max-age=1" \ + -hdr "Link: ; rel=preload; rel=stylesheet" -bodylen 100 + + rxreq + expect req.url == "/dup-second" + txresp -hdr "Cache-Control: public, max-age=1" \ + -hdr "Link: ; rel=stylesheet; rel=preload" -bodylen 100 + + rxreq + expect req.url == "/qpair" + txresp -hdr "Cache-Control: public, max-age=1" \ + -hdr {Link: ; title="foo \"bar\""; rel=preload} -bodylen 100 + + rxreq + expect req.url == "/qpair-resync" + txresp -hdr "Cache-Control: public, max-age=1" \ + -hdr {Link: ; title="x\"; rel=preload; y=\"z"} -bodylen 100 + + rxreq + expect req.url == "/multi" + txresp -hdr "Cache-Control: public, max-age=1" \ + -hdr "Link: ; rel=preload, ; rel=prefetch" -bodylen 100 + + rxreq + expect req.url == "/modulepreload" + txresp -hdr "Cache-Control: public, max-age=1" \ + -hdr "Link: ; rel=modulepreload" -bodylen 100 + + rxreq + expect req.url == "/dns-prefetch" + txresp -hdr "Cache-Control: public, max-age=1" \ + -hdr "Link: ; rel=dns-prefetch" -bodylen 100 + + rxreq + expect req.url == "/comma-uri" + txresp -hdr "Cache-Control: public, max-age=1" \ + -hdr "Link: ; rel=preload" -bodylen 100 +} -start + +haproxy h1 -conf { + global + .if feature(THREAD) + thread-groups 1 + .endif + + defaults + mode http + timeout connect "${HAPROXY_TEST_TIMEOUT-5s}" + timeout client "${HAPROXY_TEST_TIMEOUT-5s}" + timeout server "${HAPROXY_TEST_TIMEOUT-5s}" + + frontend fe + bind "fd@${fe}" + default_backend test + + backend test + # Force a fresh backend connection per client session so the server's + # two "-repeat" iterations each see one connection. + http-reuse never + http-request cache-use my_cache + server www ${s1_addr}:${s1_port} + http-response cache-store my_cache + + cache my_cache + total-max-size 1 + max-age 300 + max-object-size 4096 + early-hints on +} -start + +client c1 -connect ${h1_fe_sock} { + # Populate every variant; a fresh store never replays hints, so plain 200s. + txreq -url "/quoted" + rxresp + expect resp.status == 200 + txreq -url "/valueless" + rxresp + expect resp.status == 200 + txreq -url "/badws" + rxresp + expect resp.status == 200 + txreq -url "/comma" + rxresp + expect resp.status == 200 + txreq -url "/unquoted" + rxresp + expect resp.status == 200 + txreq -url "/nonhint" + rxresp + expect resp.status == 200 + txreq -url "/dup-first" + rxresp + expect resp.status == 200 + txreq -url "/dup-second" + rxresp + expect resp.status == 200 + txreq -url "/qpair" + rxresp + expect resp.status == 200 + txreq -url "/qpair-resync" + rxresp + expect resp.status == 200 + txreq -url "/multi" + rxresp + expect resp.status == 200 + txreq -url "/modulepreload" + rxresp + expect resp.status == 200 + txreq -url "/dns-prefetch" + rxresp + expect resp.status == 200 + txreq -url "/comma-uri" + rxresp + expect resp.status == 200 +} -run + +# Let the entries expire (max-age=1) so the lookup takes the hint-replay path. +delay 2 + +client c2 -connect ${h1_fe_sock} { + # Positive: a quoted, space-separated rel list is parsed via the quoted + # value branch; the whole link-value is replayed verbatim in the 103. + txreq -url "/quoted" + rxresphdrs + expect resp.status == 103 + expect resp.http.link == "; rel=\"preload prefetch\"" + rxresp + expect resp.status == 200 + + # Positive: a valueless parameter (crossorigin) appearing before rel must be + # tolerated (HTTP_PARAM_NOVAL); otherwise parsing would stop before rel and + # the hint would be missed. + txreq -url "/valueless" + rxresphdrs + expect resp.status == 103 + expect resp.http.link == "; crossorigin; rel=preload" + rxresp + expect resp.status == 200 + + # Positive: "bad whitespace" around '=' must be tolerated (HTTP_PARAM_BADWS). + txreq -url "/badws" + rxresphdrs + expect resp.status == 103 + expect resp.http.link == "; rel = preload" + rxresp + expect resp.status == 200 + + # Positive: a comma-separated list with an empty element; the empty element + # is ignored and the hint-worthy value is still found and replayed. + txreq -url "/comma" + rxresphdrs + expect resp.status == 103 + expect resp.http.link == "; rel=preload" + rxresp + expect resp.status == 200 + + # Negative: an unquoted multi-token rel is malformed (a space-separated rel + # list MUST be quoted). The strict value parser rejects the parameter, so no + # hint is extracted and no 103 is emitted. + txreq -url "/unquoted" + rxresp + expect resp.status == 200 + + # Negative: a well-formed but non-hint rel (stylesheet) yields no 103. + txreq -url "/nonhint" + rxresp + expect resp.status == 200 + + # Positive: when rel appears twice, only the first occurrence counts + # (RFC 8288#3.3 requires parsers to ignore the others); here the first + # one is hint-worthy. + txreq -url "/dup-first" + rxresphdrs + expect resp.status == 103 + expect resp.http.link == "; rel=preload; rel=stylesheet" + rxresp + expect resp.status == 200 + + # Negative: the first rel is not hint-worthy and the second (preload) + # must be ignored, so no 103 is emitted. + txreq -url "/dup-second" + rxresp + expect resp.status == 200 + + # Positive: an escaped '"' inside a quoted value (quoted-pair, + # RFC 9110#5.6.4) must not close the string; the following rel is + # still found and the link-value replayed verbatim. + txreq -url "/qpair" + rxresphdrs + expect resp.status == 103 + expect resp.http.link == {; title="foo \"bar\""; rel=preload} + rxresp + expect resp.status == 200 + + # Negative: the whole quoted chunk is the title; a parser unaware of + # quoted-pairs would resynchronize on the ';' inside it and extract a + # bogus rel=preload. No 103 must be emitted. + txreq -url "/qpair-resync" + rxresp + expect resp.status == 200 + + # Positive: a single Link header carrying two hint-worthy comma-separated + # values yields both, re-joined with ", " (exercises the multi-value + # accumulation branch in cache_extract_link_hints). + txreq -url "/multi" + rxresphdrs + expect resp.status == 103 + expect resp.http.link == "; rel=preload, ; rel=prefetch" + rxresp + expect resp.status == 200 + + # Positive: rel=modulepreload is hint-worthy. + txreq -url "/modulepreload" + rxresphdrs + expect resp.status == 103 + expect resp.http.link == "; rel=modulepreload" + rxresp + expect resp.status == 200 + + # Positive: rel=dns-prefetch is hint-worthy. + txreq -url "/dns-prefetch" + rxresphdrs + expect resp.status == 103 + expect resp.http.link == "; rel=dns-prefetch" + rxresp + expect resp.status == 200 + + # Negative: an unquoted comma is a list delimiter per HTTP, so this splits + # into "; rel=preload"; neither is a well-formed link-value + # (RFC 8288 requires a leading '<'), so both are ignored and no 103 fires. + # A server needing a comma in the URI must percent-encode it. + txreq -url "/comma-uri" + rxresp + expect resp.status == 200 +} -run diff --git a/reg-tests/cache/early_hints_ratio.vtc b/reg-tests/cache/early_hints_ratio.vtc new file mode 100644 index 000000000..6f46dab16 --- /dev/null +++ b/reg-tests/cache/early_hints_ratio.vtc @@ -0,0 +1,83 @@ +varnishtest "Cache early-hints ratio cap test" +# Verify the "early-hints ratio" option is respected: with ratio=1 on a 1MB +# (1024-block) cache, the cap on hints blocks is 10. Storing more URLs than +# 2 (full entries that fit) + 10 (hints entries that fit in the cap) +# triggers eviction of the oldest hints entry instead of accumulating an +# 11th. The test populates 13 URLs and verifies via "show cache" that the +# cache holds exactly 10 hints-only entries and 2 full entries (the 1st +# stored URL having been evicted). + +feature ignore_unknown_macro +feature cmd "command -v curl && command -v socat" + +server s1 -repeat 13 { + rxreq + txresp \ + -hdr "Cache-Control: public, max-age=300" \ + -hdr "Link: ; rel=preload; as=style" \ + -bodylen 400000 +} -start + +haproxy h1 -conf { + global + .if feature(THREAD) + thread-groups 1 + .endif + stats socket "${tmpdir}/h1/stats" level admin + + defaults + mode http + timeout connect "${HAPROXY_TEST_TIMEOUT-5s}" + timeout client "${HAPROXY_TEST_TIMEOUT-5s}" + timeout server "${HAPROXY_TEST_TIMEOUT-5s}" + + frontend fe + bind "fd@${fe}" + default_backend test + + backend test + # Force a fresh backend connection per request so server -repeat 13 + # actually sees 13 separate connections. + http-reuse never + http-request cache-use my_cache + server www ${s1_addr}:${s1_port} + http-response cache-store my_cache + + cache my_cache + total-max-size 1 + max-age 300 + max-object-size 500000 + early-hints on ratio 1 +} -start + + +shell { + # Issue 13 stores. With 400KB bodies into a 1MB cache, 2 fit as full + # entries. Each subsequent store strips one previously-full entry into + # a 1-block hints-only entry, until hints_blocks reaches the cap (10). + # The 13th store hits the cap, so an eviction is forced before another + # strip can succeed. + for i in $(seq 1 13); do + curl -fs -o /dev/null "http://${h1_fe_addr}:${h1_fe_port}/page$i" + done + + # Tiny pause to let cache_store_http_end settle for the last request. + sleep 0.1 + + out=$(echo "show cache" | socat -t 5 "${tmpdir}/h1/stats" -) + hints=$(echo "$out" | grep -c "type:hints") + full=$(echo "$out" | grep -c "type:full") + + if [ "$hints" -ne 10 ]; then + echo "ratio test: expected 10 hints entries, got $hints" + echo "show cache output:" + echo "$out" + exit 1 + fi + if [ "$full" -ne 2 ]; then + echo "ratio test: expected 2 full entries, got $full" + echo "show cache output:" + echo "$out" + exit 1 + fi +}