From 928e39196eda9475b2c1cf44c5234452dab13e45 Mon Sep 17 00:00:00 2001 From: Mo Chen Date: Tue, 30 Jun 2026 22:33:35 -0500 Subject: [PATCH] prefetch: don't drop replacements for non-participating optional groups Pattern::replace() rejected any replacement reference $N whose index was >= the value returned by the match. But that value is one past the highest capture group that *participated* in the match, not the number of groups the pattern defines. A trailing optional group such as "(\?.*)?" that does not participate -- e.g. a request with no query string -- lowers that count and makes a valid $N look out of range, so every such request logged "invalid reference in replacement string: $N" and silently dropped the prefetch. Validate $N references once at config-load time against the pattern's actual capture-group count (Regex::get_capture_count()), and at match time substitute an empty string for a group that did not participate instead of failing the whole replacement -- the documented PCRE2 semantics for an unmatched group. Also treat an unusable fetch-path-pattern, and invalid --fetch-count / --fetch-max / --fetch-overflow values, as configuration errors so the remap rule is refused at load rather than silently running with prefetch disabled; skip an empty expanded path instead of self-prefetching the original request path; and remove the now-dead process()/capture() helpers. Adds autests for the non-participating-group fix, the rejected over-limit pattern, and the empty-replacement skip. --- plugins/prefetch/configs.cc | 53 +++++++- plugins/prefetch/configs.h | 2 +- plugins/prefetch/pattern.cc | 117 +++++------------- plugins/prefetch/pattern.h | 2 - plugins/prefetch/plugin.cc | 8 ++ .../prefetch_bad_pattern_refused.test.py | 54 ++++++++ .../prefetch_empty_replacement.test.py | 90 ++++++++++++++ .../prefetch/prefetch_optional_group.gold | 4 + .../prefetch/prefetch_optional_group.test.py | 95 ++++++++++++++ 9 files changed, 332 insertions(+), 93 deletions(-) create mode 100644 tests/gold_tests/pluginTest/prefetch/prefetch_bad_pattern_refused.test.py create mode 100644 tests/gold_tests/pluginTest/prefetch/prefetch_empty_replacement.test.py create mode 100644 tests/gold_tests/pluginTest/prefetch/prefetch_optional_group.gold create mode 100644 tests/gold_tests/pluginTest/prefetch/prefetch_optional_group.test.py diff --git a/plugins/prefetch/configs.cc b/plugins/prefetch/configs.cc index 8d74e7585e4..6d3c5cd3abf 100644 --- a/plugins/prefetch/configs.cc +++ b/plugins/prefetch/configs.cc @@ -71,14 +71,39 @@ iequals(const StringView lhs, const StringView rhs) [](const char a, const char b) { return tolower(a) == tolower(b); }); } -void +bool PrefetchConfig::setFetchOverflow(const char *optarg) { - if (StringView("64") == optarg) { + if (nullptr == optarg) { + return false; + } + if (StringView("32") == optarg) { + _fetchOverflow = EvalPolicy::Overflow32; + } else if (StringView("64") == optarg) { _fetchOverflow = EvalPolicy::Overflow64; } else if (iequals("bignum", optarg)) { _fetchOverflow = EvalPolicy::Bignum; + } else { + return false; + } + return true; +} + +/** + * @brief Whether @a optarg is a non-empty string of decimal digits (a valid unsigned integer option). + */ +static bool +isUnsignedInt(const char *optarg) +{ + if (nullptr == optarg || '\0' == *optarg) { + return false; + } + for (const char *p = optarg; '\0' != *p; ++p) { + if (*p < '0' || *p > '9') { + return false; + } } + return true; } /** @@ -147,7 +172,12 @@ PrefetchConfig::init(int argc, char *argv[]) break; case 'c': /* --fetch-count */ - setFetchCount(optarg); + if (isUnsignedInt(optarg)) { + setFetchCount(optarg); + } else { + PrefetchError("invalid --fetch-count '%s': expected a non-negative integer", optarg ? optarg : ""); + status = false; + } break; case 'e': /* --fetch-path-pattern */ { @@ -156,7 +186,10 @@ PrefetchConfig::init(int argc, char *argv[]) if (pattern->init(optarg)) { _nextPaths.add(std::move(pattern)); } else { - PrefetchError("failed to initialize next object pattern"); + /* An unusable fetch-path-pattern is a configuration error; fail instance creation so ATS + * refuses to load the remap rule rather than silently running with prefetch disabled. */ + PrefetchError("failed to initialize fetch-path-pattern '%s'", optarg ? optarg : ""); + status = false; } } } break; @@ -166,11 +199,19 @@ PrefetchConfig::init(int argc, char *argv[]) } break; case 'x': /* --fetch-max */ - setFetchMax(optarg); + if (isUnsignedInt(optarg)) { + setFetchMax(optarg); + } else { + PrefetchError("invalid --fetch-max '%s': expected a non-negative integer", optarg ? optarg : ""); + status = false; + } break; case 'o': /* --fetch-overflow */ - setFetchOverflow(optarg); + if (!setFetchOverflow(optarg)) { + PrefetchError("invalid --fetch-overflow '%s': expected 32, 64, or bignum", optarg ? optarg : ""); + status = false; + } break; case 'r': /* --replace-host */ diff --git a/plugins/prefetch/configs.h b/plugins/prefetch/configs.h index e36e12cdb7b..e32a80410de 100644 --- a/plugins/prefetch/configs.h +++ b/plugins/prefetch/configs.h @@ -142,7 +142,7 @@ class PrefetchConfig return _fetchMax; } - void setFetchOverflow(const char *optarg); + bool setFetchOverflow(const char *optarg); EvalPolicy getFetchOverflow() const diff --git a/plugins/prefetch/pattern.cc b/plugins/prefetch/pattern.cc index 22926c42f4c..3a38200539f 100644 --- a/plugins/prefetch/pattern.cc +++ b/plugins/prefetch/pattern.cc @@ -130,45 +130,6 @@ Pattern::empty() const return _pattern.empty() || _regex.empty(); } -/** - * @brief Capture or capture-and-replace depending on whether a replacement string is specified. - * @see replace() - * @see capture() - * @param subject PCRE2 subject string - * @param result vector of strings where the result of captures or the replacements will be returned. - * @return true if there was a match and capture or replacement succeeded, false if failure. - */ -bool -Pattern::process(const String &subject, StringVector &result) -{ - if (!_replacement.empty()) { - /* Replacement pattern was provided in the configuration - capture and replace. */ - String element; - if (replace(subject, element)) { - result.push_back(element); - } else { - return false; - } - } else { - /* Replacement was not provided so return all capturing groups except the group zero. */ - StringVector captures; - if (capture(subject, captures)) { - if (captures.size() == 1) { - result.push_back(captures[0]); - } else { - StringVector::iterator it = captures.begin() + 1; - for (; it != captures.end(); it++) { - result.push_back(*it); - } - } - } else { - return false; - } - } - - return true; -} - /** * @brief PCRE2 matches a subject string against the regex pattern. * @param subject PCRE2 subject @@ -195,39 +156,6 @@ Pattern::match(const String &subject) return true; } -/** - * @brief Return all PCRE2 capture groups that matched in the subject string - * @param subject PCRE2 subject string - * @param result reference to vector of strings containing all capture groups - */ -bool -Pattern::capture(const String &subject, StringVector &result) -{ - PrefetchDebug("matching '%s' to '%s'", _pattern.c_str(), subject.c_str()); - - if (_regex.empty()) { - return false; - } - - RegexMatches matches; - int matchCount = _regex.exec(subject, matches, RE_NOTEMPTY); - - if (matchCount <= 0) { - if (matchCount != RE_ERROR_NOMATCH) { - PrefetchError("matching error %d", matchCount); - } - return false; - } - - for (int i = 0; i < matchCount; i++) { - std::string_view match = matches[i]; - result.emplace_back(match.data(), match.length()); - PrefetchDebug("capturing '%s' %d", result.back().c_str(), i); - } - - return true; -} - /** * @brief Replaces all replacements found in the replacement string with what matched in the PCRE2 capturing groups. * @param subject PCRE2 subject string @@ -253,22 +181,18 @@ Pattern::replace(const String &subject, String &result) return false; } - /* Verify the replacement has the right number of matching groups */ - for (int i = 0; i < _tokenCount; i++) { - if (_tokens[i] >= matchCount) { - PrefetchError("invalid reference in replacement string: $%d", _tokens[i]); - return false; - } - } - int previous = 0; for (int i = 0; i < _tokenCount; i++) { - int replIndex = _tokens[i]; - std::string_view dst = matches[replIndex]; + int replIndex = _tokens[i]; - String src(_replacement, _tokenOffset[i], 2); + /* $replIndex was validated at config-load time against the number of groups the pattern defines, but + * the group may still not have participated in *this* match (e.g. a trailing optional group such as + * "(\?.*)?" when the subject has no query string). pcre2_match() returns one past the highest + * participating group, so substitute an empty string for a group at or beyond that -- the documented + * PCRE2 semantics for an unmatched group -- rather than failing the whole replacement. */ + std::string_view dst = (replIndex < matchCount) ? matches[replIndex] : std::string_view{}; - PrefetchDebug("replacing '%s' with '%.*s'", src.c_str(), static_cast(dst.length()), dst.data()); + PrefetchDebug("replacing '$%d' with '%.*s'", replIndex, static_cast(dst.length()), dst.data()); result.append(_replacement, previous, _tokenOffset[i] - previous); result.append(dst.data(), dst.length()); @@ -331,6 +255,31 @@ Pattern::compile() } } + /* Validate replacement references against the number of capture groups the pattern actually defines + * (not how many happen to participate in any given match) at config-load time. This catches a + * genuinely out-of-range reference such as $5 against a 3-group pattern, and a pattern that defines + * more groups than can be captured -- RegexMatches holds the whole match plus TOKENCOUNT-1 groups. */ + if (success) { + int32_t captureCount = _regex.get_capture_count(); + if (captureCount < 0) { + PrefetchError("failed to get capture count for regex '%s'", _pattern.c_str()); + success = false; + } else if (captureCount > TOKENCOUNT - 1) { + PrefetchError("regex '%s' defines %d capture groups; the prefetch plugin supports at most %d (references $0..$%d)", + _pattern.c_str(), captureCount, TOKENCOUNT - 1, TOKENCOUNT - 1); + success = false; + } else { + for (int i = 0; i < _tokenCount; i++) { + if (_tokens[i] > captureCount) { + PrefetchError("invalid reference $%d in replacement '%s': pattern defines only %d group(s)", _tokens[i], + _replacement.c_str(), captureCount); + success = false; + break; + } + } + } + } + return success; } diff --git a/plugins/prefetch/pattern.h b/plugins/prefetch/pattern.h index 1105c17305f..43f8ea5b159 100644 --- a/plugins/prefetch/pattern.h +++ b/plugins/prefetch/pattern.h @@ -40,9 +40,7 @@ class Pattern bool init(const String &config); bool empty() const; bool match(const String &subject); - bool capture(const String &subject, StringVector &result); bool replace(const String &subject, String &result); - bool process(const String &subject, StringVector &result); private: bool compile(); diff --git a/plugins/prefetch/plugin.cc b/plugins/prefetch/plugin.cc index b935e7928db..e75d3fc2ae1 100644 --- a/plugins/prefetch/plugin.cc +++ b/plugins/prefetch/plugin.cc @@ -634,6 +634,14 @@ contHandleFetch(const TSCont contp, TSEvent event, void *edata) String expandedPath; if (config.getNextPath().replace(workingPath, expandedPath)) { + if (expandedPath.empty()) { + /* A replacement that collapses to empty (e.g. every referenced group was optional and + * absent) would otherwise be scheduled with a zero-length path, which BgFetch skips -- + * leaving the original request path in place and prefetching the pristine URL itself. + * Stop rather than issue that self-prefetch. */ + PrefetchError("prefetch pattern produced an empty path; check the fetch-path-pattern replacement"); + break; + } PrefetchDebug("replaced: %s", expandedPath.c_str()); expand(expandedPath, config.getFetchOverflow()); PrefetchDebug("expanded: %s cachekey: %s", expandedPath.c_str(), data->_cachekey.c_str()); diff --git a/tests/gold_tests/pluginTest/prefetch/prefetch_bad_pattern_refused.test.py b/tests/gold_tests/pluginTest/prefetch/prefetch_bad_pattern_refused.test.py new file mode 100644 index 00000000000..01ddbfd1a2e --- /dev/null +++ b/tests/gold_tests/pluginTest/prefetch/prefetch_bad_pattern_refused.test.py @@ -0,0 +1,54 @@ +''' +''' +# 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 that prefetch.so treats an unusable fetch-path-pattern as a configuration error: ATS refuses to +load the remap rule (and fails to start) instead of silently running with prefetch disabled. + +The pattern below defines 10 capture groups. The plugin's ovector (OVECOUNT = TOKENCOUNT*3) can hold +offsets for the whole match plus at most TOKENCOUNT-1 (9) groups, so the pattern is rejected at +config-load time; that fails the remap instance, and remap.config fails to load. +''' + +ts = Test.MakeATSProcess("ts") +ts.Disk.records_config.update({ + 'proxy.config.diags.debug.enabled': 1, + 'proxy.config.diags.debug.tags': 'prefetch', +}) +ts.Disk.remap_config.AddLine( + "map http://domain.in http://127.0.0.1:8080" + " @plugin=prefetch.so" + " @pparam=--front=true" + + " @pparam=--fetch-policy=simple" + r" @pparam=--fetch-path-pattern=/(a)(b)(c)(d)(e)(f)(g)(h)(i)(j)/$1/") + +ts.ReturnCode = 33 # Emergency exit: remap.config failed to load. +ts.Ready = 0 +# ATS is expected to log the rejection; this ContainsExpression both asserts it and replaces the +# default "diags.log must not contain ERROR:" check (the rejection is logged via TSError). +ts.Disk.diags_log.Content = Testers.ContainsExpression( + "defines 10 capture groups", "over-limit fetch-path-pattern must be rejected at config load") + +tr = Test.AddTestRun("prefetch rejects an over-limit capture-group pattern at load") +# Wait for the rejection message with a separate watcher: gating ts readiness on the log line directly +# can race the process exiting before autest observes the line. +watcher = Test.Processes.Process("watcher") +watcher.Command = "sleep 30" +watcher.Ready = When.FileContains(ts.Disk.diags_log.Name, "defines 10 capture groups") +watcher.StartBefore(ts) + +tr.Processes.Default.Command = "echo done" +tr.TimeOut = 30 +tr.Processes.Default.StartBefore(watcher) diff --git a/tests/gold_tests/pluginTest/prefetch/prefetch_empty_replacement.test.py b/tests/gold_tests/pluginTest/prefetch/prefetch_empty_replacement.test.py new file mode 100644 index 00000000000..4556e52b596 --- /dev/null +++ b/tests/gold_tests/pluginTest/prefetch/prefetch_empty_replacement.test.py @@ -0,0 +1,90 @@ +''' +''' +# 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 prefetch.so does not self-prefetch when a valid pattern's replacement collapses to an empty path. + +The replacement here is just "$3", where group 3 ("(\\?.*)?") is optional. For a query-less request +that group is absent, so the replacement expands to the empty string. An empty expanded path would +otherwise be scheduled with zero length, which BgFetch skips -- leaving the pristine request path and +prefetching the URL itself. The plugin must instead log and stop; the client request is still served. +''' + +server = Test.MakeOriginServer("server") +for i in list(range(1, 1 + 2)): + request_header = { + "headers": + f"GET /texts/demo-{i} HTTP/1.1\r\n" + "Host: does.not.matter\r\n" # But cannot be omitted. + "\r\n", + "timestamp": "1469733493.993", + "body": "" + } + response_header = { + "headers": "HTTP/1.1 200 OK\r\n" + "Connection: close\r\n" + "Cache-control: max-age=85000\r\n" + "\r\n", + "timestamp": "1469733493.993", + "body": f"This is the body for demo-{i}.\n" + } + server.addResponse("sessionlog.json", request_header, response_header) + +dns = Test.MakeDNServer("dns") + +ts = Test.MakeATSProcess("ts") +ts.Disk.records_config.update( + { + 'proxy.config.diags.debug.enabled': 1, + 'proxy.config.diags.debug.tags': 'http|dns|prefetch', + 'proxy.config.dns.nameservers': f"127.0.0.1:{dns.Variables.Port}", + 'proxy.config.dns.resolv_conf': "NULL", + }) +# A valid pattern (3 defined groups, $3 in range) whose replacement is only the optional $3 group. +ts.Disk.remap_config.AddLine( + f"map http://domain.in http://127.0.0.1:{server.Variables.Port}" + " @plugin=cachekey.so @pparam=--remove-all-params=true" + " @plugin=prefetch.so" + " @pparam=--front=true" + " @pparam=--fetch-policy=simple" + + r" @pparam=--fetch-path-pattern=/(.*-)(\d+)(\?.*)?$/$3/" + " @pparam=--fetch-count=3") +ts.ReturnCode = Any(0, -2) + +# The empty replacement must be logged and skipped. This ContainsExpression both asserts the message +# and replaces the default "diags.log must not contain ERROR:" check (it is logged via TSError). +ts.Disk.diags_log.Content = Testers.ContainsExpression( + "produced an empty path", "an empty replacement must be logged and skipped, not self-prefetched") + +tr = Test.AddTestRun() +tr.Processes.Default.StartBefore(server) +tr.Processes.Default.StartBefore(dns) +tr.Processes.Default.StartBefore(ts) +tr.Processes.Default.Command = 'echo start TS, HTTP server and DNS.' +tr.Processes.Default.ReturnCode = 0 + +# The client request is still served normally even though the prefetch is skipped. +tr = Test.AddTestRun() +tr.MakeCurlCommand(f'--verbose --proxy 127.0.0.1:{ts.Variables.port} http://domain.in/texts/demo-1') +tr.Processes.Default.ReturnCode = 0 + +Test.AddAwaitFileContainsTestRun('Await the empty-path skip to be logged.', ts.Disk.diags_log.Name, 'produced an empty path') + +# The self-prefetch that the old code issued would re-fetch the original path. With the fix the loop +# stops before scheduling, so "failed to process the pattern" (the old second-iteration symptom on the +# emptied working path) must never appear. +tr = Test.AddTestRun() +tr.Processes.Default.Command = (f"grep -c 'failed to process the pattern' {ts.Disk.traffic_out.Name} || true") +tr.Streams.stdout = Testers.ContainsExpression("0", "no per-request pattern-processing failure") +tr.Processes.Default.ReturnCode = 0 diff --git a/tests/gold_tests/pluginTest/prefetch/prefetch_optional_group.gold b/tests/gold_tests/pluginTest/prefetch/prefetch_optional_group.gold new file mode 100644 index 00000000000..5cd6cb5de9e --- /dev/null +++ b/tests/gold_tests/pluginTest/prefetch/prefetch_optional_group.gold @@ -0,0 +1,4 @@ +GET http://domain.in/texts/demo-1 HTTP/1.1 +GET http://domain.in/texts/demo-2 HTTP/1.1 +GET http://domain.in/texts/demo-3 HTTP/1.1 +GET http://domain.in/texts/demo-4 HTTP/1.1 diff --git a/tests/gold_tests/pluginTest/prefetch/prefetch_optional_group.test.py b/tests/gold_tests/pluginTest/prefetch/prefetch_optional_group.test.py new file mode 100644 index 00000000000..813a6b640ae --- /dev/null +++ b/tests/gold_tests/pluginTest/prefetch/prefetch_optional_group.test.py @@ -0,0 +1,95 @@ +''' +''' +# 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 prefetch.so with an optional trailing capture group that does not participate in the match. + +The fetch-path-pattern below ends in an optional "(\\?.*)?" group that only participates when the +subject carries a query string. For a query-less request that group is absent, so pcre_exec() returns +3 -- one past the highest *participating* group -- even though the pattern defines a 3rd group that the +replacement references as $3. The old code compared $3 against that return value and wrongly rejected +it; a non-participating group must instead substitute an empty string rather than fail the whole +replacement (which previously logged "invalid reference in replacement string: $3" and silently +dropped every prefetch). Mirrors the production hls/mvod remap pattern +"/(.*-)(\\.m3u8)(\\?.*)?$/$1-0.mp4$3/". +''' + +server = Test.MakeOriginServer("server") +for i in list(range(1, 1 + 4)): + request_header = { + "headers": + f"GET /texts/demo-{i} HTTP/1.1\r\n" + "Host: does.not.matter\r\n" # But cannot be omitted. + "\r\n", + "timestamp": "1469733493.993", + "body": "" + } + response_header = { + "headers": "HTTP/1.1 200 OK\r\n" + "Connection: close\r\n" + "Cache-control: max-age=85000\r\n" + "\r\n", + "timestamp": "1469733493.993", + "body": f"This is the body for demo-{i}.\n" + } + server.addResponse("sessionlog.json", request_header, response_header) + +dns = Test.MakeDNServer("dns") + +ts = Test.MakeATSProcess("ts") +ts.Disk.records_config.update( + { + 'proxy.config.diags.debug.enabled': 1, + 'proxy.config.diags.debug.tags': 'http|dns|prefetch', + 'proxy.config.dns.nameservers': f"127.0.0.1:{dns.Variables.Port}", + 'proxy.config.dns.resolv_conf': "NULL", + }) +ts.Disk.remap_config.AddLine( + f"map http://domain.in http://127.0.0.1:{server.Variables.Port}" + " @plugin=cachekey.so @pparam=--remove-all-params=true" + " @plugin=prefetch.so" + " @pparam=--front=true" + " @pparam=--fetch-policy=simple" + + r" @pparam=--fetch-path-pattern=/(.*-)(\d+)(\?.*)?$/$1{$2+1}$3/" + " @pparam=--fetch-count=3") +ts.ReturnCode = Any(0, -2) + +# Belt-and-suspenders next to the gold comparison (which is the primary guard): a regression that +# re-introduces a per-request rejection of the non-participating $3 logs an "invalid reference ..." +# error. This pattern is valid (3 defined groups, $3 in range) so the compile-time validator never +# fires either, hence no "invalid reference" text should ever reach the log. +ts.Disk.traffic_out.Content = Testers.ExcludesExpression( + "invalid reference", "optional non-participating group must not fail the replacement") + +tr = Test.AddTestRun() +tr.Processes.Default.StartBefore(server) +tr.Processes.Default.StartBefore(dns) +tr.Processes.Default.StartBefore(ts) +tr.Processes.Default.Command = 'echo start TS, HTTP server and DNS.' +tr.Processes.Default.ReturnCode = 0 + +tr = Test.AddTestRun() +tr.MakeCurlCommand(f'--verbose --proxy 127.0.0.1:{ts.Variables.port} http://domain.in/texts/demo-1') +tr.Processes.Default.ReturnCode = 0 + +# The original request and the three prefetches are logged independently and may finish out of +# order, so wait for every expected URL to be logged before comparing, and sort both sides so the +# comparison does not depend on completion order. +for tag in ['demo-1', 'demo-2', 'demo-3', 'demo-4']: + Test.AddAwaitFileContainsTestRun(f'Await {tag} to be logged.', ts.Disk.traffic_out.Name, tag) + +tr = Test.AddTestRun() +tr.Processes.Default.Command = (f"grep 'GET http://domain.in' {ts.Disk.traffic_out.Name} | sort") +tr.Streams.stdout = "prefetch_optional_group.gold" +tr.Processes.Default.ReturnCode = 0