--- /dev/null
+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
+}
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
+}