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