]> git.kaiwu.me - haproxy.git/commitdiff
REGTESTS: compression: check hard to compress contents and nearly full buffers
authorWilly Tarreau <w@1wt.eu>
Sat, 31 Oct 2026 08:21:13 +0000 (09:21 +0100)
committerWilly Tarreau <w@1wt.eu>
Tue, 3 Nov 2026 08:40:36 +0000 (09:40 +0100)
Add reg-tests/compression/output_margin_*.vtc, with a Lua service in
output_margin.lua, which sends bodies that are incompressible or barely
compressible (random bytes, bytes that cost 9 bits each with the fixed
tree, random data mixed with text, random data with short matches, runs
of 9-bit literals separated by 4-byte matches) or repetitive. The service
serves the cases of the set named in the request in order, so that the
vtest clients just loop over the same request. Each response must be
compressed with gzip, and is decompressed by the client, which checks the
gzip trailer's CRC and length, then compares the body length to the one
announced in x-len.

The tests are split into several files so that vtest runs them in
parallel, and tagged slow:
  - output_margin_{whole,buf,small,odd}.vtc send all patterns through 4 kB
    buffers, whole or by calls of 4000, 1 to 64 or 1 to 20000 bytes;
  - output_margin_full_{random,run51}.vtc use 16 kB buffers and
    tune.maxrewrite 32, so that the buffer may be almost full when the
    filter compresses it, with bodies of 16088 to 16116 bytes sent in one
    call. With the default maxrewrite, the channel always keeps far more
    room than the deflater could ever need, which is why this does not
    happen with default settings. These sizes still leave enough room for
    the deflater, so they must be compressed as well (responses of 16144
    bytes or more are not compressed at all with this setup).

Before the fixes, the last two crashed haproxy in comp_http_payload()
(htx_get_blk_type() on the NULL block returned by htx_replace_blk_value())
for all sizes from 16086 to 16118 bytes, which is the crash reported since
3.5-dev10. Each file takes about as long as a trivial test (0.25 to 0.3s
without ASAN here, 0.33 to 0.42s with ASAN).

reg-tests/compression/output_margin.lua [new file with mode: 0644]
reg-tests/compression/output_margin_buf.vtc [new file with mode: 0644]
reg-tests/compression/output_margin_full_random.vtc [new file with mode: 0644]
reg-tests/compression/output_margin_full_run51.vtc [new file with mode: 0644]
reg-tests/compression/output_margin_odd.vtc [new file with mode: 0644]
reg-tests/compression/output_margin_small.vtc [new file with mode: 0644]
reg-tests/compression/output_margin_whole.vtc [new file with mode: 0644]

