Skip to content

Fix cache_fill range request detection and a Content-Length config gap - #13704

Open
bryancall wants to merge 3 commits into
apache:masterfrom
bryancall:fix-cache-fill-range-hdr-find
Open

bryancall wants to merge 3 commits into
apache:masterfrom
bryancall:fix-cache-fill-range-hdr-find

Conversation

@bryancall

@bryancall bryancall commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Correction (2026-09-17): an earlier version of this description said release
builds had both options broken and that the header handle was leaking. Both were
wrong, and wrong in the direction of overstating. The corrected analysis is below.

The bug

BgFetchConfig::bgFetchAllowed() decides whether a transaction is a range
request by looking for a Range or conditional header. The lookup had a
misplaced closing paren:

if (TSMimeHdrFieldFind(bufp, hdr_loc, header.data(), header.size() == TS_SUCCESS)) {

TSMimeHdrFieldFind() returns a TSMLoc, not a TSReturnCode, so the
== TS_SUCCESS belonged on the call rather than on the length argument.
TS_SUCCESS is 0, so header.size() == TS_SUCCESS evaluated to false,
which converted to a length of 0. Every lookup asked for a zero-length field
name.

Detection still worked in a release build. mime_hdr_field_find() begins
with hdrtoken_is_wks(field_name.data()), which is a pointer-range test against
the well-known-string heap and never consults the length. FILTER_HEADERS holds
genuine WKS pointers (TS_MIME_FIELD_RANGE and friends), so the lookup took the
WKS-index path and found the field despite the zero length. So the two options
behaved correctly in release; what they did not do is clean up.

The two real defects:

  • A leaked MIMEFieldSDKHandle per matching request. TSMimeHdrFieldFind()
    allocates a handle via sdk_alloc_field_handle() for every hit. The call was
    used in boolean context and the handle was dropped on the floor, so every
    request carrying one of the six headers leaked one, on any build.
  • A debug build aborts. mime_hdr_field_find() also does
    ink_assert(!field_name.empty()), which is compiled out in release but fires
    on the first request through a remap rule that sets either option:
Fatal: src/proxy/hdrs/MIME.cc:1224: failed assertion `!field_name.empty()`
traffic_server: received signal 6 (Aborted)

The fix

Pass the name length and test the returned handle, then release it. The
TSHandleMLocRelease() on the header mloc from TSHttpTxnClientReqGet() is
contract hygiene rather than a leak fix: TSHandleMLocRelease() returns
TS_SUCCESS immediately for an HTTP_HEADER object and frees nothing. It is
there because the API asks for it and because the equivalent code in
plugins/experimental/maxmind_acl does the same.

Also reject a Content-Length condition that carries no size value, the way
plugins/background_fetch already does. Without it, ++cfg_value leaves the
view empty, swoc::svtou() sets parsed empty too, so the
parsed.size() != cfg_value.size() guard compares 0 != 0, passes, and a
<= 0 rule is installed from a config line that specified no size at all.

Testing

Folded into the existing tests/gold_tests/pluginTest/cache_fill/cache_fill.test.py,
in its own ATS process. It needs a separate process rather than sharing the
existing one, because that one loads cache_fill globally with default options,
and a global instance hooks every transaction -- it would background fill these
paths regardless of the per-remap options and make the negative cases vacuous.

Four runs, covering both options and both directions of the decision, so a fix
that simply always filled would not pass:

  • --range-req-only=true, range request: must fill (second request is
    hit-fresh / 206 / Content-Range)
  • --range-req-only=true, plain request: must not fill
  • --cache-range-req=false, range request: must decline the fill

No test in the tree set either option before this, so both branches of the
lookup were uncovered. The negative cases assert on the plugin's logged
decision rather than on cache state, because a plain cacheable response is
stored by ordinary proxy caching whether or not the plugin fills, so both
outcomes look identical from the client.

Verified it discriminates, by rebuilding the plugin with the paren restored and
re-running: the test fails on every one of those assertions, with the crash log
under ts-range only, leaving the default-options process unaffected. With the
fix, 13 runs pass and no crash log is produced.

To be precise about what that proves: the test discriminates on a build with
assertions enabled, which is what the AuTest build and CI use. On a release
build it would pass against the unfixed plugin, because there the defect is the
leaked handle rather than a behaviour change. The runs are still worth having --
they pin the intended behaviour of two options that had no coverage at all.

The existing default-options coverage is untouched, and that is itself the
reason this survived: _range_req_only defaults to false and
_cache_range_req to true, so if (_range_req_only || !_cache_range_req)
is false under the default config and the whole block was never entered by any
existing test.

Affected branches

10.2.x and 10.1.x both carry the paren bug and lack the empty-value guard.
9.2.x does not ship the cache_fill plugin.

Neither defect was flagged by Coverity. They turned up while reading
plugins/background_fetch and plugins/experimental/cache_fill side by side,
which is worth doing on its own -- cache_fill was forked from
background_fetch and the two have drifted.

The range header lookup in bgFetchAllowed() had a misplaced closing
paren:

  TSMimeHdrFieldFind(bufp, hdr_loc, header.data(), header.size() == TS_SUCCESS)

TSMimeHdrFieldFind() returns a TSMLoc rather than a TSReturnCode, so the
comparison belonged on the call, not on the length argument. Because
TS_SUCCESS is 0, "header.size() == TS_SUCCESS" evaluated to false and the
plugin searched for a zero length field name, which never matches. That
left hasRangeHdrs false for every request and broke both options that
depend on it: --range-req-only=true suppressed the background fetch even
for real range requests, and --cache-range-req=false never suppressed
anything.

Pass the name length and test the returned handle, and release the
handles the lookup hands back, which the broken call never produced.

Also reject a Content-Length condition with no size value, the way
background_fetch already does. Without the check an empty value leaves
both the parsed and remaining views empty, the length comparison
succeeds, and a "<= 0" rule is installed from a line that specified no
size at all.

Adds an autest covering both directions of the --range-req-only
decision: a range request fills the cache, a plain request does not.
…ache state

The fixed Date in the past against max-age=300 made every stored object
immediately stale, so the cache-hit assertions could not pass; the origin
supplies a current Date on its own.

The non-range case also cannot be observed through cache state, because a
plain cacheable response is stored by ordinary proxy caching whether or
not the plugin background fills. Assert on the plugin's own logged
decision instead, which is what distinguishes the two.
Copilot AI lite review requested due to automatic review settings September 17, 2026 22:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Additional regression coverage is needed for incomplete Content-Length rules and the --cache-range-req=false path.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Fixes cache_fill range-request detection and incomplete Content-Length configuration handling.

Changes:

  • Corrects MIME header lookup and releases ATS handles.
  • Rejects missing Content-Length values.
  • Adds range-only request coverage.

Review follow-ups: Add coverage for incomplete Content-Length rules and --cache-range-req=false.

File summaries
File Summary
tests/gold_tests/pluginTest/cache_fill/cache_fill_range_req_only.test.py Tests range-only cache filling for range and plain requests.
plugins/experimental/cache_fill/configs.cc Fixes header detection, handle cleanup, and configuration parsing.
Review details

Suppressed comments (1)

tests/gold_tests/pluginTest/cache_fill/cache_fill_range_req_only.test.py:77

  • This regression test exercises only --range-req-only=true. The same header lookup also controls --cache-range-req=false, whose range-request path is part of the bug described by this change; without a case for that option, a regression in that branch can pass. Add a range-request case that verifies the plugin declines the fill when --cache-range-req=false is set.
        self.ts.Disk.remap_config.AddLines(
            [
                'map http://www.example.com/fill_on_range http://127.0.0.1:{}/fill_on_range'.format(self.server.Variables.Port) +
                ' @plugin=cache_fill.so @pparam=--range-req-only=true',
                'map http://www.example.com/skip_when_plain http://127.0.0.1:{}/skip_when_plain'.format(
                    self.server.Variables.Port) + ' @plugin=cache_fill.so @pparam=--range-req-only=true',
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +171 to +174
if (cfg_value.empty()) {
TSError("[%s] missing Content-Length size value, skipping config value", PLUGIN_NAME);
continue;
}
Group the range-option coverage with the existing cache_fill test rather
than keeping a separate file, in its own ATS process: the existing one
loads cache_fill globally with default options, and a global instance
hooks every transaction, which would background fill these paths
regardless of the per-remap options and make the negative cases vacuous.

Add the --cache-range-req=false case. No test in the tree set either
option before this, and the same range header lookup drives both, so
that branch had no coverage. Under the broken lookup hasRangeHdrs was
always false and the branch never ran, which makes it an independent
check on the same fix.
Copilot AI review requested due to automatic review settings September 17, 2026 23:46
@bryancall

Copy link
Copy Markdown
Contributor Author

Thanks — I added the --cache-range-req=false case. That was a fair catch: no test in the tree set either option before this, and since the same header lookup drives both, that branch had no coverage at all. It also turns out to be an independent check on this fix rather than just extra breadth — under the broken lookup hasRangeHdrs was always false, so hasRangeHdrs && !_cache_range_req never ran either. I confirmed that by rebuilding the plugin with the paren restored: the new case fails on its own.

While adding it I folded the range-option coverage into the existing cache_fill.test.py instead of a separate file, in its own ATS process. It cannot share the existing one, because that test loads cache_fill globally with default options, and a global instance hooks every transaction — it would background fill these paths regardless of the per-remap options and make the negative cases vacuous.

On coverage for the incomplete Content-Length rule: I am leaving that one out deliberately. The only observable behavior is a TSError line, so a test for it would assert on a log message and need its own ATS instance for a config-parse path, which I do not think earns its keep here. Noting it so it does not look overlooked.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Two moderate issues remain in test isolation and malformed Content-Length coverage.

Review details

Suppressed comments (2)

plugins/experimental/cache_fill/configs.cc:174

  • This new malformed Content-Length rejection is not covered by the added cache_fill test: the new cases exercise only range-request options, while the existing Content-Length coverage is for the separate background_fetch plugin. Please add a cache_fill config case such as Content-Length < and assert that it is skipped (and does not install a <= 0 rule), so a regression in this guard or the subsequent parse cannot go unnoticed.
          if (cfg_value.empty()) {
            TSError("[%s] missing Content-Length size value, skipping config value", PLUGIN_NAME);
            continue;
          }

tests/gold_tests/pluginTest/cache_fill/cache_fill.test.py:284

  • These are three remap instances in one traffic_server, but BgFetchConfig::parseOptions() calls getopt_long() without resetting the process-global optind. After the first --range-req-only instance, the second and third instances will retain their defaults, so the negative decision assertions cannot be satisfied. Reset optind before each parse or isolate each option in a separate ATS process.
                'map http://www.example.com/skip_when_plain http://127.0.0.1:{}/skip_when_plain'.format(port) +
                ' @plugin=cache_fill.so @pparam=--range-req-only=true',
                'map http://www.example.com/decline_on_range http://127.0.0.1:{}/decline_on_range'.format(port) +
                ' @plugin=cache_fill.so @pparam=--cache-range-req=false',
  • Files reviewed: 2/2 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@bryancall bryancall self-assigned this Sep 18, 2026
@bryancall bryancall added this to the 11.0.0 milestone Sep 18, 2026
@bryancall

Copy link
Copy Markdown
Contributor Author

Correcting my own description: I overstated the severity, and the original text was up for a few hours, so the correction is worth stating rather than quietly editing.

I wrote that release builds had both options broken and that the header handle was leaking. Both are wrong.

mime_hdr_field_find() opens with hdrtoken_is_wks(field_name.data()), which is a pointer-range test against the well-known-string heap and never looks at the length. FILTER_HEADERS holds real WKS pointers, so the zero-length lookup still took the WKS-index path and found the field. Range detection worked in release. And TSHandleMLocRelease() returns TS_SUCCESS immediately for an HTTP_HEADER object, so releasing the header mloc frees nothing and nothing was leaking there.

What is actually wrong, and what this change fixes:

  • a leaked MIMEFieldSDKHandle on every request carrying one of the six headers, because TSMimeHdrFieldFind() allocates a handle per hit and the boolean-context call discarded it. That one is real on any build, and it is the field handle, not the header handle.
  • a debug-build abort on ink_assert(!field_name.empty()), compiled out in release.

I have also been precise in the description about what the new test proves: it discriminates on an assertion-enabled build, which is what AuTest and CI use, but it would pass against the unfixed plugin in a release build, since there the defect is the leak rather than a behaviour change. The runs still earn their place because they pin two options that had no coverage at all.

The diff itself is unchanged and still correct. 10.2.x and 10.1.x both carry the bug; 9.2.x does not ship this plugin.

@bryancall bryancall added Backport Marked for backport for an LTS patch release 10.1.x 10.2.x cache_fill Bug labels Sep 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

10.1.x 10.2.x Backport Marked for backport for an LTS patch release Bug cache_fill

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants