You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficserver.apache.org by bn...@apache.org on 2026/09/22 21:56:38 UTC
(trafficserver) branch master updated: Test 103 Early Hints before a cached range response (#13715)
This is an automated email from the ASF dual-hosted git repository.
bneradt pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficserver.git
The following commit(s) were added to refs/heads/master by this push:
new 0079038a12 Test 103 Early Hints before a cached range response (#13715)
0079038a12 is described below
commit 0079038a12a9f1b6a71c8c4b5e97b953ea7c4663
Author: Brian Neradt <br...@gmail.com>
AuthorDate: Tue Sep 22 16:56:32 2026 -0500
Test 103 Early Hints before a cached range response (#13715)
Issue #12244 reported ATS aborting on the HttpTunnel skip_bytes
assertion for Range requests of a small, cacheable 308. The fix in
#12906 was tested only with a 100 Continue ahead of a compressed POST
response, so the reported scenario had no coverage. 103 Early Hints
take the same interim-response path as 100 Continue, and a Range miss
with cache.range.write enabled caches the untransformed response behind
the range transform, leaving the cache-write consumer with a stale
header size to skip in a body-only buffer.
This patch extends the regression test with a run in which the origin
sends a 103 Early Hints and then a 65 byte, cacheable 308 that the
client requests with "Range: bytes=0-64". Without the #12906 fix, this
run hits the same assertion reported in the issue; with it, ATS keeps
running.
Fixes: #12244
Co-authored-by: Claude Opus 5.5 (Medium effort) <no...@anthropic.com>
---
.../compress/compress-cache-untransformed.test.py | 80 ++++++++++++++++------
.../compress/compress_100_continue_origin.py | 45 ++++++++++--
2 files changed, 100 insertions(+), 25 deletions(-)
diff --git a/tests/gold_tests/pluginTest/compress/compress-cache-untransformed.test.py b/tests/gold_tests/pluginTest/compress/compress-cache-untransformed.test.py
index bf5e0a85ca..586dc54eca 100644
--- a/tests/gold_tests/pluginTest/compress/compress-cache-untransformed.test.py
+++ b/tests/gold_tests/pluginTest/compress/compress-cache-untransformed.test.py
@@ -18,6 +18,13 @@ HttpTunnel::producer_run fires:
This test uses a custom origin that sends "100 Continue" followed by a
compressible, non-chunked 200 OK to trigger the exact crash path.
+
+The issue itself was reported for range requests of a small, cacheable 308
+response. 103 Early Hints responses are forwarded to HTTP/1.1 clients through
+the same setup_100_continue_transfer() path, and a cache miss for a Range
+request installs the range transform while caching the untransformed
+response. The second test run reproduces that scenario: 103 Early Hints, then
+a 65 byte 308, requested with "Range: bytes=0-64".
'''
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
@@ -40,8 +47,9 @@ import sys
from ports import get_port
Test.Summary = '''
-Regression test for compress plugin with cache=true causing assertion failure
-when origin sends 100 Continue before a compressible response (#12244)
+Regression test for a cache-write assertion failure when the origin sends an
+interim response (100 Continue or 103 Early Hints) before a response that goes
+through a transform (#12244)
'''
Test.SkipUnless(Condition.PluginExists('compress.so'))
@@ -51,31 +59,35 @@ class CompressCacheUntransformedTest:
def __init__(self):
self.setupTS()
- self.run()
+ continue_tr = Test.AddTestRun("100 Continue before a compressed, cached response")
+ early_hints_tr = Test.AddTestRun("103 Early Hints before a range requested, cached 308")
+ self.continue_origin = self._makeOrigin(continue_tr, "continue_origin", "continue")
+ self.early_hints_origin = self._makeOrigin(early_hints_tr, "early_hints_origin", "early-hints")
+ self.configureTS()
+ self.run100Continue(continue_tr)
+ self.runEarlyHintsRange(early_hints_tr)
def setupTS(self):
self.ts = Test.MakeATSProcess("ts", enable_cache=True)
- def run(self):
- tr = Test.AddTestRun()
-
- # Copy scripts into the test run directory.
+ def _makeOrigin(self, tr, name, mode):
tr.Setup.CopyAs("compress_100_continue_origin.py")
tr.Setup.Copy("etc/compress-cache-false.config")
- # Create and configure the custom origin server process.
- origin = tr.Processes.Process("origin")
- origin_port = get_port(origin, 'http_port')
+ origin = tr.Processes.Process(name)
+ port = get_port(origin, 'http_port')
origin.Command = (f'{sys.executable} compress_100_continue_origin.py'
- f' --port {origin_port}')
- origin.Ready = When.PortOpenv4(origin_port)
+ f' --port {port} --mode {mode}')
+ origin.Ready = When.PortOpenv4(port)
origin.ReturnCode = 0
+ tr.Processes.Default.StartBefore(origin)
+ return origin
- # Configure ATS.
+ def configureTS(self):
self.ts.Disk.records_config.update(
{
"proxy.config.diags.debug.enabled": 1,
- "proxy.config.diags.debug.tags": "http|compress|http_tunnel",
+ "proxy.config.diags.debug.tags": "http|compress|http_tunnel|http_range",
# Do NOT send 100 Continue from ATS - let the origin send it.
# This ensures ATS processes the origin's 100 via
# handle_100_continue_response -> setup_100_continue_transfer,
@@ -85,13 +97,25 @@ class CompressCacheUntransformedTest:
# the cache write path where the stale client_response_hdr_bytes
# causes the crash.
"proxy.config.http.cache.post_method": 1,
+ # Cache the response to a Range request on a miss so that the
+ # range transform is installed alongside an untransformed
+ # cache write.
+ "proxy.config.http.cache.range.write": 1,
})
- self.ts.Disk.remap_config.AddLine(
- f'map / http://127.0.0.1:{origin_port}/'
- f' @plugin=compress.so'
- f' @pparam={Test.RunDirectory}/compress-cache-false.config')
-
+ early_hints_port = self.early_hints_origin.Variables.http_port
+ continue_port = self.continue_origin.Variables.http_port
+ self.ts.Disk.remap_config.AddLines(
+ [
+ f'map /early-hints/ http://127.0.0.1:{early_hints_port}/early-hints/'
+ f' @plugin=compress.so'
+ f' @pparam={Test.RunDirectory}/compress-cache-false.config',
+ f'map / http://127.0.0.1:{continue_port}/'
+ f' @plugin=compress.so'
+ f' @pparam={Test.RunDirectory}/compress-cache-false.config',
+ ])
+
+ def run100Continue(self, tr):
# Client sends a POST with Expect: 100-continue but does not wait for
# the 100 response before sending the body (--expect100-timeout 0).
# The crash is triggered by ATS processing the origin's 100 Continue,
@@ -106,7 +130,6 @@ class CompressCacheUntransformedTest:
f' --data "test body data"'
f' http://127.0.0.1:{self.ts.Variables.port}/test/resource.js')
client.ReturnCode = 0
- client.StartBefore(origin)
client.StartBefore(self.ts)
# The key assertion: ATS must still be running after the test.
@@ -114,5 +137,22 @@ class CompressCacheUntransformedTest:
# in HttpTunnel::producer_run.
tr.StillRunningAfter = self.ts
+ def runEarlyHintsRange(self, tr):
+ # The range covers the entire 65 byte 308 body, as in the issue. On a
+ # cache miss ATS installs the range transform and caches the
+ # untransformed response, so the cache-write consumer reads from the
+ # body-only server-to-transform buffer.
+ client = tr.Processes.Default
+ client.Command = (
+ f'curl --http1.1 -s -o /dev/null'
+ f' -H "Range: bytes=0-64"'
+ f' http://127.0.0.1:{self.ts.Variables.port}/early-hints/redirect')
+ client.ReturnCode = 0
+
+ # Without the fix, ATS crashes with a failed assertion in
+ # HttpTunnel::producer_run because the cache-write consumer skips the
+ # size of the forwarded 103 headers in a buffer holding only the body.
+ tr.StillRunningAfter = self.ts
+
CompressCacheUntransformedTest()
diff --git a/tests/gold_tests/pluginTest/compress/compress_100_continue_origin.py b/tests/gold_tests/pluginTest/compress/compress_100_continue_origin.py
index a3b0f29b01..da2c7ada17 100644
--- a/tests/gold_tests/pluginTest/compress/compress_100_continue_origin.py
+++ b/tests/gold_tests/pluginTest/compress/compress_100_continue_origin.py
@@ -1,8 +1,12 @@
#!/usr/bin/env python3
-"""Origin server that sends 100 Continue then a compressible 200 OK.
+"""Origin server that sends an interim 1xx response before the final response.
-Used to reproduce the crash in HttpTunnel::producer_run when compress.so
-with cache=true is combined with a 100 Continue response from the origin.
+Used to reproduce the crash in HttpTunnel::producer_run when a response
+transform is combined with an interim response from the origin:
+
+* continue: 100 Continue followed by a compressible 200 OK.
+* early-hints: 103 Early Hints followed by a small, cacheable 308, as in
+ https://github.com/apache/trafficserver/issues/12244.
"""
# Licensed to the Apache Software Foundation (ASF) under one
@@ -25,11 +29,13 @@ import argparse
import signal
import socket
import sys
+import time
def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument('--port', type=int, required=True, help='Port to listen on')
+ parser.add_argument('--mode', choices=['continue', 'early-hints'], default='continue', help='Which interim response to send')
return parser.parse_args()
@@ -44,7 +50,32 @@ def read_request(conn):
return data
-def handle_connection(conn, addr):
+def send_early_hints_and_redirect(conn):
+ """Send 103 Early Hints followed by a small, cacheable 308.
+
+ The body must be SMALLER than the forwarded 103 response headers so that
+ a stale skip_bytes exceeds read_avail() in producer_run. The pause lets
+ ATS finish forwarding the 103 before it reads the 308.
+ """
+ conn.sendall(
+ b'HTTP/1.1 103 Early Hints\r\n'
+ b'Link: </assets/styles/main-stylesheet.css>; rel=preload; as=style\r\n'
+ b'Link: </assets/scripts/application-bundle.js>; rel=preload; as=script\r\n'
+ b'Link: </assets/fonts/body-font-regular.woff2>; rel=preload; as=font; crossorigin\r\n'
+ b'\r\n')
+ time.sleep(0.5)
+
+ body = b'Redirecting to https://example.com/new/location/for/the/resource\n'
+ conn.sendall(
+ b'HTTP/1.1 308 Permanent Redirect\r\n'
+ b'Location: https://example.com/new/location/for/the/resource\r\n'
+ b'Content-Type: text/plain\r\n'
+ b'Cache-Control: public, max-age=3600\r\n'
+ b'Content-Length: ' + str(len(body)).encode() + b'\r\n'
+ b'\r\n' + body)
+
+
+def handle_connection(conn, addr, mode):
"""Handle a single client connection."""
try:
conn.settimeout(10)
@@ -52,6 +83,10 @@ def handle_connection(conn, addr):
if request is None:
return
+ if mode == 'early-hints':
+ send_early_hints_and_redirect(conn)
+ return
+
# Send 100 Continue immediately.
conn.sendall(b'HTTP/1.1 100 Continue\r\n\r\n')
@@ -114,7 +149,7 @@ def main():
while True:
try:
conn, addr = sock.accept()
- handle_connection(conn, addr)
+ handle_connection(conn, addr, args.mode)
except socket.timeout:
break