You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficserver.apache.org by cm...@apache.org on 2026/02/23 18:58:38 UTC

(trafficserver) branch 10.2.x updated (0be74ad934 -> 49ab050e0e)

This is an automated email from the ASF dual-hosted git repository.

cmcfarlen pushed a change to branch 10.2.x
in repository https://gitbox.apache.org/repos/asf/trafficserver.git


    from 0be74ad934 parallel autests: print failure output (#12877)
     new 48b5772b92 hrw: fix lost [OR] modifiers, add more autests (#12904)
     new 5a10eaa925 rpc: tolerate unsupported chmod on unix sockets (#12895)
     new a7a2d2c462 Fix the log field type for cqpv and sqpv (#12890)
     new ffd1d59e26 Fix flaky filter_body test (#12882)
     new 8fda1d9ad5 Fix filter_body plugin: deterministic request blocking and response body blocking (#12876)
     new db021036aa Implement RFC 9213 Targeted HTTP Cache Control (#12679)
     new 3f1ffdfe88 Doc: mention elif, nested conditionals (#12869)
     new 49ab050e0e Add fail action 6, will fallback to serving stale if retry attempts are exhausted (#12852)

The 8 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 doc/admin-guide/configuration/cache-basics.en.rst  |  78 +++
 doc/admin-guide/configuration/hrw4u.en.rst         |  34 +-
 doc/admin-guide/files/records.yaml.en.rst          |  39 +-
 doc/admin-guide/plugins/lua.en.rst                 |   1 +
 .../api/functions/TSHttpOverridableConfig.en.rst   |   1 +
 .../api/types/TSOverridableConfigKey.en.rst        |   1 +
 include/proxy/hdrs/MIME.h                          |   3 +-
 include/proxy/http/HttpConfig.h                    |  48 +-
 include/proxy/http/HttpTransact.h                  |  16 +-
 include/proxy/http/OverridableConfigDefs.h         |   3 +-
 include/ts/apidefs.h.in                            |   1 +
 plugins/experimental/filter_body/filter_body.cc    |  75 ++-
 plugins/header_rewrite/condition.h                 |   6 +
 plugins/header_rewrite/header_rewrite.cc           |   5 +-
 src/api/InkAPI.cc                                  |  21 +
 src/mgmt/rpc/server/IPCSocketServer.cc             |  10 +-
 src/proxy/hdrs/HdrToken.cc                         |   6 +-
 src/proxy/hdrs/MIME.cc                             |  25 +-
 src/proxy/http/HttpCacheSM.cc                      |  18 +-
 src/proxy/http/HttpConfig.cc                       |  74 ++-
 src/proxy/http/HttpSM.cc                           |  48 +-
 src/proxy/http/HttpTransact.cc                     | 228 ++++++-
 src/proxy/logging/Log.cc                           |   4 +-
 src/records/RecordsConfig.cc                       |   6 +-
 tests/gold_tests/autest-site/ats_replay.test.ext   |  36 +-
 ...mode.test.py => cache-read-retry-stale.test.py} |  18 +-
 .../cache/cache-write-lock-contention.test.py      | 175 ++++++
 .../replay/cache-read-retry-stale.replay.yaml      | 187 ++++++
 .../replay/targeted-cache-control.replay.yaml      | 389 ++++++++++++
 ...hing.test.py => targeted-cache-control.test.py} |  12 +-
 .../config/filter_body_response_block.yaml         |   1 -
 .../pluginTest/filter_body/filter_body.replay.yaml |  39 +-
 .../header_rewrite_bundle.replay.yaml              | 669 +++++++++++++++++++++
 .../header_rewrite/rules/complex_logics.conf       | 158 +++++
 34 files changed, 2316 insertions(+), 119 deletions(-)
 copy tests/gold_tests/cache/{cache-read-retry-mode.test.py => cache-read-retry-stale.test.py} (61%)
 create mode 100644 tests/gold_tests/cache/cache-write-lock-contention.test.py
 create mode 100644 tests/gold_tests/cache/replay/cache-read-retry-stale.replay.yaml
 create mode 100644 tests/gold_tests/cache/replay/targeted-cache-control.replay.yaml
 copy tests/gold_tests/cache/{alternate-caching.test.py => targeted-cache-control.test.py} (73%)
 create mode 100644 tests/gold_tests/pluginTest/header_rewrite/rules/complex_logics.conf


(trafficserver) 01/08: hrw: fix lost [OR] modifiers, add more autests (#12904)

Posted by cm...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

cmcfarlen pushed a commit to branch 10.2.x
in repository https://gitbox.apache.org/repos/asf/trafficserver.git

commit 48b5772b92469e86504d86f20710240882455137
Author: Leif Hedstrom <zw...@apache.org>
AuthorDate: Sat Feb 21 00:54:14 2026 -0700

    hrw: fix lost [OR] modifiers, add more autests (#12904)
    
    (cherry picked from commit 29b0e93062cf7a45ad6a55958c8551a9cc95a39b)
---
 plugins/header_rewrite/condition.h                 |   6 +
 plugins/header_rewrite/header_rewrite.cc           |   5 +-
 .../header_rewrite_bundle.replay.yaml              | 669 +++++++++++++++++++++
 .../header_rewrite/rules/complex_logics.conf       | 158 +++++
 4 files changed, 836 insertions(+), 2 deletions(-)

diff --git a/plugins/header_rewrite/condition.h b/plugins/header_rewrite/condition.h
index 1404a747b6..1400f3433a 100644
--- a/plugins/header_rewrite/condition.h
+++ b/plugins/header_rewrite/condition.h
@@ -85,6 +85,12 @@ public:
     return _mods;
   }
 
+  void
+  set_mods(CondModifiers mods)
+  {
+    _mods = mods;
+  }
+
   // Setters
   virtual void
   set_qualifier(const std::string &q)
diff --git a/plugins/header_rewrite/header_rewrite.cc b/plugins/header_rewrite/header_rewrite.cc
index 9378f6022a..09ab73a74c 100644
--- a/plugins/header_rewrite/header_rewrite.cc
+++ b/plugins/header_rewrite/header_rewrite.cc
@@ -329,8 +329,9 @@ RulesConfig::parse_config(const std::string &fname, TSHttpHookID default_hook, c
               if (group_stack.empty()) {
                 throw std::runtime_error("unmatched %{GROUP}");
               } else {
-                delete cond; // We don't care about the closing group condition, it's a no-op
-                ngrp  = group;
+                ngrp = group;
+                ngrp->set_mods(cond->mods());
+                delete cond;
                 group = group_stack.top();
                 group_stack.pop();
                 group->add_condition(ngrp); // Add the previous group to the current group's conditions
diff --git a/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_bundle.replay.yaml b/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_bundle.replay.yaml
index ea1c966185..8e6e9a995c 100644
--- a/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_bundle.replay.yaml
+++ b/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_bundle.replay.yaml
@@ -155,6 +155,13 @@ autest:
             args:
               - "rules/run_plugin.conf"
 
+      - from: "http://www.example.com/from_15/"
+        to: "http://backend.ex:{SERVER_HTTP_PORT}/to_15/"
+        plugins:
+          - name: "header_rewrite.so"
+            args:
+              - "rules/complex_logics.conf"
+
     log_validation:
       traffic_out:
         excludes:
@@ -1114,3 +1121,665 @@ sessions:
 
     proxy-response:
       status: 200
+
+# ==========================================================================
+# Tests 31-52: Complex GROUP logic tests (rules/complex_logics.conf)
+# ==========================================================================
+
+# Test 31: GROUP [OR] - only A header present (should match via group)
+- transactions:
+  - client-request:
+      method: "GET"
+      version: "1.1"
+      url: /from_15/logic/group-or
+      headers:
+        fields:
+        - [ Host, www.example.com ]
+        - [ X-Group-A, "yes" ]
+        - [ uuid, 37 ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Group-Or-Result, { value: "matched", as: equal } ]
+
+# Test 32: GROUP [OR] - only B header present (should match via OR)
+- transactions:
+  - client-request:
+      method: "GET"
+      version: "1.1"
+      url: /from_15/logic/group-or
+      headers:
+        fields:
+        - [ Host, www.example.com ]
+        - [ X-Group-B, "yes" ]
+        - [ uuid, 38 ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Group-Or-Result, { value: "matched", as: equal } ]
+
+# Test 33: GROUP [OR] - neither header present (should not match)
+- transactions:
+  - client-request:
+      method: "GET"
+      version: "1.1"
+      url: /from_15/logic/group-or
+      headers:
+        fields:
+        - [ Host, www.example.com ]
+        - [ uuid, 39 ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Group-Or-Result, { as: absent } ]
+
+# Test 34: GROUP [AND] - both headers present (should match)
+- transactions:
+  - client-request:
+      method: "GET"
+      version: "1.1"
+      url: /from_15/logic/group-and
+      headers:
+        fields:
+        - [ Host, www.example.com ]
+        - [ X-And-A, "yes" ]
+        - [ X-And-B, "yes" ]
+        - [ uuid, 40 ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Group-And-Result, { value: "matched", as: equal } ]
+
+# Test 35: GROUP [AND] - only A present (should not match)
+- transactions:
+  - client-request:
+      method: "GET"
+      version: "1.1"
+      url: /from_15/logic/group-and
+      headers:
+        fields:
+        - [ Host, www.example.com ]
+        - [ X-And-A, "yes" ]
+        - [ uuid, 41 ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Group-And-Result, { as: absent } ]
+
+# Test 36: GROUP [NOT] - neither header present -> group=(false) -> NOT(false)=true
+- transactions:
+  - client-request:
+      method: "GET"
+      version: "1.1"
+      url: /from_15/logic/group-not
+      headers:
+        fields:
+        - [ Host, www.example.com ]
+        - [ uuid, 42 ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Group-Not-Result, { value: "matched", as: equal } ]
+
+# Test 37: GROUP [NOT] - both headers present -> group=(true) -> NOT(true)=false
+- transactions:
+  - client-request:
+      method: "GET"
+      version: "1.1"
+      url: /from_15/logic/group-not
+      headers:
+        fields:
+        - [ Host, www.example.com ]
+        - [ X-Not-A, "yes" ]
+        - [ X-Not-B, "yes" ]
+        - [ uuid, 43 ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Group-Not-Result, { as: absent } ]
+
+# Test 38: Nested GROUP - outer + inner-A present (should match)
+- transactions:
+  - client-request:
+      method: "GET"
+      version: "1.1"
+      url: /from_15/logic/nested-group
+      headers:
+        fields:
+        - [ Host, www.example.com ]
+        - [ X-Outer-A, "yes" ]
+        - [ X-Inner-A, "yes" ]
+        - [ uuid, 44 ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Nested-Group-Result, { value: "matched", as: equal } ]
+
+# Test 39: Nested GROUP - outer + inner-B present (should match via inner OR)
+- transactions:
+  - client-request:
+      method: "GET"
+      version: "1.1"
+      url: /from_15/logic/nested-group
+      headers:
+        fields:
+        - [ Host, www.example.com ]
+        - [ X-Outer-A, "yes" ]
+        - [ X-Inner-B, "yes" ]
+        - [ uuid, 45 ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Nested-Group-Result, { value: "matched", as: equal } ]
+
+# Test 40: Nested GROUP - no outer header (should not match)
+- transactions:
+  - client-request:
+      method: "GET"
+      version: "1.1"
+      url: /from_15/logic/nested-group
+      headers:
+        fields:
+        - [ Host, www.example.com ]
+        - [ X-Inner-A, "yes" ]
+        - [ uuid, 46 ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Nested-Group-Result, { as: absent } ]
+
+# Test 41: Two GROUPs with OR - left group matches
+- transactions:
+  - client-request:
+      method: "GET"
+      version: "1.1"
+      url: /from_15/logic/two-groups
+      headers:
+        fields:
+        - [ Host, www.example.com ]
+        - [ X-Left-A, "yes" ]
+        - [ X-Left-B, "yes" ]
+        - [ uuid, 47 ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Two-Groups-Result, { value: "matched", as: equal } ]
+
+# Test 42: Two GROUPs with OR - right group matches
+- transactions:
+  - client-request:
+      method: "GET"
+      version: "1.1"
+      url: /from_15/logic/two-groups
+      headers:
+        fields:
+        - [ Host, www.example.com ]
+        - [ X-Right-A, "yes" ]
+        - [ X-Right-B, "yes" ]
+        - [ uuid, 48 ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Two-Groups-Result, { value: "matched", as: equal } ]
+
+# Test 43: Two GROUPs with OR - neither group matches (partial left)
+- transactions:
+  - client-request:
+      method: "GET"
+      version: "1.1"
+      url: /from_15/logic/two-groups
+      headers:
+        fields:
+        - [ Host, www.example.com ]
+        - [ X-Left-A, "yes" ]
+        - [ X-Right-B, "yes" ]
+        - [ uuid, 49 ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Two-Groups-Result, { as: absent } ]
+
+# Test 44: GROUP inside if - alpha branch with X-Sub-A (should match)
+- transactions:
+  - client-request:
+      method: "GET"
+      version: "1.1"
+      url: /from_15/logic/if-group
+      headers:
+        fields:
+        - [ Host, www.example.com ]
+        - [ X-Branch, "alpha" ]
+        - [ X-Sub-A, "yes" ]
+        - [ uuid, 50 ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-If-Group-Result, { value: "alpha", as: equal } ]
+
+# Test 45: GROUP inside if - alpha branch without sub headers (falls to else)
+- transactions:
+  - client-request:
+      method: "GET"
+      version: "1.1"
+      url: /from_15/logic/if-group
+      headers:
+        fields:
+        - [ Host, www.example.com ]
+        - [ X-Branch, "alpha" ]
+        - [ uuid, 51 ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-If-Group-Result, { value: "other", as: equal } ]
+
+# Test 46: GROUP inside if - beta branch
+- transactions:
+  - client-request:
+      method: "GET"
+      version: "1.1"
+      url: /from_15/logic/if-group
+      headers:
+        fields:
+        - [ Host, www.example.com ]
+        - [ X-Branch, "beta" ]
+        - [ uuid, 52 ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-If-Group-Result, { value: "beta", as: equal } ]
+
+# Test 47: Nested if with GROUP OR - outer yes, case-A present
+- transactions:
+  - client-request:
+      method: "GET"
+      version: "1.1"
+      url: /from_15/logic/nested-if-group
+      headers:
+        fields:
+        - [ Host, www.example.com ]
+        - [ X-Outer, "yes" ]
+        - [ X-Case-A, "yes" ]
+        - [ uuid, 53 ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Nested-If-Result, { value: "inner-match", as: equal } ]
+
+# Test 48: Nested if with GROUP OR - outer yes, neither case header
+- transactions:
+  - client-request:
+      method: "GET"
+      version: "1.1"
+      url: /from_15/logic/nested-if-group
+      headers:
+        fields:
+        - [ Host, www.example.com ]
+        - [ X-Outer, "yes" ]
+        - [ uuid, 54 ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Nested-If-Result, { value: "inner-miss", as: equal } ]
+
+# Test 49: Nested if with GROUP OR - outer miss
+- transactions:
+  - client-request:
+      method: "GET"
+      version: "1.1"
+      url: /from_15/logic/nested-if-group
+      headers:
+        fields:
+        - [ Host, www.example.com ]
+        - [ X-Outer, "no" ]
+        - [ uuid, 55 ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Nested-If-Result, { value: "outer-miss", as: equal } ]
+
+# Test 50: Complex (A OR B) AND (C OR D) - P+R present (should match)
+- transactions:
+  - client-request:
+      method: "GET"
+      version: "1.1"
+      url: /from_15/logic/complex-and-or
+      headers:
+        fields:
+        - [ Host, www.example.com ]
+        - [ X-P, "yes" ]
+        - [ X-R, "yes" ]
+        - [ uuid, 56 ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Complex-Result, { value: "matched", as: equal } ]
+
+# Test 51: Complex (A OR B) AND (C OR D) - Q+S present (should match)
+- transactions:
+  - client-request:
+      method: "GET"
+      version: "1.1"
+      url: /from_15/logic/complex-and-or
+      headers:
+        fields:
+        - [ Host, www.example.com ]
+        - [ X-Q, "yes" ]
+        - [ X-S, "yes" ]
+        - [ uuid, 57 ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Complex-Result, { value: "matched", as: equal } ]
+
+# Test 52: Complex (A OR B) AND (C OR D) - only P present (first group
+# matches but second doesn't, should not match)
+- transactions:
+  - client-request:
+      method: "GET"
+      version: "1.1"
+      url: /from_15/logic/complex-and-or
+      headers:
+        fields:
+        - [ Host, www.example.com ]
+        - [ X-P, "yes" ]
+        - [ uuid, 58 ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Complex-Result, { as: absent } ]
+
+# Test 53: IP:CLIENT in GROUP with [OR] - IP matches (client is 127.0.0.1)
+- transactions:
+  - client-request:
+      method: "GET"
+      version: "1.1"
+      url: /from_15/logic/ip-group-or
+      headers:
+        fields:
+        - [ Host, www.example.com ]
+        - [ uuid, 59 ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Ip-Group-Or-Result, { value: "matched", as: equal } ]
+
+# Test 54: IP:CLIENT in GROUP with [OR] - header X-Outer matches too
+- transactions:
+  - client-request:
+      method: "GET"
+      version: "1.1"
+      url: /from_15/logic/ip-group-or
+      headers:
+        fields:
+        - [ Host, www.example.com ]
+        - [ X-Outer, "true" ]
+        - [ uuid, 60 ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Ip-Group-Or-Result, { value: "matched", as: equal } ]
+
+# Test 55: GROUP as first condition after hook - X-First-A present, should match
+- transactions:
+  - client-request:
+      method: "GET"
+      version: "1.1"
+      url: /from_15/logic/group-first
+      headers:
+        fields:
+        - [ Host, www.example.com ]
+        - [ X-First-A, "yes" ]
+        - [ uuid, 61 ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Group-First-Result, { value: "matched", as: equal } ]
+
+# Test 56: GROUP as first condition after hook - no X-First-A, should not match
+- transactions:
+  - client-request:
+      method: "GET"
+      version: "1.1"
+      url: /from_15/logic/group-first
+      headers:
+        fields:
+        - [ Host, www.example.com ]
+        - [ uuid, 62 ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Group-First-Result, { as: absent } ]
diff --git a/tests/gold_tests/pluginTest/header_rewrite/rules/complex_logics.conf b/tests/gold_tests/pluginTest/header_rewrite/rules/complex_logics.conf
new file mode 100644
index 0000000000..b4f4b92518
--- /dev/null
+++ b/tests/gold_tests/pluginTest/header_rewrite/rules/complex_logics.conf
@@ -0,0 +1,158 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# All rules use SEND_RESPONSE_HDR_HOOK so set-header operates on the
+# response that the client sees.  CLIENT-HEADER and CLIENT-URL:PATH
+# conditions always refer to the original client request regardless of hook.
+
+# ---- Test: GROUP with [OR] on GROUP:END ----
+# hrw4u: if (inbound.req.X-Group-A) || inbound.req.X-Group-B
+cond %{SEND_RESPONSE_HDR_HOOK} [AND]
+cond %{CLIENT-URL:PATH} /\/logic\/group-or$/ [AND]
+cond %{GROUP}
+    cond %{CLIENT-HEADER:X-Group-A} ="" [NOT]
+cond %{GROUP:END} [OR]
+cond %{CLIENT-HEADER:X-Group-B} ="" [NOT]
+    set-header X-Group-Or-Result "matched"
+
+# ---- Test: GROUP with [AND] on GROUP:END (explicit) ----
+# hrw4u: if (inbound.req.X-And-A) && inbound.req.X-And-B
+cond %{SEND_RESPONSE_HDR_HOOK} [AND]
+cond %{CLIENT-URL:PATH} /\/logic\/group-and$/ [AND]
+cond %{GROUP}
+    cond %{CLIENT-HEADER:X-And-A} ="" [NOT]
+cond %{GROUP:END} [AND]
+cond %{CLIENT-HEADER:X-And-B} ="" [NOT]
+    set-header X-Group-And-Result "matched"
+
+# ---- Test: GROUP with [NOT] on GROUP:END ----
+# hrw4u: if !(inbound.req.X-Not-A && inbound.req.X-Not-B)
+cond %{SEND_RESPONSE_HDR_HOOK} [AND]
+cond %{CLIENT-URL:PATH} /\/logic\/group-not$/ [AND]
+cond %{GROUP}
+    cond %{CLIENT-HEADER:X-Not-A} ="" [NOT]
+    cond %{CLIENT-HEADER:X-Not-B} ="" [NOT]
+cond %{GROUP:END} [NOT]
+    set-header X-Group-Not-Result "matched"
+
+# ---- Test: Nested GROUPs with mixed OR/AND ----
+# hrw4u: if inbound.req.X-Outer-A && (inbound.req.X-Inner-A || inbound.req.X-Inner-B)
+cond %{SEND_RESPONSE_HDR_HOOK} [AND]
+cond %{CLIENT-URL:PATH} /\/logic\/nested-group$/ [AND]
+cond %{CLIENT-HEADER:X-Outer-A} ="" [NOT]
+cond %{GROUP}
+    cond %{CLIENT-HEADER:X-Inner-A} ="" [NOT,OR]
+    cond %{CLIENT-HEADER:X-Inner-B} ="" [NOT]
+cond %{GROUP:END}
+    set-header X-Nested-Group-Result "matched"
+
+# ---- Test: Two GROUPs with OR between them ----
+# hrw4u: if (inbound.req.X-Left-A && inbound.req.X-Left-B) || (inbound.req.X-Right-A && inbound.req.X-Right-B)
+cond %{SEND_RESPONSE_HDR_HOOK} [AND]
+cond %{CLIENT-URL:PATH} /\/logic\/two-groups$/ [AND]
+cond %{GROUP}
+    cond %{CLIENT-HEADER:X-Left-A} ="" [NOT]
+    cond %{CLIENT-HEADER:X-Left-B} ="" [NOT]
+cond %{GROUP:END} [OR]
+cond %{GROUP}
+    cond %{CLIENT-HEADER:X-Right-A} ="" [NOT]
+    cond %{CLIENT-HEADER:X-Right-B} ="" [NOT]
+cond %{GROUP:END}
+    set-header X-Two-Groups-Result "matched"
+
+# ---- Test: GROUP inside if/elif/else ----
+# hrw4u:
+#   if inbound.req.X-Branch == "alpha" && (inbound.req.X-Sub-A || inbound.req.X-Sub-B)
+#     -> "alpha"
+#   elif inbound.req.X-Branch == "beta"
+#     -> "beta"
+#   else
+#     -> "other"
+cond %{SEND_RESPONSE_HDR_HOOK} [AND]
+cond %{CLIENT-URL:PATH} /\/logic\/if-group$/ [AND]
+  if
+    cond %{CLIENT-HEADER:X-Branch} ="alpha"
+    cond %{GROUP}
+        cond %{CLIENT-HEADER:X-Sub-A} ="" [NOT,OR]
+        cond %{CLIENT-HEADER:X-Sub-B} ="" [NOT]
+    cond %{GROUP:END}
+      set-header X-If-Group-Result "alpha"
+  elif
+    cond %{CLIENT-HEADER:X-Branch} ="beta"
+      set-header X-If-Group-Result "beta"
+  else
+    set-header X-If-Group-Result "other"
+  endif
+
+# ---- Test: GROUP with OR inside nested if ----
+# hrw4u:
+#   if inbound.req.X-Outer == "yes"
+#     if (inbound.req.X-Case-A || inbound.req.X-Case-B)
+#       -> "inner-match"
+#     else
+#       -> "inner-miss"
+#   else
+#     -> "outer-miss"
+cond %{SEND_RESPONSE_HDR_HOOK} [AND]
+cond %{CLIENT-URL:PATH} /\/logic\/nested-if-group$/ [AND]
+  if
+    cond %{CLIENT-HEADER:X-Outer} ="yes"
+      if
+        cond %{GROUP}
+            cond %{CLIENT-HEADER:X-Case-A} ="" [NOT,OR]
+            cond %{CLIENT-HEADER:X-Case-B} ="" [NOT]
+        cond %{GROUP:END}
+          set-header X-Nested-If-Result "inner-match"
+      else
+        set-header X-Nested-If-Result "inner-miss"
+      endif
+  else
+    set-header X-Nested-If-Result "outer-miss"
+  endif
+
+# ---- Test: Complex expression: (A OR B) AND (C OR D) ----
+# hrw4u: if (inbound.req.X-P || inbound.req.X-Q) && (inbound.req.X-R || inbound.req.X-S)
+cond %{SEND_RESPONSE_HDR_HOOK} [AND]
+cond %{CLIENT-URL:PATH} /\/logic\/complex-and-or$/ [AND]
+cond %{GROUP}
+    cond %{CLIENT-HEADER:X-P} ="" [NOT,OR]
+    cond %{CLIENT-HEADER:X-Q} ="" [NOT]
+cond %{GROUP:END} [AND]
+cond %{GROUP}
+    cond %{CLIENT-HEADER:X-R} ="" [NOT,OR]
+    cond %{CLIENT-HEADER:X-S} ="" [NOT]
+cond %{GROUP:END}
+    set-header X-Complex-Result "matched"
+
+# ---- Test: IP:CLIENT in GROUP with [OR]
+# hrw4u: if (inbound.ip in {127.0.0.0/8}) || inbound.req.X-Outer == "true"
+cond %{SEND_RESPONSE_HDR_HOOK} [AND]
+cond %{CLIENT-URL:PATH} /\/logic\/ip-group-or$/ [AND]
+cond %{GROUP}
+    cond %{IP:CLIENT} {127.0.0.0/8}
+cond %{GROUP:END} [OR]
+cond %{CLIENT-HEADER:X-Outer} ="true"
+    set-header X-Ip-Group-Or-Result "matched"
+
+# ---- Test: GROUP as first condition after hook ----
+# hrw4u: if (inbound.url.path ~ /\/logic\/group-first$/) && inbound.req.X-First-A
+cond %{SEND_RESPONSE_HDR_HOOK} [AND]
+cond %{GROUP}
+    cond %{CLIENT-URL:PATH} /\/logic\/group-first$/
+cond %{GROUP:END} [AND]
+cond %{CLIENT-HEADER:X-First-A} ="" [NOT]
+    set-header X-Group-First-Result "matched"


(trafficserver) 06/08: Implement RFC 9213 Targeted HTTP Cache Control (#12679)

Posted by cm...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

cmcfarlen pushed a commit to branch 10.2.x
in repository https://gitbox.apache.org/repos/asf/trafficserver.git

commit db021036aabd7baf5670ba04781db004e34e5389
Author: Brian Neradt <br...@gmail.com>
AuthorDate: Thu Feb 19 13:38:08 2026 -0600

    Implement RFC 9213 Targeted HTTP Cache Control (#12679)
    
    This adds support for targeted Cache-Control headers (like CDN-Cache-Control)
    that allow cache directives to be targeted at specific caches. The
    implementation includes a configurable, priority-ordered list of targeted
    headers via proxy.config.http.cache.targeted_cache_control_headers, which is
    overridable per-remap rule. When a targeted header is present, it takes
    precedence over the standard Cache-Control header for caching decisions.
    Targeted headers are passed through downstream to allow proper cache hierarchy
    behavior.
    
    Fixes: #9113
    (cherry picked from commit 20c074e7a2e56e85ec9d5b858ec9432531d9008e)
---
 doc/admin-guide/configuration/cache-basics.en.rst  |  78 +++++
 doc/admin-guide/files/records.yaml.en.rst          |  25 ++
 doc/admin-guide/plugins/lua.en.rst                 |   1 +
 .../api/functions/TSHttpOverridableConfig.en.rst   |   1 +
 .../api/types/TSOverridableConfigKey.en.rst        |   1 +
 include/proxy/hdrs/MIME.h                          |   3 +-
 include/proxy/http/HttpConfig.h                    |  47 ++-
 include/proxy/http/OverridableConfigDefs.h         |   3 +-
 include/ts/apidefs.h.in                            |   1 +
 src/api/InkAPI.cc                                  |  21 ++
 src/proxy/hdrs/HdrToken.cc                         |   6 +-
 src/proxy/hdrs/MIME.cc                             |  25 +-
 src/proxy/http/HttpConfig.cc                       |  70 +++-
 src/proxy/http/HttpSM.cc                           |  19 +-
 src/records/RecordsConfig.cc                       |   2 +
 .../replay/targeted-cache-control.replay.yaml      | 389 +++++++++++++++++++++
 .../cache/targeted-cache-control.test.py           |  25 ++
 17 files changed, 702 insertions(+), 15 deletions(-)

diff --git a/doc/admin-guide/configuration/cache-basics.en.rst b/doc/admin-guide/configuration/cache-basics.en.rst
index b3b7189817..b03d2bdae0 100644
--- a/doc/admin-guide/configuration/cache-basics.en.rst
+++ b/doc/admin-guide/configuration/cache-basics.en.rst
@@ -234,6 +234,84 @@ Traffic Server applies ``Cache-Control`` servability criteria after HTTP
 freshness criteria. For example, an object might be considered fresh but will
 not be served if its age is greater than its ``max-age``.
 
+Targeted Cache Control (RFC 9213)
+----------------------------------
+
+Traffic Server supports `RFC 9213 <https://httpwg.org/specs/rfc9213.html>`_
+Targeted HTTP Cache Control, which allows origin servers to provide different
+cache directives for different classes of caches. This is particularly useful in CDN deployments where you want to
+give different caching instructions to CDN caches versus browser caches.
+
+For example, an origin server might send::
+
+    Cache-Control: max-age=60
+    CDN-Cache-Control: max-age=3600
+
+When targeted cache control is enabled (via
+:ts:cv:`proxy.config.http.cache.targeted_cache_control_headers`), Traffic
+Server will use the ``CDN-Cache-Control`` directives instead of the standard
+``Cache-Control`` directives for caching decisions. The browser receiving the
+response will see both headers and use the standard ``Cache-Control``, allowing
+the object to be cached for 60 seconds in the browser but 3600 seconds in the CDN.
+
+Configuration
+~~~~~~~~~~~~~
+
+To enable targeted cache control, set
+:ts:cv:`proxy.config.http.cache.targeted_cache_control_headers` to a
+comma-separated list of header names to check in priority order::
+
+    # In records.yaml:
+    proxy.config.http.cache.targeted_cache_control_headers: CDN-Cache-Control
+
+Or with multiple targeted headers in priority order::
+
+    proxy.config.http.cache.targeted_cache_control_headers: ATS-Cache-Control,CDN-Cache-Control
+
+This configuration is overridable per-remap, allowing different rules for
+different origins::
+
+    # In remap.config:
+    map / https://origin.example.com/ @plugin=conf_remap.so \
+        @pparam=proxy.config.http.cache.targeted_cache_control_headers=CDN-Cache-Control
+
+Behavior
+~~~~~~~~
+
+- When a targeted header is found (first match in the priority list), its
+  directives replace the standard ``Cache-Control`` directives for all caching
+  decisions.
+
+- If no targeted headers are present or they are all empty, Traffic Server falls
+  back to the standard ``Cache-Control`` header.
+
+- Targeted headers are passed through to downstream caches, allowing CDN chains
+  to use the same directives.
+
+- All standard cache control directives are supported in targeted headers:
+  ``max-age``, ``s-maxage``, ``no-cache``, ``no-store``, ``private``,
+  ``must-revalidate``, etc.
+
+Use Cases
+~~~~~~~~~
+
+**CDN with origin cache**: An origin might have its own caching layer but want
+CDNs to cache more aggressively::
+
+    Cache-Control: max-age=60
+    CDN-Cache-Control: max-age=86400
+
+**Different CDN policies**: Using multiple CDN providers with different needs::
+
+    Cache-Control: max-age=300
+    CDN1-Cache-Control: max-age=3600
+    CDN2-Cache-Control: max-age=1800
+
+**Prevent CDN caching while allowing browser caching**::
+
+    Cache-Control: max-age=300
+    CDN-Cache-Control: no-store
+
 Revalidating HTTP Objects
 -------------------------
 
diff --git a/doc/admin-guide/files/records.yaml.en.rst b/doc/admin-guide/files/records.yaml.en.rst
index e7057658d3..d30a54a317 100644
--- a/doc/admin-guide/files/records.yaml.en.rst
+++ b/doc/admin-guide/files/records.yaml.en.rst
@@ -2518,6 +2518,31 @@ Cache Control
          ``Cache-Control: max-age``.
    ===== ======================================================================
 
+.. ts:cv:: CONFIG proxy.config.http.cache.targeted_cache_control_headers STRING ""
+   :reloadable:
+   :overridable:
+
+   Comma-separated list of targeted cache control header names to check in priority
+   order before falling back to the standard ``Cache-Control`` header. This implements
+   `RFC 9213 <https://httpwg.org/specs/rfc9213.html>`_ Targeted HTTP Cache Control.
+   When empty (the default), targeted cache control is disabled and only the standard
+   ``Cache-Control`` header is used.
+
+   Example values:
+
+   - ``CDN-Cache-Control`` - Use only CDN-Cache-Control if present
+   - ``ATS-Cache-Control,CDN-Cache-Control`` - Check ATS-Cache-Control first, then
+     CDN-Cache-Control, then fall back to Cache-Control
+
+   When a targeted header is found, its directives are used rather than those in the
+   standard ``Cache-Control`` header for caching decisions. The targeted headers are
+   passed through to downstream caches.
+
+   .. note::
+
+      This implementation uses the existing Cache-Control parser rather than the
+      strict RFC 8941 Structured Fields parser specified in RFC 9213.
+
 .. ts:cv:: CONFIG proxy.config.http.cache.max_stale_age INT 604800
    :reloadable:
    :overridable:
diff --git a/doc/admin-guide/plugins/lua.en.rst b/doc/admin-guide/plugins/lua.en.rst
index bc6f7423ac..122547ca58 100644
--- a/doc/admin-guide/plugins/lua.en.rst
+++ b/doc/admin-guide/plugins/lua.en.rst
@@ -4810,6 +4810,7 @@ Http config constants
     TS_LUA_CONFIG_NET_SOCK_NOTSENT_LOWAT
     TS_LUA_CONFIG_BODY_FACTORY_RESPONSE_SUPPRESSION_MODE
     TS_LUA_CONFIG_HTTP_CACHE_POST_METHOD
+    TS_LUA_CONFIG_HTTP_CACHE_TARGETED_CACHE_CONTROL_HEADERS
     TS_LUA_CONFIG_LAST_ENTRY
 
 :ref:`TOP <admin-plugins-ts-lua>`
diff --git a/doc/developer-guide/api/functions/TSHttpOverridableConfig.en.rst b/doc/developer-guide/api/functions/TSHttpOverridableConfig.en.rst
index 73865be169..9a175f4e15 100644
--- a/doc/developer-guide/api/functions/TSHttpOverridableConfig.en.rst
+++ b/doc/developer-guide/api/functions/TSHttpOverridableConfig.en.rst
@@ -196,6 +196,7 @@ TSOverridableConfigKey Value                                              Config
 :enumerator:`TS_CONFIG_NET_SOCK_NOTSENT_LOWAT`                          :ts:cv:`proxy.config.net.sock_notsent_lowat`
 :enumerator:`TS_CONFIG_BODY_FACTORY_RESPONSE_SUPPRESSION_MODE`          :ts:cv:`proxy.config.body_factory.response_suppression_mode`
 :enumerator:`TS_CONFIG_HTTP_CACHE_POST_METHOD`                          :ts:cv:`proxy.config.http.cache.post_method`
+:enumerator:`TS_CONFIG_HTTP_CACHE_TARGETED_CACHE_CONTROL_HEADERS`       :ts:cv:`proxy.config.http.cache.targeted_cache_control_headers`
 ======================================================================  ====================================================================
 
 Examples
diff --git a/doc/developer-guide/api/types/TSOverridableConfigKey.en.rst b/doc/developer-guide/api/types/TSOverridableConfigKey.en.rst
index 64c4b19571..56d325e619 100644
--- a/doc/developer-guide/api/types/TSOverridableConfigKey.en.rst
+++ b/doc/developer-guide/api/types/TSOverridableConfigKey.en.rst
@@ -163,6 +163,7 @@ Enumeration Members
 .. enumerator:: TS_CONFIG_HTTP_NO_DNS_JUST_FORWARD_TO_PARENT
 .. enumerator:: TS_CONFIG_HTTP_CACHE_IGNORE_QUERY
 .. enumerator:: TS_CONFIG_HTTP_CACHE_POST_METHOD
+.. enumerator:: TS_CONFIG_HTTP_CACHE_TARGETED_CACHE_CONTROL_HEADERS
 
 
 Description
diff --git a/include/proxy/hdrs/MIME.h b/include/proxy/hdrs/MIME.h
index 4bed80dc13..7605fc0640 100644
--- a/include/proxy/hdrs/MIME.h
+++ b/include/proxy/hdrs/MIME.h
@@ -325,7 +325,8 @@ struct MIMEHdrImpl : public HdrHeapObjImpl {
   void check_strings(HeapCheck *heaps, int num_heaps);
 
   // Cooked values
-  void recompute_cooked_stuff(MIMEField *changing_field_or_null = nullptr);
+  void recompute_cooked_stuff(MIMEField *changing_field_or_null = nullptr, const std::string_view *targeted_headers = nullptr,
+                              size_t targeted_headers_count = 0);
   void recompute_accelerators_and_presence_bits();
 
   // Utility
diff --git a/include/proxy/http/HttpConfig.h b/include/proxy/http/HttpConfig.h
index 85cb7ea433..93377a4cd1 100644
--- a/include/proxy/http/HttpConfig.h
+++ b/include/proxy/http/HttpConfig.h
@@ -92,6 +92,46 @@ private:
   HttpStatusBitset _data;
 };
 
+/**
+ * Pre-parsed list of targeted cache control header names (RFC 9213).
+ *
+ * Instead of parsing a comma-separated string on each request, this class
+ * stores the header names as an array of string_views into a stable backing
+ * string. The Converter ensures the string is parsed once at config load time
+ * and whenever the per-transaction override is set.
+ */
+class TargetedCacheControlHeaders
+{
+public:
+  static const MgmtConverter Conv;
+  // Must remain copy/move-capable: instances are stored in ParsedValue's
+  // std::variant and then kept inside ParsedConfigCache containers.
+
+  /// Maximum number of targeted headers supported.
+  static constexpr size_t MAX_HEADERS = 8;
+
+  char            *conf_value{nullptr};
+  std::string_view headers[MAX_HEADERS];
+  size_t           count{0};
+
+  /// Parse a comma-separated header list into the headers array.
+  void parse(std::string_view src);
+
+  /// Return a pointer to the parsed headers array.
+  const std::string_view *
+  get_headers() const
+  {
+    return headers;
+  }
+
+  /// Return the number of parsed headers.
+  size_t
+  get_count() const
+  {
+    return count;
+  }
+};
+
 struct HttpStatsBlock {
   // Need two stats for these for counts and times
   Metrics::Counter::AtomicType *background_fill_bytes_aborted;
@@ -550,6 +590,8 @@ struct OverridableHttpConfigParams {
   MgmtByte cache_range_write              = 0;
   MgmtByte allow_multi_range              = 0;
 
+  TargetedCacheControlHeaders targeted_cache_control_headers;
+
   MgmtByte ignore_accept_mismatch          = 0;
   MgmtByte ignore_accept_language_mismatch = 0;
   MgmtByte ignore_accept_encoding_mismatch = 0;
@@ -884,7 +926,9 @@ public:
    */
   struct ParsedValue {
     std::string conf_value_storage{}; // Owns the string data.
-    std::variant<std::monostate, HostResData, HttpStatusCodeList, HttpForwarded::OptionBitSet, MgmtByte> parsed{};
+    std::variant<std::monostate, HostResData, HttpStatusCodeList, HttpForwarded::OptionBitSet, MgmtByte,
+                 TargetedCacheControlHeaders>
+      parsed{};
   };
 
   /** Return the parsed value for the configuration.
@@ -985,6 +1029,7 @@ inline HttpConfigParams::~HttpConfigParams()
   ats_free(oride.host_res_data.conf_value);
   ats_free(oride.negative_caching_list.conf_value);
   ats_free(oride.negative_revalidating_list.conf_value);
+  ats_free(oride.targeted_cache_control_headers.conf_value);
 
   delete connect_ports;
   delete redirect_actions_map;
diff --git a/include/proxy/http/OverridableConfigDefs.h b/include/proxy/http/OverridableConfigDefs.h
index 52440b40b1..d70c4c54ca 100644
--- a/include/proxy/http/OverridableConfigDefs.h
+++ b/include/proxy/http/OverridableConfigDefs.h
@@ -249,6 +249,7 @@
   X(HTTP_NEGATIVE_CACHING_LIST,                     negative_caching_list,                      "proxy.config.http.negative_caching_list",                        STRING, HttpStatusCodeList_Conv) \
   X(HTTP_CONNECT_ATTEMPTS_RETRY_BACKOFF_BASE,       connect_attempts_retry_backoff_base,        "proxy.config.http.connect_attempts_retry_backoff_base",          INT,    GENERIC) \
   X(HTTP_NEGATIVE_REVALIDATING_LIST,                negative_revalidating_list,                 "proxy.config.http.negative_revalidating_list",                   STRING, HttpStatusCodeList_Conv) \
-  X(HTTP_CACHE_POST_METHOD,                         cache_post_method,                          "proxy.config.http.cache.post_method",                            INT,    GENERIC)
+  X(HTTP_CACHE_POST_METHOD,                         cache_post_method,                          "proxy.config.http.cache.post_method",                            INT,    GENERIC) \
+  X(HTTP_CACHE_TARGETED_CACHE_CONTROL_HEADERS,      targeted_cache_control_headers,             "proxy.config.http.cache.targeted_cache_control_headers",         STRING, TargetedCacheControlHeaders_Conv)
 
 // clang-format on
diff --git a/include/ts/apidefs.h.in b/include/ts/apidefs.h.in
index 078ce0eb69..5515aa7665 100644
--- a/include/ts/apidefs.h.in
+++ b/include/ts/apidefs.h.in
@@ -908,6 +908,7 @@ enum TSOverridableConfigKey {
   TS_CONFIG_HTTP_CONNECT_ATTEMPTS_RETRY_BACKOFF_BASE,
   TS_CONFIG_HTTP_NEGATIVE_REVALIDATING_LIST,
   TS_CONFIG_HTTP_CACHE_POST_METHOD,
+  TS_CONFIG_HTTP_CACHE_TARGETED_CACHE_CONTROL_HEADERS,
   TS_CONFIG_LAST_ENTRY,
 };
 
diff --git a/src/api/InkAPI.cc b/src/api/InkAPI.cc
index 1c832eb9dd..462d15cf38 100644
--- a/src/api/InkAPI.cc
+++ b/src/api/InkAPI.cc
@@ -7298,6 +7298,10 @@ _memberp_to_generic(MgmtFloat *ptr, MgmtConverter const *&conv) -> typename std:
 #define _CONF_CASE_HttpTransact_HOST_RES_CONV(KEY, MEMBER)                      \
   case TS_CONFIG_##KEY: ret = &overridableHttpConfig->MEMBER; conv = &HttpTransact::HOST_RES_CONV; break;
 
+// Custom converter: Parses/formats targeted cache control header lists.
+#define _CONF_CASE_TargetedCacheControlHeaders_Conv(KEY, MEMBER)                \
+  case TS_CONFIG_##KEY: ret = &overridableHttpConfig->MEMBER; conv = &TargetedCacheControlHeaders::Conv; break;
+
 // Dispatcher: Routes to _CONF_CASE_<CONV> based on the CONV parameter.
 #define _CONF_CASE_DISPATCH(KEY, MEMBER, RECORD_NAME, DATA_TYPE, CONV) _CONF_CASE_##CONV(KEY, MEMBER)
 
@@ -7348,6 +7352,7 @@ _conf_to_memberp(TSOverridableConfigKey conf, OverridableHttpConfigParams *overr
 #undef _CONF_CASE_ConnectionTracker_MAX_SERVER_CONV
 #undef _CONF_CASE_ConnectionTracker_SERVER_MATCH_CONV
 #undef _CONF_CASE_HttpTransact_HOST_RES_CONV
+#undef _CONF_CASE_TargetedCacheControlHeaders_Conv
 #undef _CONF_CASE_DISPATCH
 
 // 2nd little helper function to find the struct member for getting.
@@ -7561,6 +7566,22 @@ TSHttpTxnConfigStringSet(TSHttpTxn txnp, TSOverridableConfigKey conf, const char
       s->t_state.my_txn_conf().host_res_data = std::get<HostResData>(parsed.parsed);
     }
     break;
+  case TS_CONFIG_HTTP_CACHE_TARGETED_CACHE_CONTROL_HEADERS:
+    if (value && length > 0) {
+      auto &parsed = ParsedConfigCache::lookup(conf, std::string_view(value, length));
+      // This is intentionally a non-owning copy of the parsed representation.
+      // ParsedConfigCache::ParsedValue owns the backing string in conf_value_storage,
+      // and TargetedCacheControlHeaders stores string_view entries into that stable
+      // storage. The per-transaction override struct in HttpTransact::State is raw
+      // storage (not an owning/destructed HttpConfigParams object), so this path
+      // does not free conf_value. Reusing the cached parsed object avoids reparsing
+      // and avoids allocating/duplicating a second backing string on every txn
+      // override update, while preserving valid lifetimes for all string_view data.
+      s->t_state.my_txn_conf().targeted_cache_control_headers = std::get<TargetedCacheControlHeaders>(parsed.parsed);
+    } else {
+      s->t_state.my_txn_conf().targeted_cache_control_headers = TargetedCacheControlHeaders{};
+    }
+    break;
   default: {
     if (value && length > 0) {
       return _eval_conv(&(s->t_state.my_txn_conf()), conf, value, length);
diff --git a/src/proxy/hdrs/HdrToken.cc b/src/proxy/hdrs/HdrToken.cc
index 6e5d44cf11..712a5ed7f7 100644
--- a/src/proxy/hdrs/HdrToken.cc
+++ b/src/proxy/hdrs/HdrToken.cc
@@ -125,7 +125,10 @@ const char *const _hdrtoken_strs[] = {
   "br",
 
   // RFC-8878
-  "zstd"};
+  "zstd",
+
+  // RFC-9213 Targeted Cache Control
+  "CDN-Cache-Control"};
 
 HdrTokenTypeBinding _hdrtoken_strs_type_initializers[] = {
   {"file",                 HdrTokenType::SCHEME        },
@@ -267,6 +270,7 @@ HdrTokenFieldInfo _hdrtoken_strs_field_initializers[] = {
   {"Forwarded",                 MIME_SLOTID_NONE,                MIME_PRESENCE_NONE,                (HdrTokenInfoFlags::COMMAS | HdrTokenInfoFlags::MULTVALS)                              },
   {"Sec-WebSocket-Key",         MIME_SLOTID_NONE,                MIME_PRESENCE_NONE,                HdrTokenInfoFlags::NONE                                                                },
   {"Sec-WebSocket-Version",     MIME_SLOTID_NONE,                MIME_PRESENCE_NONE,                HdrTokenInfoFlags::NONE                                                                },
+  {"CDN-Cache-Control",         MIME_SLOTID_NONE,                MIME_PRESENCE_NONE,                (HdrTokenInfoFlags::COMMAS | HdrTokenInfoFlags::MULTVALS)                              },
   {nullptr,                     0,                               0,                                 HdrTokenInfoFlags::NONE                                                                },
 };
 
diff --git a/src/proxy/hdrs/MIME.cc b/src/proxy/hdrs/MIME.cc
index 8eae5d7852..869468a8cc 100644
--- a/src/proxy/hdrs/MIME.cc
+++ b/src/proxy/hdrs/MIME.cc
@@ -3713,7 +3713,8 @@ MIMEHdrImpl::recompute_accelerators_and_presence_bits()
 ////////////////////////////////////////////////////////
 
 void
-MIMEHdrImpl::recompute_cooked_stuff(MIMEField *changing_field_or_null)
+MIMEHdrImpl::recompute_cooked_stuff(MIMEField *changing_field_or_null, const std::string_view *targeted_headers,
+                                    size_t targeted_headers_count)
 {
   int         len, tlen;
   const char *s;
@@ -3725,13 +3726,27 @@ MIMEHdrImpl::recompute_cooked_stuff(MIMEField *changing_field_or_null)
 
   mime_hdr_cooked_stuff_init(this, changing_field_or_null);
 
-  //////////////////////////////////////////////////
-  // (1) cook the Cache-Control header if present //
-  //////////////////////////////////////////////////
+  /////////////////////////////////////////////////////////////////////////////
+  // (1) cook the Cache-Control header (or targeted variant) if present     //
+  /////////////////////////////////////////////////////////////////////////////
 
   // to be safe, recompute unless you know this call is for other cooked field
   if ((changing_field_or_null == nullptr) || (changing_field_or_null->m_wks_idx != MIME_WKSIDX_PRAGMA)) {
-    field = mime_hdr_field_find(this, static_cast<std::string_view>(MIME_FIELD_CACHE_CONTROL));
+    ink_assert(targeted_headers != nullptr || targeted_headers_count == 0);
+    field = nullptr;
+
+    // Check for targeted cache control headers first (in priority order).
+    for (size_t i = 0; i < targeted_headers_count; ++i) {
+      field = mime_hdr_field_find(this, targeted_headers[i]);
+      if (field) {
+        break;
+      }
+    }
+
+    // If no targeted header was found, fall back to standard Cache-Control.
+    if (!field) {
+      field = mime_hdr_field_find(this, static_cast<std::string_view>(MIME_FIELD_CACHE_CONTROL));
+    }
 
     if (field) {
       // try pathpaths first -- unlike most other fastpaths, this one
diff --git a/src/proxy/http/HttpConfig.cc b/src/proxy/http/HttpConfig.cc
index d58f7d7c01..7a950da5e3 100644
--- a/src/proxy/http/HttpConfig.cc
+++ b/src/proxy/http/HttpConfig.cc
@@ -694,6 +694,52 @@ const MgmtConverter HttpStatusCodeList::Conv{
   }};
 // clang-format on
 
+/////////////////////////////////////////////////////////////
+//
+// TargetedCacheControlHeaders implementation
+//
+/////////////////////////////////////////////////////////////
+
+void
+TargetedCacheControlHeaders::parse(std::string_view src)
+{
+  size_t dropped = 0;
+
+  count = 0;
+  swoc::TextView config_view{src};
+
+  while (config_view) {
+    swoc::TextView header_name = config_view.take_prefix_at(',').trim_if(&isspace);
+    if (!header_name.empty()) {
+      if (count < MAX_HEADERS) {
+        headers[count++] = std::string_view{header_name.data(), header_name.size()};
+      } else {
+        ++dropped;
+      }
+    }
+  }
+
+  if (dropped > 0) {
+    Warning("Ignoring %zu headers for proxy.config.http.cache.targeted_cache_control_headers (maximum is %zu).", dropped,
+            MAX_HEADERS);
+  }
+}
+
+// clang-format off
+const MgmtConverter TargetedCacheControlHeaders::Conv{
+  [](const void *data) -> std::string_view {
+    const TargetedCacheControlHeaders *hdrs = static_cast<const TargetedCacheControlHeaders *>(data);
+    return hdrs->conf_value ? hdrs->conf_value : "";
+  },
+  [](void *data, std::string_view src) -> void {
+    TargetedCacheControlHeaders *hdrs = static_cast<TargetedCacheControlHeaders *>(data);
+    // Keep conf_value and parse source consistent; headers[] points into src.
+    // The caller is responsible for src lifetime for as long as this object is used.
+    hdrs->conf_value = const_cast<char *>(src.data());
+    hdrs->parse(src);
+  }};
+// clang-format on
+
 /////////////////////////////////////////////////////////////
 //
 // ParsedConfigCache implementation
@@ -784,6 +830,13 @@ ParsedConfigCache::parse(TSOverridableConfigKey key, std::string_view value)
     break;
   }
 
+  case TS_CONFIG_HTTP_CACHE_TARGETED_CACHE_CONTROL_HEADERS: {
+    TargetedCacheControlHeaders targeted_headers{};
+    TargetedCacheControlHeaders::Conv.store_string(&targeted_headers, result.conf_value_storage);
+    result.parsed = targeted_headers;
+    break;
+  }
+
   default:
     // No special parsing needed for this config.
     break;
@@ -1093,6 +1146,11 @@ HttpConfig::startup()
   HttpEstablishStaticConfigByte(c.oride.cache_required_headers, "proxy.config.http.cache.required_headers");
   HttpEstablishStaticConfigByte(c.oride.cache_range_lookup, "proxy.config.http.cache.range.lookup");
   HttpEstablishStaticConfigByte(c.oride.cache_range_write, "proxy.config.http.cache.range.write");
+  HttpEstablishStaticConfigStringAlloc(c.oride.targeted_cache_control_headers.conf_value,
+                                       "proxy.config.http.cache.targeted_cache_control_headers");
+  if (c.oride.targeted_cache_control_headers.conf_value) {
+    c.oride.targeted_cache_control_headers.parse(c.oride.targeted_cache_control_headers.conf_value);
+  }
 
   HttpEstablishStaticConfigStringAlloc(c.connect_ports_string, "proxy.config.http.connect_ports");
 
@@ -1398,10 +1456,14 @@ HttpConfig::reconfigure()
   params->max_payload_iobuf_index        = m_master.max_payload_iobuf_index;
   params->max_msg_iobuf_index            = m_master.max_msg_iobuf_index;
 
-  params->oride.cache_required_headers = m_master.oride.cache_required_headers;
-  params->oride.cache_range_lookup     = INT_TO_BOOL(m_master.oride.cache_range_lookup);
-  params->oride.cache_range_write      = INT_TO_BOOL(m_master.oride.cache_range_write);
-  params->oride.allow_multi_range      = m_master.oride.allow_multi_range;
+  params->oride.cache_required_headers                    = m_master.oride.cache_required_headers;
+  params->oride.cache_range_lookup                        = INT_TO_BOOL(m_master.oride.cache_range_lookup);
+  params->oride.cache_range_write                         = INT_TO_BOOL(m_master.oride.cache_range_write);
+  params->oride.targeted_cache_control_headers.conf_value = ats_strdup(m_master.oride.targeted_cache_control_headers.conf_value);
+  if (params->oride.targeted_cache_control_headers.conf_value) {
+    params->oride.targeted_cache_control_headers.parse(params->oride.targeted_cache_control_headers.conf_value);
+  }
+  params->oride.allow_multi_range = m_master.oride.allow_multi_range;
 
   params->connect_ports_string = ats_strdup(m_master.connect_ports_string);
   params->connect_ports        = parse_ports_list(params->connect_ports_string);
diff --git a/src/proxy/http/HttpSM.cc b/src/proxy/http/HttpSM.cc
index d9cfb66292..c6fd71a3cf 100644
--- a/src/proxy/http/HttpSM.cc
+++ b/src/proxy/http/HttpSM.cc
@@ -1639,7 +1639,17 @@ HttpSM::handle_api_return()
   case HttpTransact::StateMachineAction_t::API_READ_REQUEST_HDR:
   case HttpTransact::StateMachineAction_t::REQUEST_BUFFER_READ_COMPLETE:
   case HttpTransact::StateMachineAction_t::API_OS_DNS:
+    call_transact_and_set_next_state(nullptr);
+    return;
   case HttpTransact::StateMachineAction_t::API_READ_RESPONSE_HDR:
+    // Plugins may mutate response cache-control headers in this hook. Re-cook
+    // targeted cache-control after hooks so downstream cache decisions see the
+    // final header state with targeted precedence.
+    if (t_state.hdr_info.server_response.m_mime) {
+      t_state.hdr_info.server_response.m_mime->recompute_cooked_stuff(
+        nullptr, t_state.txn_conf->targeted_cache_control_headers.get_headers(),
+        t_state.txn_conf->targeted_cache_control_headers.get_count());
+    }
     call_transact_and_set_next_state(nullptr);
     return;
   case HttpTransact::StateMachineAction_t::API_TUNNEL_START:
@@ -2100,14 +2110,18 @@ HttpSM::state_read_server_response_header(int event, void *data)
   }
     // fallthrough
 
-  case ParseResult::DONE:
-
+  case ParseResult::DONE: {
     if (!t_state.hdr_info.server_response.check_hdr_implements()) {
       t_state.http_return_code = HTTPStatus::BAD_GATEWAY;
       call_transact_and_set_next_state(HttpTransact::BadRequest);
       break;
     }
 
+    // Recompute cooked cache control with targeted headers.
+    t_state.hdr_info.server_response.m_mime->recompute_cooked_stuff(nullptr,
+                                                                    t_state.txn_conf->targeted_cache_control_headers.get_headers(),
+                                                                    t_state.txn_conf->targeted_cache_control_headers.get_count());
+
     SMDbg(dbg_ctl_http_seq, "Done parsing server response header");
 
     // Now that we know that we have all of the origin server
@@ -2138,6 +2152,7 @@ HttpSM::state_read_server_response_header(int event, void *data)
       server_entry->read_vio->disable(); // Disable the read until we finish the tunnel
     }
     break;
+  }
   case ParseResult::CONT:
     ink_assert(server_entry->eos == false);
     server_entry->read_vio->reenable();
diff --git a/src/records/RecordsConfig.cc b/src/records/RecordsConfig.cc
index 88526c08ad..f0cefd209e 100644
--- a/src/records/RecordsConfig.cc
+++ b/src/records/RecordsConfig.cc
@@ -643,6 +643,8 @@ static constexpr RecordElement RecordsConfig[] =
   ,
   {RECT_CONFIG, "proxy.config.http.cache.range.write", RECD_INT, "0", RECU_NULL, RR_NULL, RECC_NULL, nullptr, RECA_NULL}
   ,
+  {RECT_CONFIG, "proxy.config.http.cache.targeted_cache_control_headers", RECD_STRING, "", RECU_DYNAMIC, RR_NULL, RECC_NULL, nullptr, RECA_NULL}
+  ,
 
   //        ########################
   //        # heuristic expiration #
diff --git a/tests/gold_tests/cache/replay/targeted-cache-control.replay.yaml b/tests/gold_tests/cache/replay/targeted-cache-control.replay.yaml
new file mode 100644
index 0000000000..90300f79e3
--- /dev/null
+++ b/tests/gold_tests/cache/replay/targeted-cache-control.replay.yaml
@@ -0,0 +1,389 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+# Test targeted cache control headers per RFC 9213
+# Test that CDN-Cache-Control overrides Cache-Control when configured
+
+meta:
+  version: "1.0"
+
+# Configuration section for autest integration
+autest:
+  description: 'Test targeted cache control headers per RFC 9213'
+
+  dns:
+    name: 'dns'
+
+  server:
+    name: 'server'
+
+  client:
+    name: 'client'
+
+  ats:
+    name: 'ts'
+    enable_cache: true
+
+    records_config:
+      proxy.config.diags.debug.enabled: 1
+      proxy.config.diags.debug.tags: 'http|cache'
+      proxy.config.http.cache.http: 1
+      proxy.config.http.cache.required_headers: 0
+      proxy.config.http.cache.targeted_cache_control_headers: 'ATS-Cache-Control,CDN-Cache-Control'
+
+    remap_config:
+      - from: "http://example.com/"
+        to: "http://backend.example.com:{SERVER_HTTP_PORT}/"
+
+      - from: "http://acme.com/"
+        to: "http://backend.acme.com:{SERVER_HTTP_PORT}/"
+        plugins:
+          - name: "conf_remap.so"
+            args:
+              - "proxy.config.http.cache.targeted_cache_control_headers=ACME-Cache-Control"
+
+
+sessions:
+- transactions:
+
+  #############################################################################
+  # Test 1: CDN-Cache-Control with higher max-age overrides Cache-Control
+  #############################################################################
+  - client-request:
+      method: GET
+      url: /targeted/test1
+      version: '1.1'
+      headers:
+        fields:
+        - [Host, example.com]
+        - [uuid, targeted-test1-request1]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [Content-Type, text/plain]
+        - [Content-Length, "14"]
+        - [Cache-Control, "max-age=1"]
+        - [CDN-Cache-Control, "max-age=30"] # Should be used.
+        - [Connection, close]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ Cache-Control, { value: "max-age=1", as: equal } ]
+        - [ CDN-Cache-Control, { value: "max-age=30", as: equal } ]
+
+  #############################################################################
+  # Test 2: Priority order - first targeted header wins (verify lowercase).
+  #############################################################################
+  - client-request:
+      method: GET
+      url: /targeted/test2
+      version: '1.1'
+      headers:
+        fields:
+        - [Host, example.com]
+        - [uuid, targeted-test2-request1]
+
+    # Test lowercase *cache-control.
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [Content-Type, text/plain]
+        - [Content-Length, "14"]
+        - [cache-control, "no-store"]
+        - [cdn-cache-control, "no-store"]
+        - [ats-cache-control, "max-age=30"]  # Should take precedence. Test lowercase.
+        - [Connection, close]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ cache-control, { value: "no-store", as: equal } ]
+        - [ cdn-cache-control, { value: "no-store", as: equal } ]
+        - [ ats-cache-control, { value: "max-age=30", as: equal } ]
+
+
+  #############################################################################
+  # Test 3: no-store in targeted header
+  #############################################################################
+  - client-request:
+      method: GET
+      url: /targeted/test3
+      version: '1.1'
+      headers:
+        fields:
+        - [Host, example.com]
+        - [uuid, targeted-test3-request1]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [Content-Type, text/plain]
+        - [Content-Length, "14"]
+        - [Cache-Control, "max-age=3600"]
+        - [CDN-Cache-Control, "no-store"] # Should be used.
+        - [Connection, close]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ Cache-Control, { value: "max-age=3600", as: equal } ]
+        - [ CDN-Cache-Control, { value: "no-store", as: equal } ]
+
+  #############################################################################
+  # Test 4: Vanilla Cache-Control should still function.
+  #############################################################################
+  - client-request:
+      method: GET
+      url: /targeted/test4
+      version: '1.1'
+      headers:
+        fields:
+        - [Host, example.com]
+        - [uuid, targeted-test4-request1]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [Content-Type, text/plain]
+        - [Content-Length, "14"]
+        - [Cache-Control, "max-age=30"]
+        - [Connection, close]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ Cache-Control, { value: "max-age=30", as: equal } ]
+
+
+  #############################################################################
+  # Test 5: conf_remap.so override with ACME-Cache-Control
+  #############################################################################
+  - client-request:
+      method: GET
+      url: /acme/test1
+      version: '1.1'
+      headers:
+        fields:
+        - [Host, acme.com]
+        - [uuid, acme-test1-request1]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [Content-Type, text/plain]
+        - [Content-Length, "14"]
+        - [Cache-Control, "no-store"]
+        - [CDN-Cache-Control, "no-store"]
+        - [ATS-Cache-Control, "no-store"]
+        - [ACME-Cache-Control, "max-age=30"]  # Should be used due to override
+        - [Connection, close]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ Cache-Control, { value: "no-store", as: equal } ]
+        - [ CDN-Cache-Control, { value: "no-store", as: equal } ]
+        - [ ATS-Cache-Control, { value: "no-store", as: equal } ]
+        - [ ACME-Cache-Control, { value: "max-age=30", as: equal } ]
+
+  #############################################################################
+  # Test 6: Verify ACME-Cache-Control override works (no-cache)
+  #############################################################################
+  - client-request:
+      method: GET
+      url: /acme/test2
+      version: '1.1'
+      headers:
+        fields:
+        - [Host, acme.com]
+        - [uuid, acme-test2-request1]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [Content-Type, text/plain]
+        - [Content-Length, "14"]
+        - [Cache-Control, "max-age=3600"]
+        - [ACME-Cache-Control, "no-store"]  # Should prevent caching
+        - [Connection, close]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ Cache-Control, { value: "max-age=3600", as: equal } ]
+        - [ ACME-Cache-Control, { value: "no-store", as: equal } ]
+
+  #############################################################################
+  # Now verify the correct cache behavior from above.
+  #############################################################################
+  - client-request:
+      # Delay to exceed Cache-Control but not CDN-Cache-Control.
+      delay: 2s
+
+      method: GET
+      url: /targeted/test1
+      version: '1.1'
+      headers:
+        fields:
+        - [Host, example.com]
+        - [uuid, targeted-test1-request2]
+
+    # Should not reach the origin.
+    server-response:
+      status: 404
+      reason: Not Found
+
+    # Expect the cached 200 response.
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ Cache-Control, { value: "max-age=1", as: equal } ]
+        - [ CDN-Cache-Control, { value: "max-age=30", as: equal } ]
+
+  - client-request:
+      method: GET
+      url: /targeted/test2
+      version: '1.1'
+      headers:
+        fields:
+        - [Host, example.com]
+        - [uuid, targeted-test2-request2]
+
+    # Should not reach the origin.
+    server-response:
+      status: 404
+      reason: Not Found
+
+    # Expect the cached 200 response since ATS-Cache-Control takes precedence
+    # over CDN-Cache-Control and Cache-Control.
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ Cache-Control, { value: "no-store", as: equal } ]
+        - [ CDN-Cache-Control, { value: "no-store", as: equal } ]
+        - [ ATS-Cache-Control, { value: "max-age=30", as: equal } ]
+
+  - client-request:
+      method: GET
+      url: /targeted/test3
+      version: '1.1'
+      headers:
+        fields:
+        - [Host, example.com]
+        - [uuid, targeted-test3-request2]
+
+    # Since CDN-Cache-Control is no-store, the request should reach the origin.
+    server-response:
+      status: 404
+      reason: Not Found
+
+    proxy-response:
+      status: 404
+      headers:
+        fields:
+        - [ Cache-Control, { as: absent } ]
+        - [ CDN-Cache-Control, { as: absent } ]
+
+  - client-request:
+      method: GET
+      url: /targeted/test4
+      version: '1.1'
+      headers:
+        fields:
+        - [Host, example.com]
+        - [uuid, targeted-test4-request2]
+
+    # Should not reach the origin.
+    server-response:
+      status: 404
+      reason: Not Found
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ Cache-Control, { value: "max-age=30", as: equal } ]
+
+  # Verify that ACME-Cache-Control: max-age=30 allowed caching.
+  - client-request:
+      method: GET
+      url: /acme/test1
+      version: '1.1'
+      headers:
+        fields:
+        - [Host, acme.com]
+        - [uuid, acme-test1-request2]
+
+    # The origin should not receive the request since ATS will reply out of cache.
+    server-response:
+      status: 404
+      reason: Not Found
+
+    # Expect the cached 200 response.
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ Cache-Control, { value: "no-store", as: equal } ]
+        - [ CDN-Cache-Control, { value: "no-store", as: equal } ]
+        - [ ATS-Cache-Control, { value: "no-store", as: equal } ]
+        - [ ACME-Cache-Control, { value: "max-age=30", as: equal } ]
+
+  # On the other hand, verify that ACME-Cache-Control: no-store prevents caching.
+  - client-request:
+      method: GET
+      url: /acme/test2
+      version: '1.1'
+      headers:
+        fields:
+        - [Host, acme.com]
+        - [uuid, acme-test2-request2]
+
+    server-response:
+      status: 404
+      reason: Not Found
+
+    # Expect the 404 from the origin server, not the cached 200 response.
+    proxy-response:
+      status: 404
+      headers:
+        fields:
+        - [ Cache-Control, { as: absent } ]
+        - [ CDN-Cache-Control, { as: absent } ]
+        - [ ATS-Cache-Control, { as: absent } ]
+        - [ ACME-Cache-Control, { as: absent } ]
diff --git a/tests/gold_tests/cache/targeted-cache-control.test.py b/tests/gold_tests/cache/targeted-cache-control.test.py
new file mode 100644
index 0000000000..3bcd84047b
--- /dev/null
+++ b/tests/gold_tests/cache/targeted-cache-control.test.py
@@ -0,0 +1,25 @@
+'''Test targeted cache control headers per RFC 9213.'''
+
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+Test.Summary = '''
+Test targeted cache control headers per RFC 9213.
+Verifies that CDN-Cache-Control and other targeted headers can override
+standard Cache-Control when properly configured.
+'''
+
+Test.ATSReplayTest(replay_file="replay/targeted-cache-control.replay.yaml")


(trafficserver) 07/08: Doc: mention elif, nested conditionals (#12869)

Posted by cm...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

cmcfarlen pushed a commit to branch 10.2.x
in repository https://gitbox.apache.org/repos/asf/trafficserver.git

commit 3f1ffdfe88f4450c1eb8ab4f2f29e26ca83baaeb
Author: mlibbey <ml...@apache.org>
AuthorDate: Sat Feb 21 09:16:48 2026 -0800

    Doc: mention elif, nested conditionals (#12869)
    
    Missed some documentation when adding elif and nested conditionals.
    
    (cherry picked from commit 82076d5b53f9d553ed2d141c77638f88bab07003)
---
 doc/admin-guide/configuration/hrw4u.en.rst | 34 ++++++++++++++++++++++++------
 1 file changed, 27 insertions(+), 7 deletions(-)

diff --git a/doc/admin-guide/configuration/hrw4u.en.rst b/doc/admin-guide/configuration/hrw4u.en.rst
index e2a054383e..c401dafc4e 100644
--- a/doc/admin-guide/configuration/hrw4u.en.rst
+++ b/doc/admin-guide/configuration/hrw4u.en.rst
@@ -134,11 +134,12 @@ For people using IDEs, the package also provides an LSP for this language, named
 Syntax Differences
 ==================
 
-The basic structure is a `section` name defining the part of the transaction to run in
-followed by conditionals and operators. It uses `if () {} else {}` conditional syntax
-with `&& , || and ==`, with conditions and operators generally following function() or
-object.style grammar. Operator lines are terminated with `;` (whitespace is still not
-significant). For instance:
+The basic structure consists of a `section` name defining the part of the transaction to run,
+followed by conditionals and operators. It uses `if ... {...} elif ... {...} else {...}` conditional syntax
+with `&& , || and ==`. Conditionals support parenthesized grouping for complex expressions (`if (...) {...}`)
+and can be nested up to a maximum depth of 10 levels. Conditions and operators generally follow
+function() or object.style grammar. Operator lines are terminated with `;` and whitespace is still
+insignificant. For instance:
 
 .. code-block:: none
 
@@ -148,13 +149,31 @@ significant). For instance:
    }
 
    REMAP {
-     if inbound.status == 403 || access("/etc/lock") {
+     if inbound.status == 403 {
        inbound.req.X-Fail = "1";
+     } elif inbound.status == 404 {
+       inbound.req.X-NotFound = "1";
      } else {
        no-op();
      }
    }
 
+   READ_RESPONSE {
+     if inbound.status > 399 {
+       if inbound.status < 500 {
+         # Client error - check specific cases
+         if inbound.status == 404 {
+           counter("errors.not_found");
+         } elif inbound.status == 403 {
+           counter("errors.forbidden");
+         }
+       } else {
+         # Server error
+         counter("errors.server");
+       }
+     }
+   }
+
 There is no `cond` or `set-header` syntax — those are implied by context.
 
 Header Presence
@@ -199,7 +218,8 @@ Syntax               Free-form                     Structured `if (...) { ... }`
 Conditions           `cond %{...}`                 Implicit in `if (...)`
 Operators            Raw text (`set-header`)       Structured assignments or statements
 Grouping             `GROUP` / `GROUP:END`         Use `()` inside `if` expressions
-Else branches        `else` with indented ops      `else { ... }` with full block
+Else branches        `else` with indented ops      `elif ... { ... }` and `else { ... }`
+                                                   with full blocks
 Debugging            Manual with logs              Built-in debug tracing (`--debug`)
 Quotes               Quoted strings optional       Quoted strings required / encouraged
 Validation           Runtime                       Static during parsing and symbol resolution


(trafficserver) 03/08: Fix the log field type for cqpv and sqpv (#12890)

Posted by cm...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

cmcfarlen pushed a commit to branch 10.2.x
in repository https://gitbox.apache.org/repos/asf/trafficserver.git

commit a7a2d2c4627027d337ab191b2ef3fce855908951
Author: Masakazu Kitajo <ma...@apache.org>
AuthorDate: Thu Feb 19 11:17:25 2026 -0700

    Fix the log field type for cqpv and sqpv (#12890)
    
    (cherry picked from commit b287b7242aa6b336eb995cadebc722fb98bd9ee9)
---
 src/proxy/logging/Log.cc | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/proxy/logging/Log.cc b/src/proxy/logging/Log.cc
index 50e975e284..4ac59bf679 100644
--- a/src/proxy/logging/Log.cc
+++ b/src/proxy/logging/Log.cc
@@ -480,12 +480,12 @@ Log::init_fields()
   global_field_list.add(field, false);
   field_symbol_hash.emplace("pqup", field);
 
-  field = new LogField("client_req_protocol_version", "cqpv", LogField::dINT, &LogAccess::marshal_client_req_protocol_version,
+  field = new LogField("client_req_protocol_version", "cqpv", LogField::STRING, &LogAccess::marshal_client_req_protocol_version,
                        &LogAccess::unmarshal_str);
   global_field_list.add(field, false);
   field_symbol_hash.emplace("cqpv", field);
 
-  field = new LogField("server_req_protocol_version", "sqpv", LogField::dINT, &LogAccess::marshal_server_req_protocol_version,
+  field = new LogField("server_req_protocol_version", "sqpv", LogField::STRING, &LogAccess::marshal_server_req_protocol_version,
                        &LogAccess::unmarshal_str);
   global_field_list.add(field, false);
   field_symbol_hash.emplace("sqpv", field);


(trafficserver) 04/08: Fix flaky filter_body test (#12882)

Posted by cm...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

cmcfarlen pushed a commit to branch 10.2.x
in repository https://gitbox.apache.org/repos/asf/trafficserver.git

commit ffd1d59e262c54bb242fc6c3eb67074820c68b6c
Author: Brian Neradt <br...@gmail.com>
AuthorDate: Thu Feb 12 16:19:03 2026 -0600

    Fix flaky filter_body test (#12882)
    
    The filter_body test's Proxy Verifier client can exit with either 0 or
    1 depending on timing of connection closure during request body
    blocking. The ATSReplayTest extension only supported a single scalar
    return_code value, so the test would intermittently fail when the client
    happened to exit with 0 instead of the expected 1.
    
    Support a list of return codes in the replay YAML by wrapping them in
    Any() when a list is provided, and update the filter_body test to accept
    either exit code.
    
    (cherry picked from commit 1b6847fd677828adf950be95d742c77330768963)
---
 tests/gold_tests/autest-site/ats_replay.test.ext             | 12 ++++++++----
 .../pluginTest/filter_body/filter_body.replay.yaml           |  4 +++-
 2 files changed, 11 insertions(+), 5 deletions(-)

diff --git a/tests/gold_tests/autest-site/ats_replay.test.ext b/tests/gold_tests/autest-site/ats_replay.test.ext
index 75bd1306d5..bb9e19d8c8 100644
--- a/tests/gold_tests/autest-site/ats_replay.test.ext
+++ b/tests/gold_tests/autest-site/ats_replay.test.ext
@@ -172,9 +172,11 @@ def ATSReplayTest(obj, replay_file: str):
     process_config = server_config.get('process_config', {})
     server = tr.AddVerifierServerProcess(name, replay_file, **process_config)
 
-    # Set expected return code for server if specified.
+    # Set expected return code for server if specified. A list of codes is
+    # wrapped in Any() so that any of the listed values is accepted.
     if 'return_code' in server_config:
-        server.ReturnCode = server_config['return_code']
+        rc = server_config['return_code']
+        server.ReturnCode = Any(*rc) if isinstance(rc, list) else rc
 
     # ATS configuration.
     if not 'ats' in autest_config:
@@ -193,9 +195,11 @@ def ATSReplayTest(obj, replay_file: str):
     client = tr.AddVerifierClientProcess(
         name, replay_file, http_ports=[ts.Variables.port], https_ports=https_ports, **process_config)
 
-    # Set expected return code if specified.
+    # Set expected return code if specified. A list of codes is wrapped in
+    # Any() so that any of the listed values is accepted.
     if 'return_code' in client_config:
-        client.ReturnCode = client_config['return_code']
+        rc = client_config['return_code']
+        client.ReturnCode = Any(*rc) if isinstance(rc, list) else rc
 
     if dns:
         ts.StartBefore(dns)
diff --git a/tests/gold_tests/pluginTest/filter_body/filter_body.replay.yaml b/tests/gold_tests/pluginTest/filter_body/filter_body.replay.yaml
index 1b70d6048a..78f66584f4 100644
--- a/tests/gold_tests/pluginTest/filter_body/filter_body.replay.yaml
+++ b/tests/gold_tests/pluginTest/filter_body/filter_body.replay.yaml
@@ -29,7 +29,9 @@ autest:
 
   client:
     name: 'client'
-    return_code: 1
+    # There's a race condition for when the connection is droped by ATS - this
+    # can result in either a 0 or 1 return code.
+    return_code: [0, 1]
 
   ats:
     name: 'ts'


(trafficserver) 08/08: Add fail action 6, will fallback to serving stale if retry attempts are exhausted (#12852)

Posted by cm...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

cmcfarlen pushed a commit to branch 10.2.x
in repository https://gitbox.apache.org/repos/asf/trafficserver.git

commit 49ab050e0ecc723348a30b098f51791e7974457b
Author: Evan Zelkowitz <ez...@apache.org>
AuthorDate: Mon Feb 23 10:12:08 2026 -0700

    Add fail action 6, will fallback to serving stale if retry attempts are exhausted (#12852)
    
    * Add fail action 6, will fallback to serving stale if retry attempts are exhausted
    
    (cherry picked from commit df8306597efaecdd7f6e788e0afc11397bf68b0e)
---
 doc/admin-guide/files/records.yaml.en.rst          |  14 +-
 include/proxy/http/HttpConfig.h                    |   1 +
 include/proxy/http/HttpTransact.h                  |  16 +-
 src/proxy/http/HttpCacheSM.cc                      |  18 +-
 src/proxy/http/HttpConfig.cc                       |   4 +-
 src/proxy/http/HttpSM.cc                           |  29 ++-
 src/proxy/http/HttpTransact.cc                     | 228 ++++++++++++++++++---
 src/records/RecordsConfig.cc                       |   4 +-
 .../cache/cache-read-retry-stale.test.py           |  30 +++
 .../cache/cache-write-lock-contention.test.py      | 175 ++++++++++++++++
 .../replay/cache-read-retry-stale.replay.yaml      | 187 +++++++++++++++++
 11 files changed, 656 insertions(+), 50 deletions(-)

diff --git a/doc/admin-guide/files/records.yaml.en.rst b/doc/admin-guide/files/records.yaml.en.rst
index d30a54a317..74aba11c22 100644
--- a/doc/admin-guide/files/records.yaml.en.rst
+++ b/doc/admin-guide/files/records.yaml.en.rst
@@ -2917,7 +2917,7 @@ Dynamic Content & Content Negotiation
     The number of times to attempt a cache open write upon failure to get a write lock.
 
     This config is ignored when :ts:cv:`proxy.config.http.cache.open_write_fail_action` is
-    set to ``5`` or :ts:cv:`proxy.config.http.cache.max_open_write_retry_timeout` is set to gt ``0``.
+    set to ``5`` or ``6``, or when :ts:cv:`proxy.config.http.cache.max_open_write_retry_timeout` is set to gt ``0``.
 
 .. ts:cv:: CONFIG proxy.config.http.cache.max_open_write_retry_timeout INT 0
    :reloadable:
@@ -2926,7 +2926,7 @@ Dynamic Content & Content Negotiation
     A timeout for attempting a cache open write upon failure to get a write lock.
 
     This config is ignored when :ts:cv:`proxy.config.http.cache.open_write_fail_action` is
-    set to ``5``.
+    set to ``5`` or ``6``.
 
 .. ts:cv:: CONFIG proxy.config.http.cache.open_write_fail_action INT 0
    :reloadable:
@@ -2954,8 +2954,14 @@ Dynamic Content & Content Negotiation
          with :ts:cv:`proxy.config.cache.enable_read_while_writer` configuration
          allows to collapse concurrent requests without a need for any plugin.
          Make sure to configure the :ref:`admin-config-read-while-writer` feature
-         correctly. Note that this option may result in CACHE_LOOKUP_COMPLETE HOOK
-         being called back more than once.
+         correctly. With this option, CACHE_LOOKUP_COMPLETE HOOK is deferred for
+         read retries so that plugins see only the final cache lookup result.
+   ``6`` Retry Cache Read on a Cache Write Lock failure (same as ``5``), but if
+         read retries are exhausted and a stale cached object exists, serve the
+         stale content if allowed. This combines the request collapsing behavior
+         of ``5`` with the stale-serving fallback of ``2``. If stale is not
+         returnable (e.g., due to ``Cache-Control: must-revalidate``), go to
+         origin server.
    ===== ======================================================================
 
 Customizable User Response Pages
diff --git a/include/proxy/http/HttpConfig.h b/include/proxy/http/HttpConfig.h
index 93377a4cd1..29cabb50a6 100644
--- a/include/proxy/http/HttpConfig.h
+++ b/include/proxy/http/HttpConfig.h
@@ -411,6 +411,7 @@ enum class CacheOpenWriteFailAction_t {
   ERROR_ON_MISS_STALE_ON_REVALIDATE = 0x03,
   ERROR_ON_MISS_OR_REVALIDATE       = 0x04,
   READ_RETRY                        = 0x05,
+  READ_RETRY_STALE_ON_REVALIDATE    = 0x06,
   TOTAL_TYPES
 };
 
diff --git a/include/proxy/http/HttpTransact.h b/include/proxy/http/HttpTransact.h
index 856fbe991d..0e05c1e14b 100644
--- a/include/proxy/http/HttpTransact.h
+++ b/include/proxy/http/HttpTransact.h
@@ -487,6 +487,7 @@ public:
     HTTPInfo         transform_store;
     CacheDirectives  directives;
     HTTPInfo        *object_read          = nullptr;
+    HTTPInfo        *stale_fallback       = nullptr; // Saved stale object for action 6 fallback during retry
     CacheWriteLock_t write_lock_state     = CacheWriteLock_t::INIT;
     int              lookup_count         = 0;
     SquidHitMissCode hit_miss_code        = SQUID_MISS_NONE;
@@ -703,6 +704,15 @@ public:
     ///   configuration.
     bool is_cacheable_due_to_negative_caching_configuration = false;
 
+    /// Set when stale content is served due to cache write lock failure.
+    /// Used to correctly attribute statistics and VIA strings.
+    bool serving_stale_due_to_write_lock = false;
+
+    /// Set when CACHE_LOOKUP_COMPLETE hook is deferred for action 5/6.
+    /// The hook will fire later with the final result once we know if
+    /// stale content will be served or if we're going to origin.
+    bool cache_lookup_complete_deferred = false;
+
     MgmtByte cache_open_write_fail_action = 0;
 
     HttpConfigParams           *http_config_param = nullptr;
@@ -998,9 +1008,12 @@ public:
   static void HandleCacheOpenReadHitFreshness(State *s);
   static void HandleCacheOpenReadHit(State *s);
   static void HandleCacheOpenReadMiss(State *s);
+  static void HandleCacheOpenReadMissGoToOrigin(State *s);
   static void set_cache_prepare_write_action_for_new_request(State *s);
   static void build_response_from_cache(State *s, HTTPWarningCode warning_code);
   static void handle_cache_write_lock(State *s);
+  static void handle_cache_write_lock_go_to_origin(State *s);
+  static void handle_cache_write_lock_go_to_origin_continue(State *s);
   static void HandleResponse(State *s);
   static void HandleUpdateCachedObject(State *s);
   static void HandleUpdateCachedObjectContinue(State *s);
@@ -1093,7 +1106,8 @@ public:
   static void             handle_response_keep_alive_headers(State *s, HTTPVersion ver, HTTPHdr *heads);
   static int              get_max_age(HTTPHdr *response);
   static int              calculate_document_freshness_limit(State *s, HTTPHdr *response, time_t response_date, bool *heuristic);
-  static Freshness_t      what_is_document_freshness(State *s, HTTPHdr *client_request, HTTPHdr *cached_obj_response);
+  static Freshness_t      what_is_document_freshness(State *s, HTTPHdr *client_request, HTTPHdr *cached_obj_response,
+                                                     bool evaluate_actual_freshness = false);
   static Authentication_t AuthenticationNeeded(const OverridableHttpConfigParams *p, HTTPHdr *client_request,
                                                HTTPHdr *obj_response);
   static void             handle_parent_down(State *s);
diff --git a/src/proxy/http/HttpCacheSM.cc b/src/proxy/http/HttpCacheSM.cc
index 075e81694f..7893fda4b1 100644
--- a/src/proxy/http/HttpCacheSM.cc
+++ b/src/proxy/http/HttpCacheSM.cc
@@ -43,6 +43,14 @@
 namespace
 {
 DbgCtl dbg_ctl_http_cache{"http_cache"};
+
+// Helper to check if cache_open_write_fail_action has READ_RETRY behavior
+inline bool
+is_read_retry_action(MgmtByte action)
+{
+  return action == static_cast<MgmtByte>(CacheOpenWriteFailAction_t::READ_RETRY) ||
+         action == static_cast<MgmtByte>(CacheOpenWriteFailAction_t::READ_RETRY_STALE_ON_REVALIDATE);
+}
 } // end anonymous namespace
 
 ////
@@ -215,12 +223,11 @@ HttpCacheSM::state_cache_open_write(int event, void *data)
     break;
 
   case CACHE_EVENT_OPEN_WRITE_FAILED: {
-    if (master_sm->t_state.txn_conf->cache_open_write_fail_action ==
-        static_cast<MgmtByte>(CacheOpenWriteFailAction_t::READ_RETRY)) {
+    if (is_read_retry_action(master_sm->t_state.txn_conf->cache_open_write_fail_action)) {
       // fall back to open_read_tries
-      // Note that when CacheOpenWriteFailAction_t::READ_RETRY is configured, max_cache_open_write_retries
+      // Note that when READ_RETRY actions are configured, max_cache_open_write_retries
       // is automatically ignored. Make sure to not disable max_cache_open_read_retries
-      // with CacheOpenWriteFailAction_t::READ_RETRY as this results in proxy'ing to origin
+      // with READ_RETRY actions as this results in proxy'ing to origin
       // without write retries in both a cache miss or a cache refresh scenario.
 
       if (write_retry_done()) {
@@ -264,8 +271,7 @@ HttpCacheSM::state_cache_open_write(int event, void *data)
       _read_retry_event = nullptr;
     }
 
-    if (master_sm->t_state.txn_conf->cache_open_write_fail_action ==
-        static_cast<MgmtByte>(CacheOpenWriteFailAction_t::READ_RETRY)) {
+    if (is_read_retry_action(master_sm->t_state.txn_conf->cache_open_write_fail_action)) {
       Dbg(dbg_ctl_http_cache,
           "[%" PRId64 "] [state_cache_open_write] cache open write failure %d. "
           "falling back to read retry...",
diff --git a/src/proxy/http/HttpConfig.cc b/src/proxy/http/HttpConfig.cc
index 7a950da5e3..7875a79f3b 100644
--- a/src/proxy/http/HttpConfig.cc
+++ b/src/proxy/http/HttpConfig.cc
@@ -1442,7 +1442,9 @@ HttpConfig::reconfigure()
   params->disallow_post_100_continue = INT_TO_BOOL(m_master.disallow_post_100_continue);
 
   params->oride.cache_open_write_fail_action = m_master.oride.cache_open_write_fail_action;
-  if (params->oride.cache_open_write_fail_action == static_cast<MgmtByte>(CacheOpenWriteFailAction_t::READ_RETRY)) {
+  if (params->oride.cache_open_write_fail_action == static_cast<MgmtByte>(CacheOpenWriteFailAction_t::READ_RETRY) ||
+      params->oride.cache_open_write_fail_action ==
+        static_cast<MgmtByte>(CacheOpenWriteFailAction_t::READ_RETRY_STALE_ON_REVALIDATE)) {
     if (params->oride.max_cache_open_read_retries <= 0 || params->oride.max_cache_open_write_retries <= 0) {
       Warning("Invalid config, cache_open_write_fail_action (%d), max_cache_open_read_retries (%" PRIu64 "), "
               "max_cache_open_write_retries (%" PRIu64 ")",
diff --git a/src/proxy/http/HttpSM.cc b/src/proxy/http/HttpSM.cc
index c6fd71a3cf..683b9ae96d 100644
--- a/src/proxy/http/HttpSM.cc
+++ b/src/proxy/http/HttpSM.cc
@@ -2552,9 +2552,11 @@ HttpSM::state_cache_open_write(int event, void *data)
   case CACHE_EVENT_OPEN_READ:
     if (!t_state.cache_info.object_read) {
       t_state.cache_open_write_fail_action = t_state.txn_conf->cache_open_write_fail_action;
-      // Note that CACHE_LOOKUP_COMPLETE may be invoked more than once
-      // if CacheOpenWriteFailAction_t::READ_RETRY is configured
-      ink_assert(t_state.cache_open_write_fail_action == static_cast<MgmtByte>(CacheOpenWriteFailAction_t::READ_RETRY));
+      // READ_RETRY mode: write lock failed, no stale object available.
+      // CACHE_LOOKUP_COMPLETE will fire from HandleCacheOpenReadMiss with MISS result.
+      ink_assert(t_state.cache_open_write_fail_action == static_cast<MgmtByte>(CacheOpenWriteFailAction_t::READ_RETRY) ||
+                 t_state.cache_open_write_fail_action ==
+                   static_cast<MgmtByte>(CacheOpenWriteFailAction_t::READ_RETRY_STALE_ON_REVALIDATE));
       t_state.cache_lookup_result         = HttpTransact::CacheLookupResult_t::NONE;
       t_state.cache_info.write_lock_state = HttpTransact::CacheWriteLock_t::READ_RETRY;
       break;
@@ -2573,8 +2575,9 @@ HttpSM::state_cache_open_write(int event, void *data)
     t_state.source = HttpTransact::Source_t::CACHE;
     // clear up CacheLookupResult_t::MISS, let Freshness function decide
     // hit status
-    t_state.cache_lookup_result         = HttpTransact::CacheLookupResult_t::NONE;
-    t_state.cache_info.write_lock_state = HttpTransact::CacheWriteLock_t::READ_RETRY;
+    t_state.cache_open_write_fail_action = t_state.txn_conf->cache_open_write_fail_action;
+    t_state.cache_lookup_result          = HttpTransact::CacheLookupResult_t::NONE;
+    t_state.cache_info.write_lock_state  = HttpTransact::CacheWriteLock_t::READ_RETRY;
     break;
 
   case HTTP_TUNNEL_EVENT_DONE:
@@ -2678,7 +2681,21 @@ HttpSM::state_cache_open_read(int event, void *data)
 
     ink_assert(t_state.transact_return_point == nullptr);
     t_state.transact_return_point = HttpTransact::HandleCacheOpenRead;
-    setup_cache_lookup_complete_api();
+
+    // For READ_RETRY actions (5 and 6), skip the CACHE_LOOKUP_COMPLETE hook now.
+    // The hook will fire later with the final result: HIT if stale content is found
+    // during retry, or MISS if nothing is found (from HandleCacheOpenReadMiss).
+    // This ensures plugins see only the final cache lookup result, avoiding issues
+    // like stats double-counting and duplicate hook registrations.
+    if (t_state.txn_conf->cache_open_write_fail_action == static_cast<MgmtByte>(CacheOpenWriteFailAction_t::READ_RETRY) ||
+        t_state.txn_conf->cache_open_write_fail_action ==
+          static_cast<MgmtByte>(CacheOpenWriteFailAction_t::READ_RETRY_STALE_ON_REVALIDATE)) {
+      SMDbg(dbg_ctl_http, "READ_RETRY configured, deferring CACHE_LOOKUP_COMPLETE hook");
+      t_state.cache_lookup_complete_deferred = true;
+      call_transact_and_set_next_state(nullptr);
+    } else {
+      setup_cache_lookup_complete_api();
+    }
     break;
 
   default:
diff --git a/src/proxy/http/HttpTransact.cc b/src/proxy/http/HttpTransact.cc
index 3cc8d3dc54..a30849e698 100644
--- a/src/proxy/http/HttpTransact.cc
+++ b/src/proxy/http/HttpTransact.cc
@@ -2591,12 +2591,22 @@ HttpTransact::HandleCacheOpenReadHitFreshness(State *s)
     }
   }
 
+  // If we're serving stale due to write lock failure, override cache_lookup_result to HIT_STALE
+  // so plugins see the accurate status. what_is_document_freshness returns FRESH to bypass
+  // revalidation, but the content is actually stale.
+  if (s->serving_stale_due_to_write_lock) {
+    s->cache_lookup_result = HttpTransact::CacheLookupResult_t::HIT_STALE;
+  }
+
   ink_assert(s->cache_lookup_result != HttpTransact::CacheLookupResult_t::MISS);
   if (s->cache_lookup_result == HttpTransact::CacheLookupResult_t::HIT_STALE) {
     SET_VIA_STRING(VIA_DETAIL_CACHE_LOOKUP, VIA_DETAIL_MISS_EXPIRED);
     SET_VIA_STRING(VIA_CACHE_RESULT, VIA_IN_CACHE_STALE);
   }
 
+  // Clear the deferred flag if set (for READ_RETRY cases that found stale content)
+  s->cache_lookup_complete_deferred = false;
+
   TRANSACT_RETURN(StateMachineAction_t::API_CACHE_LOOKUP_COMPLETE, HttpTransact::HandleCacheOpenReadHit);
 }
 
@@ -2787,7 +2797,9 @@ HttpTransact::HandleCacheOpenReadHit(State *s)
   //    proxy.config.http.cache.ignore_server_no_cache is set to 0 (i.e don't ignore no cache -- the default setting)
   //
   // But, we only do this if we're not in an API updating the cached object (see TSHttpTxnUpdateCachedObject)
-  if ((((s->cache_lookup_result == CacheLookupResult_t::HIT_STALE) ||
+  // Also skip revalidation if we're serving stale due to write lock failure - we've already decided
+  // to serve the stale content to avoid contention, so don't try to revalidate.
+  if ((((s->cache_lookup_result == CacheLookupResult_t::HIT_STALE && !s->serving_stale_due_to_write_lock) ||
         ((obj->response_get()->get_cooked_cc_mask() & MIME_COOKED_MASK_CC_NO_CACHE) && !s->cache_control.ignore_server_no_cache)) &&
        (s->api_update_cached_object != HttpTransact::UpdateCachedObject_t::CONTINUE))) {
     needs_revalidate = true;
@@ -2942,14 +2954,27 @@ HttpTransact::HandleCacheOpenReadHit(State *s)
 
   HttpCacheSM &cache_sm = s->state_machine->get_cache_sm();
   TxnDbg(dbg_ctl_http_trans, "CacheOpenRead --- HIT-FRESH read while write %d", cache_sm.is_readwhilewrite_inprogress());
-  if (cache_sm.is_readwhilewrite_inprogress())
+  if (cache_sm.is_readwhilewrite_inprogress()) {
     SET_VIA_STRING(VIA_CACHE_RESULT, VIA_IN_CACHE_RWW_HIT);
+  }
+
+  // If serving stale due to write lock failure (actions 2, 3, or 6), adjust VIA to reflect stale serving.
+  // This ensures correct statistics attribution (cache_hit_stale_served instead of cache_hit_fresh).
+  // Note: VIA_SERVER_RESULT is left as default (space) since the origin server was never contacted.
+  if (s->serving_stale_due_to_write_lock) {
+    TxnDbg(dbg_ctl_http_trans, "Serving stale due to write lock failure, adjusting VIA for statistics");
+    SET_VIA_STRING(VIA_DETAIL_CACHE_LOOKUP, VIA_DETAIL_MISS_EXPIRED);
+    SET_VIA_STRING(VIA_CACHE_RESULT, VIA_IN_CACHE_STALE);
+  }
 
   if (s->cache_lookup_result == CacheLookupResult_t::HIT_WARNING) {
     build_response_from_cache(s, HTTPWarningCode::HERUISTIC_EXPIRATION);
-  } else if (s->cache_lookup_result == CacheLookupResult_t::HIT_STALE) {
+  } else if (s->cache_lookup_result == CacheLookupResult_t::HIT_STALE && !s->serving_stale_due_to_write_lock) {
     ink_assert(server_up == false);
     build_response_from_cache(s, HTTPWarningCode::REVALIDATION_FAILED);
+  } else if (s->serving_stale_due_to_write_lock) {
+    // Serving stale due to write lock failure - no Warning header (deprecated per RFC 9111)
+    build_response_from_cache(s, HTTPWarningCode::NONE);
   } else {
     build_response_from_cache(s, HTTPWarningCode::NONE);
   }
@@ -3166,7 +3191,9 @@ HttpTransact::handle_cache_write_lock(State *s)
       //  Write failed and read retry triggered
       //  Clean up server_request and re-initiate
       //  Cache Lookup
-      ink_assert(s->cache_open_write_fail_action == static_cast<MgmtByte>(CacheOpenWriteFailAction_t::READ_RETRY));
+      ink_assert(s->cache_open_write_fail_action == static_cast<MgmtByte>(CacheOpenWriteFailAction_t::READ_RETRY) ||
+                 s->cache_open_write_fail_action ==
+                   static_cast<MgmtByte>(CacheOpenWriteFailAction_t::READ_RETRY_STALE_ON_REVALIDATE));
       s->cache_info.write_status = CacheWriteStatus_t::LOCK_MISS;
       StateMachineAction_t next;
       next           = StateMachineAction_t::CACHE_LOOKUP;
@@ -3207,21 +3234,102 @@ HttpTransact::handle_cache_write_lock(State *s)
   }
 
   if (s->cache_info.write_lock_state == CacheWriteLock_t::READ_RETRY) {
-    TxnDbg(dbg_ctl_http_error, "calling hdr_info.server_request.destroy");
-    s->hdr_info.server_request.destroy();
-    HandleCacheOpenReadHitFreshness(s);
-  } else {
-    StateMachineAction_t next;
-    next = how_to_open_connection(s);
-    if (next == StateMachineAction_t::ORIGIN_SERVER_OPEN || next == StateMachineAction_t::ORIGIN_SERVER_RAW_OPEN) {
-      s->next_action = next;
-      TRANSACT_RETURN(next, nullptr);
+    // For READ_RETRY with cached object, evaluate actual freshness to decide the path.
+    // If the initial lookup was a MISS, cache_lookup_complete_deferred is true and we
+    // need to fire the hook here with the final result. If the initial lookup was a
+    // HIT_STALE (revalidation case), the hook already fired and deferred is false.
+    CacheHTTPInfo *obj = s->cache_info.object_read;
+    if (obj != nullptr) {
+      // Restore request/response times from cached object for freshness calculations and Age header.
+      // Similar to HandleCacheOpenReadHitFreshness, handle clock skew by capping times.
+      s->request_sent_time      = obj->request_sent_time_get();
+      s->response_received_time = obj->response_received_time_get();
+      s->request_sent_time      = std::min(s->client_request_time, s->request_sent_time);
+      s->response_received_time = std::min(s->client_request_time, s->response_received_time);
+
+      // Evaluate actual document freshness. Pass true to skip the STALE_ON_REVALIDATE
+      // short-circuit so we get the real freshness, not the "return FRESH to bypass revalidation" result.
+      Freshness_t freshness = what_is_document_freshness(s, &s->hdr_info.client_request, obj->response_get(), true);
+
+      if (freshness == Freshness_t::FRESH || freshness == Freshness_t::WARNING) {
+        // Object is fresh - serve it from cache for both action 5 and 6.
+        // This is the main benefit of request collapsing: we found a valid cached object.
+        // Clear stale-related state in case it was set during initial stale short-circuit.
+        s->serving_stale_due_to_write_lock = false;
+        s->cache_info.stale_fallback       = nullptr;
+
+        // Destroy server_request since we're serving from cache.
+        TxnDbg(dbg_ctl_http_trans, "READ_RETRY: found fresh object, serving from cache");
+        s->hdr_info.server_request.destroy();
+        s->cache_lookup_result =
+          (freshness == Freshness_t::FRESH) ? CacheLookupResult_t::HIT_FRESH : CacheLookupResult_t::HIT_WARNING;
+
+        if (s->cache_lookup_complete_deferred) {
+          s->cache_lookup_complete_deferred = false;
+          TRANSACT_RETURN(StateMachineAction_t::API_CACHE_LOOKUP_COMPLETE, HandleCacheOpenReadHit);
+        }
+        HandleCacheOpenReadHit(s);
+      } else {
+        // Object is stale. Save it as potential fallback, then trigger actual cache retry.
+        // HandleCacheOpenReadMiss will serve stale fallback (action 6) or go to origin (action 5).
+        if (is_stale_cache_response_returnable(s)) {
+          s->cache_info.stale_fallback = s->cache_info.object_read;
+        }
+        TxnDbg(dbg_ctl_http_trans, "READ_RETRY: object stale, triggering actual cache retry");
+        s->cache_info.object_read  = nullptr;
+        s->cache_info.write_status = CacheWriteStatus_t::LOCK_MISS;
+        s->hdr_info.server_request.destroy();
+        TRANSACT_RETURN(StateMachineAction_t::CACHE_LOOKUP, nullptr);
+      }
     } else {
-      // hehe!
-      s->next_action = next;
-      ink_assert(s->next_action == StateMachineAction_t::DNS_LOOKUP);
-      return;
+      HandleCacheOpenReadMiss(s);
     }
+  } else {
+    handle_cache_write_lock_go_to_origin(s);
+  }
+}
+
+///////////////////////////////////////////////////////////////////////////////
+// Name       : handle_cache_write_lock_go_to_origin
+// Description: Helper to proceed to origin server from handle_cache_write_lock.
+//              Called when write lock succeeded or failed without READ_RETRY.
+//
+///////////////////////////////////////////////////////////////////////////////
+void
+HttpTransact::handle_cache_write_lock_go_to_origin(State *s)
+{
+  TxnDbg(dbg_ctl_http_trans, "handle_cache_write_lock_go_to_origin - proceeding to origin");
+
+  // If the CACHE_LOOKUP_COMPLETE hook was deferred (for action 5/6), fire it now.
+  // We're going to origin without READ_RETRY, so MISS is the final result.
+  if (s->cache_lookup_complete_deferred) {
+    TxnDbg(dbg_ctl_http_trans, "Firing deferred CACHE_LOOKUP_COMPLETE with MISS before origin");
+    s->cache_lookup_complete_deferred = false;
+    s->cache_lookup_result            = CacheLookupResult_t::MISS;
+    TRANSACT_RETURN(StateMachineAction_t::API_CACHE_LOOKUP_COMPLETE, handle_cache_write_lock_go_to_origin_continue);
+  }
+
+  handle_cache_write_lock_go_to_origin_continue(s);
+}
+
+///////////////////////////////////////////////////////////////////////////////
+// Name       : handle_cache_write_lock_go_to_origin_continue
+// Description: Continuation of handle_cache_write_lock_go_to_origin after
+//              the deferred CACHE_LOOKUP_COMPLETE hook fires.
+//
+///////////////////////////////////////////////////////////////////////////////
+void
+HttpTransact::handle_cache_write_lock_go_to_origin_continue(State *s)
+{
+  StateMachineAction_t next;
+  next = how_to_open_connection(s);
+  if (next == StateMachineAction_t::ORIGIN_SERVER_OPEN || next == StateMachineAction_t::ORIGIN_SERVER_RAW_OPEN) {
+    s->next_action = next;
+    TRANSACT_RETURN(next, nullptr);
+  } else {
+    s->next_action = next;
+    ink_assert(s->next_action == StateMachineAction_t::DNS_LOOKUP);
+    return;
   }
 }
 
@@ -3271,20 +3379,69 @@ HttpTransact::HandleCacheOpenReadMiss(State *s)
   } else if (s->api_server_response_no_store) { // plugin may have decided not to cache the response
     s->cache_info.action = CacheAction_t::NO_ACTION;
   } else if (s->cache_info.write_lock_state == CacheWriteLock_t::READ_RETRY) {
-    // We've looped back around due to failing to read during READ_RETRY mode.
-    // Don't attempt another cache write - just proxy to origin without caching.
+    // READ_RETRY cache read failed (no fresh object found).
+    // Check if we have a stale fallback (saved from action 6 revalidation case).
+    bool is_action_6 =
+      (s->cache_open_write_fail_action & static_cast<MgmtByte>(CacheOpenWriteFailAction_t::STALE_ON_REVALIDATE)) != 0;
+
+    if (is_action_6 && s->cache_info.stale_fallback != nullptr) {
+      // Action 6: Serve stale fallback after retries exhausted.
+      TxnDbg(dbg_ctl_http_trans, "READ_RETRY: retries exhausted, serving stale (action 6)");
+      s->cache_info.object_read          = s->cache_info.stale_fallback;
+      s->cache_info.stale_fallback       = nullptr;
+      s->serving_stale_due_to_write_lock = true;
+      s->cache_lookup_result             = CacheLookupResult_t::HIT_STALE;
+
+      if (s->cache_lookup_complete_deferred) {
+        s->cache_lookup_complete_deferred = false;
+        TRANSACT_RETURN(StateMachineAction_t::API_CACHE_LOOKUP_COMPLETE, HandleCacheOpenReadHit);
+      }
+      HandleCacheOpenReadHit(s);
+      return;
+    }
+    // Action 5 or no stale fallback: proceed to origin without caching.
     TxnDbg(dbg_ctl_http_trans, "READ_RETRY cache read failed, bypassing cache");
-    s->cache_info.action = CacheAction_t::NO_ACTION;
+    s->cache_info.stale_fallback = nullptr; // Clear unused fallback
+    s->cache_info.action         = CacheAction_t::NO_ACTION;
   } else {
     HttpTransact::set_cache_prepare_write_action_for_new_request(s);
   }
 
-  ///////////////////////////////////////////////////////////////
-  // a normal miss would try to fetch the document from the    //
-  // origin server, unless the origin server isn't resolvable, //
-  // but if "CacheControl: only-if-cached" is set, then we are //
-  // supposed to send a 504 (GATEWAY TIMEOUT) response.        //
-  ///////////////////////////////////////////////////////////////
+  // If the CACHE_LOOKUP_COMPLETE hook was deferred, decide whether to fire it now.
+  // Only fire when we're at a terminal state for cache lookup:
+  // - NO_ACTION: Not attempting write lock, MISS is the final result
+  // - READ_RETRY: Already retried and still missed, MISS is the final result
+  //
+  // Don't fire yet if action == PREPARE_TO_WRITE and write_lock_state != READ_RETRY,
+  // because we're about to attempt the write lock and may find content on retry.
+  // In that case, the hook will fire later:
+  // - If READ_RETRY finds HIT: handle_cache_write_lock fires CACHE_LOOKUP_COMPLETE with HIT
+  // - If READ_RETRY finds MISS: We come back here with write_lock_state == READ_RETRY
+  if (s->cache_lookup_complete_deferred) {
+    if (s->cache_info.action == CacheAction_t::NO_ACTION || s->cache_info.write_lock_state == CacheWriteLock_t::READ_RETRY) {
+      TxnDbg(dbg_ctl_http_trans, "Firing deferred CACHE_LOOKUP_COMPLETE with MISS (action=%d, write_lock_state=%d)",
+             static_cast<int>(s->cache_info.action), static_cast<int>(s->cache_info.write_lock_state));
+      s->cache_lookup_complete_deferred = false;
+      s->cache_lookup_result            = CacheLookupResult_t::MISS;
+      TRANSACT_RETURN(StateMachineAction_t::API_CACHE_LOOKUP_COMPLETE, HandleCacheOpenReadMissGoToOrigin);
+    }
+    TxnDbg(dbg_ctl_http_trans, "Deferring CACHE_LOOKUP_COMPLETE hook - waiting for write lock attempt");
+  }
+
+  // Proceed to origin server (handles DNS lookup, parent proxy, etc.)
+  HandleCacheOpenReadMissGoToOrigin(s);
+}
+
+///////////////////////////////////////////////////////////////////////////////
+// Name       : HandleCacheOpenReadMissGoToOrigin
+// Description: Contains the "go to origin" logic for cache miss cases.
+//              Handles DNS lookup, parent proxy selection, and request building.
+//
+///////////////////////////////////////////////////////////////////////////////
+void
+HttpTransact::HandleCacheOpenReadMissGoToOrigin(State *s)
+{
+  TxnDbg(dbg_ctl_http_trans, "HandleCacheOpenReadMissGoToOrigin - proceeding to origin");
 
   HTTPHdr *h = &s->hdr_info.client_request;
 
@@ -3327,8 +3484,6 @@ HttpTransact::HandleCacheOpenReadMiss(State *s)
     build_error_response(s, HTTPStatus::GATEWAY_TIMEOUT, "Not Cached", "cache#not_in_cache");
     s->next_action = StateMachineAction_t::SEND_ERROR_CACHE_NOOP;
   }
-
-  return;
 }
 
 void
@@ -7287,7 +7442,8 @@ HttpTransact::calculate_document_freshness_limit(State *s, HTTPHdr *response, ti
 //
 //////////////////////////////////////////////////////////////////////////////
 HttpTransact::Freshness_t
-HttpTransact::what_is_document_freshness(State *s, HTTPHdr *client_request, HTTPHdr *cached_obj_response)
+HttpTransact::what_is_document_freshness(State *s, HTTPHdr *client_request, HTTPHdr *cached_obj_response,
+                                         bool evaluate_actual_freshness)
 {
   bool       heuristic, do_revalidate = false;
   int        age_limit;
@@ -7296,9 +7452,19 @@ HttpTransact::what_is_document_freshness(State *s, HTTPHdr *client_request, HTTP
   uint32_t   cc_mask, cooked_cc_mask;
   uint32_t   os_specifies_revalidate;
 
-  if (s->cache_open_write_fail_action & static_cast<MgmtByte>(CacheOpenWriteFailAction_t::STALE_ON_REVALIDATE)) {
+  // This check works for STALE_ON_REVALIDATE(0x2), ERROR_ON_MISS_STALE_ON_REVALIDATE(0x3), and
+  // READ_RETRY_STALE_ON_REVALIDATE(0x6).
+  // We return FRESH (not STALE) intentionally to bypass the revalidation code path in
+  // HandleCacheOpenReadHit. Returning STALE would trigger origin server contact for revalidation,
+  // but for write lock failure scenarios we want to serve the stale content directly without
+  // revalidation. The serving_stale_due_to_write_lock flag tracks that we're actually serving
+  // stale content, so VIA strings and statistics can be correctly attributed.
+  // When evaluate_actual_freshness is true, skip this short-circuit to get the real freshness.
+  if (!evaluate_actual_freshness &&
+      (s->cache_open_write_fail_action & static_cast<MgmtByte>(CacheOpenWriteFailAction_t::STALE_ON_REVALIDATE))) {
     if (is_stale_cache_response_returnable(s)) {
-      TxnDbg(dbg_ctl_http_match, "cache_serve_stale_on_write_lock_fail, return FRESH");
+      TxnDbg(dbg_ctl_http_match, "cache_serve_stale_on_write_lock_fail, return FRESH to bypass revalidation");
+      s->serving_stale_due_to_write_lock = true;
       return (Freshness_t::FRESH);
     }
   }
diff --git a/src/records/RecordsConfig.cc b/src/records/RecordsConfig.cc
index f0cefd209e..215b6ffd6a 100644
--- a/src/records/RecordsConfig.cc
+++ b/src/records/RecordsConfig.cc
@@ -610,13 +610,15 @@ static constexpr RecordElement RecordsConfig[] =
   ,
   {RECT_CONFIG, "proxy.config.http.cache.max_open_write_retry_timeout", RECD_INT, "0", RECU_DYNAMIC, RR_NULL, RECC_NULL, nullptr, RECA_NULL}
   ,
-  //       #  open_write_fail_action has 3 options:
+  //       #  open_write_fail_action options:
   //       #
   //       #  0 - default. disable cache and goto origin
   //       #  1 - return error if cache miss
   //       #  2 - serve stale until proxy.config.http.cache.max_stale_age, then goto origin, if revalidate
   //       #  3 - return error if cache miss or serve stale until proxy.config.http.cache.max_stale_age, then goto origin, if revalidate
   //       #  4 - return error if cache miss or if revalidate
+  //       #  5 - retry cache read (read-while-writer) on write lock failure, goto origin if retries exhausted
+  //       #  6 - retry cache read on write lock failure, if retries exhausted serve stale if allowed, otherwise goto origin
   {RECT_CONFIG, "proxy.config.http.cache.open_write_fail_action", RECD_INT, "0", RECU_DYNAMIC, RR_NULL, RECC_NULL, nullptr, RECA_NULL}
   ,
   //       #  when_to_revalidate has 4 options:
diff --git a/tests/gold_tests/cache/cache-read-retry-stale.test.py b/tests/gold_tests/cache/cache-read-retry-stale.test.py
new file mode 100644
index 0000000000..8ec31e2aa0
--- /dev/null
+++ b/tests/gold_tests/cache/cache-read-retry-stale.test.py
@@ -0,0 +1,30 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+Test.Summary = '''
+Smoke test for cache_open_write_fail_action = 6 (READ_RETRY_STALE_ON_REVALIDATE) to verify:
+1. Action 6 is accepted by the configuration system
+2. Basic caching works correctly with action 6 enabled
+3. The system does not crash
+
+Note: The stale fallback behavior (serving stale on read retry exhaustion) is
+difficult to reliably test due to timing sensitivity. This test focuses on
+verifying the feature is functional without causing instability.
+'''
+
+Test.ContinueOnFail = True
+
+Test.ATSReplayTest(replay_file="replay/cache-read-retry-stale.replay.yaml")
diff --git a/tests/gold_tests/cache/cache-write-lock-contention.test.py b/tests/gold_tests/cache/cache-write-lock-contention.test.py
new file mode 100644
index 0000000000..7e8ebc9ad1
--- /dev/null
+++ b/tests/gold_tests/cache/cache-write-lock-contention.test.py
@@ -0,0 +1,175 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+'''
+Test cache write lock contention behavior with parallel requests for actions 5 and 6.
+
+This test issues multiple parallel curl requests to trigger actual cache write lock
+contention. It is skipped by default because the behavior is timing-sensitive and
+not suitable for CI environments.
+
+To run this test manually:
+    export RUN_CACHE_CONTENTION_TEST=1
+    cd build/tests
+    ./autest.sh --sandbox /tmp/sbcursor --clean=none -f cache-write-lock-contention
+'''
+
+import os
+
+Test.Summary = '''
+Test cache write lock contention with parallel requests (actions 5 and 6).
+
+This test is SKIPPED by default. Set RUN_CACHE_CONTENTION_TEST=1 to run it.
+'''
+
+Test.ContinueOnFail = True
+
+Test.SkipUnless(
+    Condition(
+        lambda: os.environ.get('RUN_CACHE_CONTENTION_TEST', '').lower() in ('1', 'true', 'yes'),
+        "Set RUN_CACHE_CONTENTION_TEST=1 to run this timing-sensitive test", True))
+
+
+def make_parallel_curl(url, count, stagger_delay=None):
+    """
+    Generate a parallel curl command string.
+
+    Args:
+        url: The URL to request
+        count: Number of parallel requests
+        stagger_delay: If set, add this delay between requests (e.g., "0.1")
+                      If None, all requests fire simultaneously
+    """
+    parts = ['(']
+    for i in range(1, count + 1):
+        if i > 1 and stagger_delay:
+            parts.append(f'sleep {stagger_delay} && ')
+        parts.append(
+            f'{{curl}} -s -o /dev/null -w "req{i}: %{{{{http_code}}}}\\n" "{url}" '
+            f'-H "Host: example.com" -H "X-Request: req{i}" & ')
+    parts.append('wait)')
+    return ''.join(parts)
+
+
+class ContentionTest:
+    """
+    Base class for cache write lock contention tests.
+
+    Tests parallel requests to trigger cache write lock contention and verify
+    that the configured fail_action behaves correctly.
+    """
+
+    def __init__(self, name, action, description, stale_scenario=False):
+        self.name = name
+        self.action = action
+        self.description = description
+        self.stale_scenario = stale_scenario
+        self._setup()
+
+    def _setup(self):
+        origin_delay = 5 if self.stale_scenario else 3
+        self.server = Test.MakeOriginServer(f"server_{self.name}", delay=origin_delay)
+
+        max_age = 1 if self.stale_scenario else 300
+        self.server.addResponse(
+            "sessionlog.json", {
+                "headers": f"GET /test-{self.name} HTTP/1.1\r\nHost: example.com\r\n\r\n",
+                "timestamp": "1469733493.993",
+                "body": ""
+            }, {
+                "headers":
+                    f"HTTP/1.1 200 OK\r\n"
+                    f"Content-Length: 100\r\n"
+                    f"Cache-Control: max-age={max_age}\r\n"
+                    f"X-Origin: {self.name}\r\n"
+                    f"Connection: close\r\n\r\n",
+                "timestamp": "1469733493.993",
+                "body": "X" * 100
+            })
+
+        self.ts = Test.MakeATSProcess(f"ts_{self.name}", enable_cache=True)
+
+        read_retries = 2 if self.stale_scenario else 10
+        retry_time = 100 if self.stale_scenario else 500
+
+        self.ts.Disk.records_config.update(
+            {
+                'proxy.config.diags.debug.enabled': 1,
+                'proxy.config.diags.debug.tags': 'http|cache|http_cache|http_trans',
+                'proxy.config.http.cache.open_write_fail_action': self.action,
+                'proxy.config.http.cache.max_open_write_retries': 1,
+                'proxy.config.http.cache.max_open_write_retry_timeout': 0,
+                'proxy.config.http.cache.max_open_read_retries': read_retries,
+                'proxy.config.http.cache.open_read_retry_time': retry_time,
+                'proxy.config.cache.enable_read_while_writer': 1,
+                'proxy.config.http.cache.max_stale_age': 300,
+            })
+        self.ts.Disk.remap_config.AddLine(f'map http://example.com/ http://127.0.0.1:{self.server.Variables.Port}/')
+
+    def run(self):
+        url_path = f"/test-{self.name}"
+        url = f"http://127.0.0.1:{self.ts.Variables.port}{url_path}"
+
+        if self.stale_scenario:
+            tr = Test.AddTestRun(f"{self.description} - Prime cache")
+            tr.Processes.Default.StartBefore(self.server)
+            tr.Processes.Default.StartBefore(self.ts)
+            tr.MakeCurlCommand(f'-s -D - "{url}" -H "Host: example.com"', ts=self.ts)
+            tr.Processes.Default.ReturnCode = 0
+            tr.StillRunningAfter = self.ts
+            tr.StillRunningAfter = self.server
+
+            tr = Test.AddTestRun(f"{self.description} - Wait for stale")
+            tr.Processes.Default.Command = "sleep 3"
+            tr.Processes.Default.ReturnCode = 0
+            tr.StillRunningAfter = self.ts
+            tr.StillRunningAfter = self.server
+
+        tr = Test.AddTestRun(self.description)
+        if not self.stale_scenario:
+            tr.Processes.Default.StartBefore(self.server)
+            tr.Processes.Default.StartBefore(self.ts)
+
+        count = 8 if self.stale_scenario else 5
+        stagger = None if self.stale_scenario else "0.1"
+        tr.MakeCurlCommandMulti(make_parallel_curl(url, count, stagger), ts=self.ts)
+        tr.Processes.Default.ReturnCode = 0
+        tr.StillRunningAfter = self.ts
+        tr.StillRunningAfter = self.server
+
+        tr = Test.AddTestRun(f"{self.description} - Verify cache")
+        tr.MakeCurlCommand(f'-s -D - "{url}" -H "Host: example.com"', ts=self.ts)
+        tr.Processes.Default.ReturnCode = 0
+        tr.Processes.Default.Streams.stdout = Testers.ContainsExpression("200 OK", "Cache serves 200")
+        tr.StillRunningAfter = self.ts
+        tr.StillRunningAfter = self.server
+
+        self.ts.Disk.traffic_out.Content = Testers.ExcludesExpression("FATAL|ink_release_assert|ink_abort", "No crashes")
+
+        if self.stale_scenario and self.action == 6:
+            self.ts.Disk.traffic_out.Content += Testers.ContainsExpression(
+                "serving stale \\(action 6\\)|object stale, serving stale", "Stale fallback triggered")
+        else:
+            self.ts.Disk.traffic_out.Content += Testers.ContainsExpression(
+                "cache open read failure.*retrying|read while write", "Cache contention occurred")
+
+
+# Action 5: READ_RETRY - retries cache reads, goes to origin if exhausted
+ContentionTest("a5_fresh", 5, "Action 5 - fresh contention").run()
+ContentionTest("a5_stale", 5, "Action 5 - stale revalidation", stale_scenario=True).run()
+
+# Action 6: READ_RETRY_STALE_ON_REVALIDATE - like action 5, but serves stale if available
+ContentionTest("a6_fresh", 6, "Action 6 - fresh contention").run()
+ContentionTest("a6_stale", 6, "Action 6 - stale fallback", stale_scenario=True).run()
diff --git a/tests/gold_tests/cache/replay/cache-read-retry-stale.replay.yaml b/tests/gold_tests/cache/replay/cache-read-retry-stale.replay.yaml
new file mode 100644
index 0000000000..de28fe9648
--- /dev/null
+++ b/tests/gold_tests/cache/replay/cache-read-retry-stale.replay.yaml
@@ -0,0 +1,187 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+#
+# This replay file tests cache_open_write_fail_action = 6 (READ_RETRY_STALE_ON_REVALIDATE)
+#
+# This is a smoke test that verifies:
+# 1. Action 6 is accepted by the configuration system
+# 2. Basic caching works correctly with action 6 enabled
+# 3. The system does not crash
+#
+# The stale fallback behavior (serving stale on read retry exhaustion) is
+# difficult to reliably test due to timing sensitivity. This test focuses
+# on verifying the feature is functional without causing instability.
+#
+
+meta:
+  version: "1.0"
+
+autest:
+  description: 'Test cache_open_write_fail_action = 6 (READ_RETRY with stale fallback) smoke test'
+  dns:
+    name: 'dns-read-retry-stale'
+
+  server:
+    name: 'origin-read-retry-stale'
+
+  client:
+    name: 'client-read-retry-stale'
+
+  ats:
+    name: 'ts-read-retry-stale'
+    process_config:
+      enable_cache: true
+
+    records_config:
+      proxy.config.diags.debug.enabled: 1
+      proxy.config.diags.debug.tags: 'http_cache'
+      # Enable READ_RETRY_STALE_ON_REVALIDATE mode (action 6)
+      proxy.config.http.cache.open_write_fail_action: 6
+      # Configure retry parameters
+      proxy.config.http.cache.max_open_write_retries: 1
+      proxy.config.http.cache.max_open_write_retry_timeout: 0
+      proxy.config.http.cache.max_open_read_retries: 3
+      proxy.config.http.cache.open_read_retry_time: 100
+      proxy.config.cache.enable_read_while_writer: 1
+      # Allow serving stale content
+      proxy.config.http.cache.max_stale_age: 300
+
+    remap_config:
+      - from: "http://example.com/"
+        to: "http://backend.example.com:{SERVER_HTTP_PORT}/"
+
+    log_validation:
+      traffic_out:
+        # Should NOT contain crash indicators
+        excludes:
+          - expression: "FATAL|ALERT|Emergency|ink_release_assert|ink_abort"
+            description: "Verify ATS does not crash with READ_RETRY_STALE mode enabled"
+
+sessions:
+  #############################################################################
+  # First session: Cache a normal object
+  #############################################################################
+  - transactions:
+      - client-request:
+          method: "GET"
+          version: "1.1"
+          url: /test-object
+          headers:
+            fields:
+              - [uuid, cache-prime]
+              - [Host, example.com]
+
+        server-response:
+          status: 200
+          reason: OK
+          headers:
+            fields:
+              - [Content-Length, 100]
+              - [Cache-Control, "max-age=300"]
+              - [X-Response, cached-content]
+
+        proxy-response:
+          status: 200
+          headers:
+            fields:
+              - [X-Response, {value: 'cached-content', as: equal}]
+
+  #############################################################################
+  # Second session: Verify cache hit (action 6 doesn't break basic caching)
+  #############################################################################
+  - transactions:
+      - client-request:
+          delay: 500ms
+          method: "GET"
+          version: "1.1"
+          url: /test-object
+          headers:
+            fields:
+              - [uuid, cache-hit]
+              - [Host, example.com]
+
+        # Should NOT reach origin (cache hit)
+        server-response:
+          status: 500
+          reason: Should Not Reach
+          headers:
+            fields:
+              - [Content-Length, 100]
+              - [X-Response, should-not-reach]
+
+        # Should serve from cache
+        proxy-response:
+          status: 200
+          headers:
+            fields:
+              - [X-Response, {value: 'cached-content', as: equal}]
+
+  #############################################################################
+  # Third session: Test a different uncached object
+  #############################################################################
+  - transactions:
+      - client-request:
+          method: "GET"
+          version: "1.1"
+          url: /another-object
+          headers:
+            fields:
+              - [uuid, another-cache]
+              - [Host, example.com]
+
+        server-response:
+          status: 200
+          reason: OK
+          headers:
+            fields:
+              - [Content-Length, 50]
+              - [Cache-Control, "max-age=300"]
+              - [X-Response, another-content]
+
+        proxy-response:
+          status: 200
+          headers:
+            fields:
+              - [X-Response, {value: 'another-content', as: equal}]
+
+  #############################################################################
+  # Fourth session: Verify second object cached
+  #############################################################################
+  - transactions:
+      - client-request:
+          delay: 500ms
+          method: "GET"
+          version: "1.1"
+          url: /another-object
+          headers:
+            fields:
+              - [uuid, another-hit]
+              - [Host, example.com]
+
+        server-response:
+          status: 500
+          reason: Should Not Reach
+          headers:
+            fields:
+              - [Content-Length, 100]
+              - [X-Response, should-not-reach]
+
+        proxy-response:
+          status: 200
+          headers:
+            fields:
+              - [X-Response, {value: 'another-content', as: equal}]


(trafficserver) 05/08: Fix filter_body plugin: deterministic request blocking and response body blocking (#12876)

Posted by cm...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

cmcfarlen pushed a commit to branch 10.2.x
in repository https://gitbox.apache.org/repos/asf/trafficserver.git

commit 8fda1d9ad5377329ffc82a5bf00252bcf2fbcb9a
Author: Bryan Call <bc...@apache.org>
AuthorDate: Thu Feb 19 09:50:46 2026 -0800

    Fix filter_body plugin: deterministic request blocking and response body blocking (#12876)
    
    * Fix filter_body request body blocking race -- transform blocks after headers
      are sent to origin, causing Content-Length mismatch and 30s hang. Set 1s origin
      no-activity timeout on block, add SEND_RESPONSE_HDR hook for deterministic 403.
    * Conditional SEND_RESPONSE_HDR hook, re-enable response blocking -- only attach
      hook when blocking a request body. Re-enable response blocking in tests.
    * Add FILTER_BODY_BLOCK_CANARY to prove blocked body never reaches origin --
      canary prefix in blocked body + excludes rule on verifier server output.
    * Add server/client log_validation to ats_replay.test.ext -- remove confusing
      traffic_out node, use contains/excludes directly. Add client validation support.
    
    Fixes: #12875
    (cherry picked from commit 3bdfaa59b3abed57c3a528dfce6beaf19f6210f7)
---
 plugins/experimental/filter_body/filter_body.cc    | 75 ++++++++++++++++++++--
 tests/gold_tests/autest-site/ats_replay.test.ext   | 24 +++++++
 .../config/filter_body_response_block.yaml         |  1 -
 .../pluginTest/filter_body/filter_body.replay.yaml | 37 +++++++----
 4 files changed, 115 insertions(+), 22 deletions(-)

diff --git a/plugins/experimental/filter_body/filter_body.cc b/plugins/experimental/filter_body/filter_body.cc
index 11f68a42cc..ca7471c71b 100644
--- a/plugins/experimental/filter_body/filter_body.cc
+++ b/plugins/experimental/filter_body/filter_body.cc
@@ -101,6 +101,10 @@ struct TransformData {
   bool                      headers_added = false;
 };
 
+// Forward declaration - send_response_handler is used in execute_actions
+// but defined later in the file.
+int send_response_handler(TSCont contp, TSEvent event, void *edata);
+
 /**
  * @brief Case-insensitive substring search.
  *
@@ -439,10 +443,22 @@ execute_actions(TransformData *data, Rule const *rule, std::string const *matche
   if (rule->actions & ACTION_BLOCK) {
     data->blocked = true;
     TSHttpTxnStatusSet(data->txnp, TS_HTTP_STATUS_FORBIDDEN);
-    // Set error body so client gets a proper response
     char const *error_body = "Blocked by content filter";
     TSHttpTxnErrorBodySet(data->txnp, TSstrdup(error_body), strlen(error_body), TSstrdup("text/plain"));
-    Dbg(dbg_ctl, "Blocking request due to rule: %s", rule->name.c_str());
+    if (data->direction == Direction::REQUEST) {
+      // Add a SEND_RESPONSE_HDR hook to override the error response to 403.
+      // Only added here (when actually blocking) to avoid hook overhead on
+      // every transaction.  The transform zeros its output, creating a
+      // Content-Length mismatch at the origin.  The short timeout below makes
+      // ATS close the hanging origin connection quickly instead of waiting the
+      // default 30 seconds.
+      TSCont send_resp_contp = TSContCreate(send_response_handler, nullptr);
+      TSHttpTxnHookAdd(data->txnp, TS_HTTP_SEND_RESPONSE_HDR_HOOK, send_resp_contp);
+      TSHttpTxnHookAdd(data->txnp, TS_HTTP_TXN_CLOSE_HOOK, send_resp_contp);
+      TSHttpTxnConfigIntSet(data->txnp, TS_CONFIG_HTTP_TRANSACTION_NO_ACTIVITY_TIMEOUT_OUT, 1);
+    }
+    Dbg(dbg_ctl, "Blocking %s body due to rule: %s", data->direction == Direction::REQUEST ? "request" : "response",
+        rule->name.c_str());
   }
 }
 
@@ -611,19 +627,21 @@ transform_handler(TSCont contp, TSEvent event, void *edata ATS_UNUSED)
         }
 
         if (data->blocked) {
-          // Blocking action - complete the transform with zero output
-          // The 403 status we set will cause ATS to generate the error response
+          // Zero the transform output so the blocked body is not forwarded.
+          // This creates a Content-Length mismatch at the origin (headers
+          // advertised the original body size but 0 bytes arrive). The short
+          // no-activity timeout set in execute_actions causes ATS to close
+          // the hanging origin connection quickly and generate an error
+          // response. The SEND_RESPONSE_HDR hook then overrides it to 403.
           TSVIONBytesSet(data->output_vio, 0);
           TSVIOReenable(data->output_vio);
 
-          // Consume all remaining input
+          // Consume all remaining input so the client side completes cleanly
           int64_t const remaining = TSIOBufferReaderAvail(reader);
           if (remaining > 0) {
             TSIOBufferReaderConsume(reader, remaining);
           }
           TSVIONDoneSet(write_vio, TSVIONBytesGet(write_vio));
-
-          // Signal write complete
           TSContCall(TSVIOContGet(write_vio), TS_EVENT_VCONN_WRITE_COMPLETE, write_vio);
           return 0;
         }
@@ -747,6 +765,49 @@ response_handler(TSCont contp, TSEvent event, void *edata)
   return 0;
 }
 
+/**
+ * @brief Send response handler to enforce 403 for blocked requests.
+ *
+ * Called on TS_HTTP_SEND_RESPONSE_HDR_HOOK only when the request body
+ * transform has blocked a request (hook is added in execute_actions).
+ * The transform zeros its output, creating a Content-Length mismatch that
+ * causes ATS to generate an error response (typically 502). This hook
+ * overrides that error to a deterministic 403 Forbidden.
+ *
+ * @param[in] contp The continuation.
+ * @param[in] event The event type (SEND_RESPONSE_HDR or TXN_CLOSE).
+ * @param[in] edata The HTTP transaction.
+ * @return Always returns 0.
+ */
+int
+send_response_handler(TSCont contp, TSEvent event, void *edata)
+{
+  TSHttpTxn txnp = static_cast<TSHttpTxn>(edata);
+
+  if (event == TS_EVENT_HTTP_TXN_CLOSE) {
+    TSContDestroy(contp);
+    TSHttpTxnReenable(txnp, TS_EVENT_HTTP_CONTINUE);
+    return 0;
+  }
+
+  if (event == TS_EVENT_HTTP_SEND_RESPONSE_HDR) {
+    // This hook is only added when the request body was blocked, so we
+    // unconditionally override the response to 403.
+    TSMBuffer bufp;
+    TSMLoc    hdr_loc;
+    if (TSHttpTxnClientRespGet(txnp, &bufp, &hdr_loc) == TS_SUCCESS) {
+      Dbg(dbg_ctl, "Overriding response to 403 for blocked request");
+      TSHttpHdrStatusSet(bufp, hdr_loc, TS_HTTP_STATUS_FORBIDDEN);
+      TSHttpHdrReasonSet(bufp, hdr_loc, TSHttpHdrReasonLookup(TS_HTTP_STATUS_FORBIDDEN),
+                         strlen(TSHttpHdrReasonLookup(TS_HTTP_STATUS_FORBIDDEN)));
+      TSHandleMLocRelease(bufp, TS_NULL_MLOC, hdr_loc);
+    }
+  }
+
+  TSHttpTxnReenable(txnp, TS_EVENT_HTTP_CONTINUE);
+  return 0;
+}
+
 /**
  * @brief Parse the YAML configuration file.
  *
diff --git a/tests/gold_tests/autest-site/ats_replay.test.ext b/tests/gold_tests/autest-site/ats_replay.test.ext
index bb9e19d8c8..c041c42770 100644
--- a/tests/gold_tests/autest-site/ats_replay.test.ext
+++ b/tests/gold_tests/autest-site/ats_replay.test.ext
@@ -178,6 +178,18 @@ def ATSReplayTest(obj, replay_file: str):
         rc = server_config['return_code']
         server.ReturnCode = Any(*rc) if isinstance(rc, list) else rc
 
+    # Configure server log validation if specified.
+    server_log_validation = server_config.get('log_validation', {})
+    if server_log_validation:
+        for contains_entry in server_log_validation.get('contains', []):
+            expression = contains_entry['expression']
+            description = contains_entry.get('description', f'Verify server output contains: {expression}')
+            server.Streams.All += Testers.ContainsExpression(expression, description)
+        for excludes_entry in server_log_validation.get('excludes', []):
+            expression = excludes_entry['expression']
+            description = excludes_entry.get('description', f'Verify server output excludes: {expression}')
+            server.Streams.All += Testers.ExcludesExpression(expression, description)
+
     # ATS configuration.
     if not 'ats' in autest_config:
         raise ValueError(f"Replay file {replay_file} does not contain 'autest.ats' section")
@@ -201,6 +213,18 @@ def ATSReplayTest(obj, replay_file: str):
         rc = client_config['return_code']
         client.ReturnCode = Any(*rc) if isinstance(rc, list) else rc
 
+    # Configure client log validation if specified.
+    client_log_validation = client_config.get('log_validation', {})
+    if client_log_validation:
+        for contains_entry in client_log_validation.get('contains', []):
+            expression = contains_entry['expression']
+            description = contains_entry.get('description', f'Verify client output contains: {expression}')
+            client.Streams.All += Testers.ContainsExpression(expression, description)
+        for excludes_entry in client_log_validation.get('excludes', []):
+            expression = excludes_entry['expression']
+            description = excludes_entry.get('description', f'Verify client output excludes: {expression}')
+            client.Streams.All += Testers.ExcludesExpression(expression, description)
+
     if dns:
         ts.StartBefore(dns)
     ts.StartBefore(server)
diff --git a/tests/gold_tests/pluginTest/filter_body/config/filter_body_response_block.yaml b/tests/gold_tests/pluginTest/filter_body/config/filter_body_response_block.yaml
index 2888a229f2..a853ec8efa 100644
--- a/tests/gold_tests/pluginTest/filter_body/config/filter_body_response_block.yaml
+++ b/tests/gold_tests/pluginTest/filter_body/config/filter_body_response_block.yaml
@@ -13,4 +13,3 @@ rules:
         - "SSN:"
         - "password:"
     action: [log, block]
-
diff --git a/tests/gold_tests/pluginTest/filter_body/filter_body.replay.yaml b/tests/gold_tests/pluginTest/filter_body/filter_body.replay.yaml
index 78f66584f4..5cb4f8bb24 100644
--- a/tests/gold_tests/pluginTest/filter_body/filter_body.replay.yaml
+++ b/tests/gold_tests/pluginTest/filter_body/filter_body.replay.yaml
@@ -26,10 +26,16 @@ autest:
 
   server:
     name: 'server'
+    process_config:
+      other_args: '--verbose diag'
+    log_validation:
+      excludes:
+        - expression: "FILTER_BODY_BLOCK_CANARY"
+          description: "Verify blocked request body never reached the origin"
 
   client:
     name: 'client'
-    # There's a race condition for when the connection is droped by ATS - this
+    # There's a race condition for when the connection is dropped by ATS - this
     # can result in either a 0 or 1 return code.
     return_code: [0, 1]
 
@@ -122,8 +128,12 @@ autest:
             description: "Verify response block rule matched"
       traffic_out:
         contains:
-          - expression: "Blocking request due to rule"
+          - expression: "Blocking request body due to rule"
             description: "Verify request blocking action was taken"
+          - expression: "Overriding response to 403 for blocked request"
+            description: "Verify SEND_RESPONSE_HDR hook enforced 403 status"
+          - expression: "Blocking response body due to rule"
+            description: "Verify response blocking action was taken"
           - expression: "Added header X-Security-Match"
             description: "Verify header was added for request"
           # Adding an internal response is not supported while streaming the body.
@@ -167,11 +177,10 @@ sessions:
   #############################################################################
   # Test 2: Request block - request with XXE pattern is blocked
   #
-  # When blocking request bodies, ATS closes the connection to the origin and
-  # the client either experiences simply a closed connection or, depending upon
-  # timeing, a 502 Bad Gateway response. The plugin cannot send a custom error
-  # response (like 403) because the request headers have already been sent to
-  # the origin by the time the body is inspected.
+  # The plugin detects the malicious pattern in the request body and blocks it.
+  # The transform aborts the origin-side VConn to prevent the body from being
+  # forwarded, and a SEND_RESPONSE_HDR hook overrides whatever error ATS
+  # generates (typically 502) to a deterministic 403.
   #############################################################################
   - transactions:
       - client-request:
@@ -182,10 +191,10 @@ sessions:
             fields:
               - [Host, request-block.example.com]
               - [Content-Type, "application/xml+plus_other_stuff"]
-              - [Content-Length, 49]
+              - [Content-Length, 74]
               - [uuid, request-block-test]
           content:
-            data: '<?xml version="1.0"?><!ENTITY xxe SYSTEM "file:">'
+            data: 'FILTER_BODY_BLOCK_CANARY <?xml version="1.0"?><!ENTITY xxe SYSTEM "file:">'
 
         server-response:
           status: 200
@@ -197,7 +206,7 @@ sessions:
             data: "OK"
 
         proxy-response:
-          status: 502
+          status: 403
 
   #############################################################################
   # Test 3: Request header - request passes, header added to server request
@@ -334,9 +343,10 @@ sessions:
           # don't expect to see any external headers added.
 
   #############################################################################
-  # Test 7: Response block - detect pattern and attempt blocking
-  # Note: Response blocking after streaming starts has limitations - the
-  # response will still return 200 but the body will be blocked.
+  # Test 7: Response block - block responses with sensitive data
+  # The response body is blocked (zeroed) when a pattern is detected. Because
+  # response headers (including Content-Length) are committed before the body
+  # is inspected, the client sees a Content-Length mismatch.
   #############################################################################
   - transactions:
       - client-request:
@@ -362,6 +372,5 @@ sessions:
           content:
             data: '{"name": "John", "SSN: 123-45-6789"}'
 
-        # Note that blocking happens after the 200 response headers are sent.
         proxy-response:
           status: 200


(trafficserver) 02/08: rpc: tolerate unsupported chmod on unix sockets (#12895)

Posted by cm...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

cmcfarlen pushed a commit to branch 10.2.x
in repository https://gitbox.apache.org/repos/asf/trafficserver.git

commit 5a10eaa9251cdb7a63a7edc124d4bb0ad6d207fc
Author: Brian Neradt <br...@gmail.com>
AuthorDate: Thu Feb 19 18:03:30 2026 -0600

    rpc: tolerate unsupported chmod on unix sockets (#12895)
    
    test_jsonrpcserver can fail on containerized macOS-host mounts where
    chmod() on an AF_UNIX socket inode returns EINVAL or ENOTSUP.
    When this happens, RPC server init aborts and the client test
    fails with connection refused.
    
    Treat unsupported chmod-on-socket errors as non-fatal and keep
    running with umask-derived permissions, while preserving fatal
    behavior for other chmod failures. Also zero-initialize
    sockaddr_un before use to avoid carrying stale bytes into bind().
    
    (cherry picked from commit e26b42498f6d04b87ae858179e0e6efc6144c8f5)
---
 src/mgmt/rpc/server/IPCSocketServer.cc | 10 ++++++++--
 1 file changed, 8 insertions(+), 2 deletions(-)

diff --git a/src/mgmt/rpc/server/IPCSocketServer.cc b/src/mgmt/rpc/server/IPCSocketServer.cc
index c819c08fca..6b6ebbe7c2 100644
--- a/src/mgmt/rpc/server/IPCSocketServer.cc
+++ b/src/mgmt/rpc/server/IPCSocketServer.cc
@@ -140,6 +140,7 @@ std::error_code
 IPCSocketServer::init()
 {
   std::error_code ec; // Flag possible errors.
+  _serverAddr = {};
   // Need to run some validations on the pathname to avoid issue. Normally this would not be an issue, but some tests may fail on
   // this.
   if (_conf.sockPathName.empty() || _conf.sockPathName.size() > sizeof _serverAddr.sun_path) {
@@ -318,8 +319,13 @@ IPCSocketServer::bind(std::error_code &ec)
 
   mode_t mode = restricted ? 00700 : 00777;
   if (chmod(_conf.sockPathName.c_str(), mode) < 0) {
-    ec = std::make_error_code(static_cast<std::errc>(errno));
-    return;
+    // Some filesystems don't support chmod on AF_UNIX socket inodes.
+    // Keep running in that case and rely on default umask-derived permissions.
+    if (errno != EINVAL && errno != ENOTSUP && errno != EOPNOTSUPP) {
+      ec = std::make_error_code(static_cast<std::errc>(errno));
+      return;
+    }
+    Dbg(dbg_ctl, "chmod(%s) not supported on this filesystem: %s", _conf.sockPathName.c_str(), std::strerror(errno));
   }
 }