]> git.kaiwu.me - haproxy.git/commitdiff
REGTESTS: cache: validate the emission of 103s
authorMaxime Henrion <mhenrion@haproxy.com>
Tue, 2 Jun 2026 13:08:23 +0000 (09:08 -0400)
committerWilly Tarreau <w@1wt.eu>
Fri, 10 Jul 2026 14:42:35 +0000 (16:42 +0200)
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.

reg-tests/cache/early_hints.vtc [new file with mode: 0644]
reg-tests/cache/early_hints_multiblock.vtc [new file with mode: 0644]
reg-tests/cache/early_hints_parsing.vtc [new file with mode: 0644]
reg-tests/cache/early_hints_ratio.vtc [new file with mode: 0644]

diff --git a/reg-tests/cache/early_hints.vtc b/reg-tests/cache/early_hints.vtc
new file mode 100644 (file)
index 0000000..4e53c0d
--- /dev/null
@@ -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: </style.css>; rel=preload; as=style" \
+        -hdr "Link: <https://fonts.example>; rel=preconnect" \
+        -bodylen 400000
+
+    rxreq
+    expect req.url == "/page2"
+    txresp \
+        -hdr "Cache-Control: public, max-age=300" \
+        -hdr "Link: </style.css>; rel=preload; as=style" \
+        -hdr "Link: <https://fonts.example>; rel=preconnect" \
+        -bodylen 400000
+
+    rxreq
+    expect req.url == "/page3"
+    txresp \
+        -hdr "Cache-Control: public, max-age=300" \
+        -hdr "Link: </style.css>; 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: </style.css>; 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: </style.css>; 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 == "</style.css>; 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 (file)
index 0000000..a315f6b
--- /dev/null
@@ -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: <late.css>; 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 == "<late.css>; 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 (file)
index 0000000..040233c
--- /dev/null
@@ -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: <a.css>; rel=\"preload prefetch\"" -bodylen 100
+
+    rxreq
+    expect req.url == "/valueless"
+    txresp -hdr "Cache-Control: public, max-age=1" \
+           -hdr "Link: <a.css>; crossorigin; rel=preload" -bodylen 100
+
+    rxreq
+    expect req.url == "/badws"
+    txresp -hdr "Cache-Control: public, max-age=1" \
+           -hdr "Link: <a.css>; rel = preload" -bodylen 100
+
+    rxreq
+    expect req.url == "/comma"
+    txresp -hdr "Cache-Control: public, max-age=1" \
+           -hdr "Link: <a.css>; rel=stylesheet, , <b.js>; rel=preload" -bodylen 100
+
+    rxreq
+    expect req.url == "/unquoted"
+    txresp -hdr "Cache-Control: public, max-age=1" \
+           -hdr "Link: <a.css>; rel=preload prefetch" -bodylen 100
+
+    rxreq
+    expect req.url == "/nonhint"
+    txresp -hdr "Cache-Control: public, max-age=1" \
+           -hdr "Link: <a.css>; rel=stylesheet" -bodylen 100
+
+    rxreq
+    expect req.url == "/dup-first"
+    txresp -hdr "Cache-Control: public, max-age=1" \
+           -hdr "Link: <a.css>; rel=preload; rel=stylesheet" -bodylen 100
+
+    rxreq
+    expect req.url == "/dup-second"
+    txresp -hdr "Cache-Control: public, max-age=1" \
+           -hdr "Link: <a.css>; rel=stylesheet; rel=preload" -bodylen 100
+
+    rxreq
+    expect req.url == "/qpair"
+    txresp -hdr "Cache-Control: public, max-age=1" \
+           -hdr {Link: <a.css>; title="foo \"bar\""; rel=preload} -bodylen 100
+
+    rxreq
+    expect req.url == "/qpair-resync"
+    txresp -hdr "Cache-Control: public, max-age=1" \
+           -hdr {Link: </a>; title="x\"; rel=preload; y=\"z"} -bodylen 100
+
+    rxreq
+    expect req.url == "/multi"
+    txresp -hdr "Cache-Control: public, max-age=1" \
+           -hdr "Link: <a.css>; rel=preload, <b.js>; rel=prefetch" -bodylen 100
+
+    rxreq
+    expect req.url == "/modulepreload"
+    txresp -hdr "Cache-Control: public, max-age=1" \
+           -hdr "Link: <m.js>; rel=modulepreload" -bodylen 100
+
+    rxreq
+    expect req.url == "/dns-prefetch"
+    txresp -hdr "Cache-Control: public, max-age=1" \
+           -hdr "Link: <https://cdn.example>; rel=dns-prefetch" -bodylen 100
+
+    rxreq
+    expect req.url == "/comma-uri"
+    txresp -hdr "Cache-Control: public, max-age=1" \
+           -hdr "Link: </a,b.css>; 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 == "<a.css>; 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 == "<a.css>; 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 == "<a.css>; 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 == "<b.js>; 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 == "<a.css>; 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 == {<a.css>; 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 == "<a.css>; rel=preload, <b.js>; 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 == "<m.js>; 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 == "<https://cdn.example>; rel=dns-prefetch"
+    rxresp
+    expect resp.status == 200
+
+    # Negative: an unquoted comma is a list delimiter per HTTP, so this splits
+    # into "</a" and "b.css>; 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 (file)
index 0000000..6f46dab
--- /dev/null
@@ -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: </style.css>; 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
+}