diff --git a/reg-tests/compression/output_margin.lua b/reg-tests/compression/output_margin.lua
new file mode 100644 (file)
index 0000000..1d7849d
--- /dev/null
@@ -0,0 +1,178 @@
+-- Generates deterministic response bodies that are hard to compress, for
+-- the output_margin_*.vtc files.
+--
+-- Patterns:
+--   random : random bytes, which the deflater cannot compress at all
+--   high   : random bytes >= 144, which cost 9 bits each with the fixed tree
+--   mixed  : alternating random and repetitive text blocks of random sizes
+--   sparse : random bytes with a short repeated sequence every 20-60 bytes,
+--            i.e. matches that barely pay for themselves
+--   runN   : runs of N random bytes >= 144 separated by 4-byte matches
+--            copied from 8 bytes before, which are barely worth a reference
+--            and exercise the deflater's switching between stored and
+--            huffman blocks
+--   text   : repetitive HTML-like text
+--
+-- Chunkings:
+--   whole  : the whole body in one call, with a content-length
+--   buf    : 4000-byte calls, close to the h1 buffer size, with a
+--            content-length
+--   small  : 1 to 64 bytes per call, chunked
+--   odd    : 1 to 20000 bytes per call, chunked
+
+-- Each pattern is generated once, as long as the longest body using it, and
+-- shorter bodies are prefixes of it, so that all requests for the same
+-- pattern and length get the same body. Random bytes come from a small LCG,
+-- inlined and turned into strings 256 bytes at a time, since it's run under
+-- ASAN in CI.
+local max_len = {}
+
+local phrase = "<div class=\"item\"><a href=\"/page?id=1234\">Some link text</a></div>\n"
+
+local bodies = {}
+
+local function make_body(pattern)
+       if bodies[pattern] then
+               return bodies[pattern]
+       end
+
+       local x = 12345
+       local t = {}
+       local len = 0
+       local tail = ""
+
+       local function rnd()
+               x = (x * 1103515245 + 12345) % 2147483648
+               return (x >> 16) & 0xff
+       end
+
+       local function add(s)
+               t[#t + 1] = s
+               len = len + #s
+               tail = (tail .. s):sub(-8)
+       end
+
+       -- adds <count> random bytes >= <min>
+       local function add_random(count, min)
+               local a = {}
+               local span = 256 - min
+               for i = 1, count do
+                       x = (x * 1103515245 + 12345) % 2147483648
+                       a[i] = min + ((x >> 16) & 0xff) % span
+               end
+               for i = 1, count, 256 do
+                       add(string.char(table.unpack(a, i, math.min(i + 255, count))))
+               end
+       end
+
+       local function add_text(count)
+               add(string.rep(phrase, count // #phrase + 1):sub(1, count))
+       end
+
+       while len < max_len[pattern] do
+               if pattern == "random" then
+                       add_random(4096, 0)
+               elseif pattern == "high" then
+                       add_random(4096, 144)
+               elseif pattern == "mixed" then
+                       add_random(300 + rnd() * 7, 0)
+                       add_text(300 + rnd() * 7)
+               elseif pattern:sub(1, 3) == "run" then
+                       add_random(tonumber(pattern:sub(4)) or 51, 144)
+                       add(tail:sub(1, 4))
+               elseif pattern == "sparse" then
+                       add_random(20 + rnd() % 40, 0)
+                       add(("wxyz1234"):sub(1, 4 + rnd() % 5))
+               else
+                       add_text(4096)
+               end
+       end
+       bodies[pattern] = table.concat(t):sub(1, max_len[pattern])
+       return bodies[pattern]
+end
+
+-- small LCG for the chunk sizes
+local function prng(seed)
+       local x = seed
+       return function()
+               x = (x * 1103515245 + 12345) % 2147483648
+               return (x >> 16) & 0xff
+       end
+end
+
+-- The cases are served in order, one per request, from the set named in the
+-- query string (/?set=<name>), so that each vtest client only has to loop
+-- over the same request. Each set is used by its own output_margin_*.vtc file
+-- so that vtest runs them in parallel. The body length is advertised in x-len
+-- for the client to check it once decompressed.
+local sets = {}
+local next_case = {}
+
+local function add_case(set, p, c, n)
+       sets[set] = sets[set] or {}
+       next_case[set] = 0
+       table.insert(sets[set], { p = p, c = c, n = n })
+       max_len[p] = math.max(max_len[p] or 0, n)
+end
+
+-- all patterns with 4 kB buffers, one set per chunking
+for _, c in ipairs({ "whole", "buf", "small", "odd" }) do
+       for _, p in ipairs({ "random", "high", "mixed", "sparse", "run4", "run51", "run53", "run200", "run349", "text" }) do
+               add_case(c, p, c, 10000)
+       end
+end
+
+-- sizes that leave little room in the buffer once received at once: with
+-- 16 kB buffers, a tiny maxrewrite and these headers, haproxy before the fixes
+-- crashed on 16086 to 16118 bytes, and responses of 16144 bytes or more are
+-- not compressed at all.
+for _, p in ipairs({ "random", "run51" }) do
+       for n = 16088, 16116, 4 do
+               add_case("full-" .. p, p, "whole", n)
+       end
+end
+
+core.register_service("gen", "http", function(applet)
+       local qs = applet.qs or ""
+       local set = qs:match("set=([%w-]+)") or "whole"
+       local cases = sets[set]
+       local case = cases[next_case[set] % #cases + 1]
+       local body, rnd
+       local pos = 1
+
+       next_case[set] = next_case[set] + 1
+
+       -- explicit case, for debugging: /?p=<pattern>&c=<chunking>&n=<length>
+       if qs:match("p=") then
+               case = { p = qs:match("p=(%w+)"), c = qs:match("c=(%w+)") or "whole",
+                        n = tonumber(qs:match("n=(%d+)") or 10000) }
+               max_len[case.p] = math.max(max_len[case.p] or 0, case.n)
+               bodies[case.p] = nil
+       end
+
+       body = make_body(case.p):sub(1, case.n)
+       rnd = prng(54321)
+
+       applet:set_status(200)
+       applet:add_header("Content-Type", "text/plain")
+       applet:add_header("x-len", #body)
+       if case.c == "whole" or case.c == "buf" then
+               applet:add_header("Content-Length", #body)
+       end
+       applet:start_response()
+
+       while pos <= #body do
+               local size
+               if case.c == "buf" then
+                       size = 4000
+               elseif case.c == "small" then
+                       size = 1 + rnd() % 64
+               elseif case.c == "odd" then
+                       size = 1 + (rnd() * 256 + rnd()) % 20000
+               else
+                       size = #body
+               end
+               applet:send(body:sub(pos, pos + size - 1))
+               pos = pos + size
+       end
+end)
diff --git a/reg-tests/compression/output_margin_buf.vtc b/reg-tests/compression/output_margin_buf.vtc
new file mode 100644 (file)
index 0000000..4f6fee2
--- /dev/null
@@ -0,0 +1,52 @@
+varnishtest "Compression filter: contents that are hard to compress, sent by 4000-byte calls with a content-length"
+
+# The deflater's output may be larger than its input on incompressible data,
+# and the compression filter must reserve enough room in the buffer for this.
+# Here a Lua service sends bodies that are incompressible or barely
+# compressible (random bytes, bytes that cost 9 bits each with the fixed tree,
+# random data mixed with text, random data with short matches, runs of 9-bit
+# literals separated by short matches) or repetitive, sent by 4000-byte calls with a content-length, through 4 kB
+# buffers. They are decompressed by the client, which checks the gzip
+# trailer's CRC and length, and their length is compared to the one announced
+# by the service. See output_margin.lua for the patterns, and the other
+# output_margin_*.vtc files for other chunkings and nearly full buffers.
+#REGTEST_TYPE=slow
+feature cmd "$HAPROXY_PROGRAM -cc '(feature(ZLIB) || feature(SLZ)) && feature(LUA)'"
+feature ignore_unknown_macro
+
+haproxy h1 -conf {
+    global
+        tune.bufsize 4096
+        tune.lua.bool-sample-conversion normal
+        lua-load ${testdir}/output_margin.lua
+
+    defaults
+        mode http
+        timeout connect "${HAPROXY_TEST_TIMEOUT-5s}"
+        timeout client "${HAPROXY_TEST_TIMEOUT-5s}"
+        timeout server "${HAPROXY_TEST_TIMEOUT-5s}"
+
+    frontend fe-gzip
+        bind "fd@${fe_gzip}"
+        compression algo gzip
+        compression type text/plain
+        default_backend be
+
+    backend be
+        server gen ${h1_gen_addr}:${h1_gen_port}
+
+    listen gen
+        bind "fd@${gen}"
+        http-request use-service lua.gen
+} -start
+
+client c1 -connect ${h1_fe_gzip_sock} {
+    loop 10 {
+        txreq -url "/?set=buf" -hdr "Accept-Encoding: gzip"
+        rxresp
+        expect resp.status == 200
+        expect resp.http.content-encoding == "gzip"
+        gunzip
+        expect resp.bodylen == resp.http.x-len
+    }
+} -run
diff --git a/reg-tests/compression/output_margin_full_random.vtc b/reg-tests/compression/output_margin_full_random.vtc
new file mode 100644 (file)
index 0000000..8e0b13f
--- /dev/null
@@ -0,0 +1,57 @@
+varnishtest "Compression filter: random bytes in nearly full buffers"
+
+# The deflater's output may be larger than its input on incompressible data,
+# and the compression filter must reserve enough room in the buffer for this.
+# Before the fixes, libslz could emit far more than its documented worst case
+# on such data, which exceeded the reserve on a nearly full buffer, made
+# htx_replace_blk_value() fail, and crashed the filter which did not check
+# its result.
+#
+# Here, with 16 kB buffers and a tiny maxrewrite, so that buffers can be
+# almost full when the filter compresses them, a Lua service sends random bytes
+# of sizes close to the buffer size in one call, which used to crash. The
+# sizes are chosen so that there is still room for the deflater, so all
+# responses must be compressed (see output_margin.lua). They are decompressed
+# by the client, which checks the gzip trailer's CRC and length, and their
+# length is compared to the one announced by the service.
+#REGTEST_TYPE=slow
+feature cmd "$HAPROXY_PROGRAM -cc '(feature(ZLIB) || feature(SLZ)) && feature(LUA)'"
+feature ignore_unknown_macro
+
+haproxy h1 -conf {
+    global
+        tune.bufsize 16384
+        tune.maxrewrite 32
+        tune.lua.bool-sample-conversion normal
+        lua-load ${testdir}/output_margin.lua
+
+    defaults
+        mode http
+        timeout connect "${HAPROXY_TEST_TIMEOUT-5s}"
+        timeout client "${HAPROXY_TEST_TIMEOUT-5s}"
+        timeout server "${HAPROXY_TEST_TIMEOUT-5s}"
+
+    frontend fe-gzip
+        bind "fd@${fe_gzip}"
+        compression algo gzip
+        compression type text/plain
+        default_backend be
+
+    backend be
+        server gen ${h1_gen_addr}:${h1_gen_port}
+
+    listen gen
+        bind "fd@${gen}"
+        http-request use-service lua.gen
+} -start
+
+client c1 -connect ${h1_fe_gzip_sock} {
+    loop 8 {
+        txreq -url "/?set=full-random" -hdr "Accept-Encoding: gzip"
+        rxresp
+        expect resp.status == 200
+        expect resp.http.content-encoding == "gzip"
+        gunzip
+        expect resp.bodylen == resp.http.x-len
+    }
+} -run
diff --git a/reg-tests/compression/output_margin_full_run51.vtc b/reg-tests/compression/output_margin_full_run51.vtc
new file mode 100644 (file)
index 0000000..e2d78e1
--- /dev/null
@@ -0,0 +1,57 @@
+varnishtest "Compression filter: runs of 51 9-bit literals separated by 4-byte matches in nearly full buffers"
+
+# The deflater's output may be larger than its input on incompressible data,
+# and the compression filter must reserve enough room in the buffer for this.
+# Before the fixes, libslz could emit far more than its documented worst case
+# on such data, which exceeded the reserve on a nearly full buffer, made
+# htx_replace_blk_value() fail, and crashed the filter which did not check
+# its result.
+#
+# Here, with 16 kB buffers and a tiny maxrewrite, so that buffers can be
+# almost full when the filter compresses them, a Lua service sends runs of 51 9-bit literals separated by 4-byte matches
+# of sizes close to the buffer size in one call, which used to crash. The
+# sizes are chosen so that there is still room for the deflater, so all
+# responses must be compressed (see output_margin.lua). They are decompressed
+# by the client, which checks the gzip trailer's CRC and length, and their
+# length is compared to the one announced by the service.
+#REGTEST_TYPE=slow
+feature cmd "$HAPROXY_PROGRAM -cc '(feature(ZLIB) || feature(SLZ)) && feature(LUA)'"
+feature ignore_unknown_macro
+
+haproxy h1 -conf {
+    global
+        tune.bufsize 16384
+        tune.maxrewrite 32
+        tune.lua.bool-sample-conversion normal
+        lua-load ${testdir}/output_margin.lua
+
+    defaults
+        mode http
+        timeout connect "${HAPROXY_TEST_TIMEOUT-5s}"
+        timeout client "${HAPROXY_TEST_TIMEOUT-5s}"
+        timeout server "${HAPROXY_TEST_TIMEOUT-5s}"
+
+    frontend fe-gzip
+        bind "fd@${fe_gzip}"
+        compression algo gzip
+        compression type text/plain
+        default_backend be
+
+    backend be
+        server gen ${h1_gen_addr}:${h1_gen_port}
+
+    listen gen
+        bind "fd@${gen}"
+        http-request use-service lua.gen
+} -start
+
+client c1 -connect ${h1_fe_gzip_sock} {
+    loop 8 {
+        txreq -url "/?set=full-run51" -hdr "Accept-Encoding: gzip"
+        rxresp
+        expect resp.status == 200
+        expect resp.http.content-encoding == "gzip"
+        gunzip
+        expect resp.bodylen == resp.http.x-len
+    }
+} -run
diff --git a/reg-tests/compression/output_margin_odd.vtc b/reg-tests/compression/output_margin_odd.vtc
new file mode 100644 (file)
index 0000000..293ca16
--- /dev/null
@@ -0,0 +1,52 @@
+varnishtest "Compression filter: contents that are hard to compress, chunked and sent by calls of 1 to 20000 bytes"
+
+# The deflater's output may be larger than its input on incompressible data,
+# and the compression filter must reserve enough room in the buffer for this.
+# Here a Lua service sends bodies that are incompressible or barely
+# compressible (random bytes, bytes that cost 9 bits each with the fixed tree,
+# random data mixed with text, random data with short matches, runs of 9-bit
+# literals separated by short matches) or repetitive, chunked and sent by calls of 1 to 20000 bytes, through 4 kB
+# buffers. They are decompressed by the client, which checks the gzip
+# trailer's CRC and length, and their length is compared to the one announced
+# by the service. See output_margin.lua for the patterns, and the other
+# output_margin_*.vtc files for other chunkings and nearly full buffers.
+#REGTEST_TYPE=slow
+feature cmd "$HAPROXY_PROGRAM -cc '(feature(ZLIB) || feature(SLZ)) && feature(LUA)'"
+feature ignore_unknown_macro
+
+haproxy h1 -conf {
+    global
+        tune.bufsize 4096
+        tune.lua.bool-sample-conversion normal
+        lua-load ${testdir}/output_margin.lua
+
+    defaults
+        mode http
+        timeout connect "${HAPROXY_TEST_TIMEOUT-5s}"
+        timeout client "${HAPROXY_TEST_TIMEOUT-5s}"
+        timeout server "${HAPROXY_TEST_TIMEOUT-5s}"
+
+    frontend fe-gzip
+        bind "fd@${fe_gzip}"
+        compression algo gzip
+        compression type text/plain
+        default_backend be
+
+    backend be
+        server gen ${h1_gen_addr}:${h1_gen_port}
+
+    listen gen
+        bind "fd@${gen}"
+        http-request use-service lua.gen
+} -start
+
+client c1 -connect ${h1_fe_gzip_sock} {
+    loop 10 {
+        txreq -url "/?set=odd" -hdr "Accept-Encoding: gzip"
+        rxresp
+        expect resp.status == 200
+        expect resp.http.content-encoding == "gzip"
+        gunzip
+        expect resp.bodylen == resp.http.x-len
+    }
+} -run
diff --git a/reg-tests/compression/output_margin_small.vtc b/reg-tests/compression/output_margin_small.vtc
new file mode 100644 (file)
index 0000000..63db08b
--- /dev/null
@@ -0,0 +1,52 @@
+varnishtest "Compression filter: contents that are hard to compress, chunked and sent by calls of 1 to 64 bytes"
+
+# The deflater's output may be larger than its input on incompressible data,
+# and the compression filter must reserve enough room in the buffer for this.
+# Here a Lua service sends bodies that are incompressible or barely
+# compressible (random bytes, bytes that cost 9 bits each with the fixed tree,
+# random data mixed with text, random data with short matches, runs of 9-bit
+# literals separated by short matches) or repetitive, chunked and sent by calls of 1 to 64 bytes, through 4 kB
+# buffers. They are decompressed by the client, which checks the gzip
+# trailer's CRC and length, and their length is compared to the one announced
+# by the service. See output_margin.lua for the patterns, and the other
+# output_margin_*.vtc files for other chunkings and nearly full buffers.
+#REGTEST_TYPE=slow
+feature cmd "$HAPROXY_PROGRAM -cc '(feature(ZLIB) || feature(SLZ)) && feature(LUA)'"
+feature ignore_unknown_macro
+
+haproxy h1 -conf {
+    global
+        tune.bufsize 4096
+        tune.lua.bool-sample-conversion normal
+        lua-load ${testdir}/output_margin.lua
+
+    defaults
+        mode http
+        timeout connect "${HAPROXY_TEST_TIMEOUT-5s}"
+        timeout client "${HAPROXY_TEST_TIMEOUT-5s}"
+        timeout server "${HAPROXY_TEST_TIMEOUT-5s}"
+
+    frontend fe-gzip
+        bind "fd@${fe_gzip}"
+        compression algo gzip
+        compression type text/plain
+        default_backend be
+
+    backend be
+        server gen ${h1_gen_addr}:${h1_gen_port}
+
+    listen gen
+        bind "fd@${gen}"
+        http-request use-service lua.gen
+} -start
+
+client c1 -connect ${h1_fe_gzip_sock} {
+    loop 10 {
+        txreq -url "/?set=small" -hdr "Accept-Encoding: gzip"
+        rxresp
+        expect resp.status == 200
+        expect resp.http.content-encoding == "gzip"
+        gunzip
+        expect resp.bodylen == resp.http.x-len
+    }
+} -run
diff --git a/reg-tests/compression/output_margin_whole.vtc b/reg-tests/compression/output_margin_whole.vtc
new file mode 100644 (file)
index 0000000..4d4a938
--- /dev/null
@@ -0,0 +1,52 @@
+varnishtest "Compression filter: contents that are hard to compress, sent at once with a content-length"
+
+# The deflater's output may be larger than its input on incompressible data,
+# and the compression filter must reserve enough room in the buffer for this.
+# Here a Lua service sends bodies that are incompressible or barely
+# compressible (random bytes, bytes that cost 9 bits each with the fixed tree,
+# random data mixed with text, random data with short matches, runs of 9-bit
+# literals separated by short matches) or repetitive, sent at once with a content-length, through 4 kB
+# buffers. They are decompressed by the client, which checks the gzip
+# trailer's CRC and length, and their length is compared to the one announced
+# by the service. See output_margin.lua for the patterns, and the other
+# output_margin_*.vtc files for other chunkings and nearly full buffers.
+#REGTEST_TYPE=slow
+feature cmd "$HAPROXY_PROGRAM -cc '(feature(ZLIB) || feature(SLZ)) && feature(LUA)'"
+feature ignore_unknown_macro
+
+haproxy h1 -conf {
+    global
+        tune.bufsize 4096
+        tune.lua.bool-sample-conversion normal
+        lua-load ${testdir}/output_margin.lua
+
+    defaults
+        mode http
+        timeout connect "${HAPROXY_TEST_TIMEOUT-5s}"
+        timeout client "${HAPROXY_TEST_TIMEOUT-5s}"
+        timeout server "${HAPROXY_TEST_TIMEOUT-5s}"
+
+    frontend fe-gzip
+        bind "fd@${fe_gzip}"
+        compression algo gzip
+        compression type text/plain
+        default_backend be
+
+    backend be
+        server gen ${h1_gen_addr}:${h1_gen_port}
+
+    listen gen
+        bind "fd@${gen}"
+        http-request use-service lua.gen
+} -start
+
+client c1 -connect ${h1_fe_gzip_sock} {
+    loop 10 {
+        txreq -url "/?set=whole" -hdr "Accept-Encoding: gzip"
+        rxresp
+        expect resp.status == 200
+        expect resp.http.content-encoding == "gzip"
+        gunzip
+        expect resp.bodylen == resp.http.x-len
+    }
+} -run