bryancall commented on code in PR #13683:
URL: https://github.com/apache/trafficserver/pull/13683#discussion_r4355468408
##########
src/tsutil/Regex.cc:
##########
@@ -80,6 +81,70 @@ my_free(void *ptr, void * /*caller*/)
free(ptr);
}
+//----------------------------------------------------------------------------
+// One match context is shared by every thread that matches through it, and
PCRE2
+// requires a distinct JIT stack per thread, so the stack comes from a callback
+// invoked at match time rather than a pointer baked in when the context is
built.
+//
+// The per thread stack is held in a pthread key rather than a thread_local. A
+// thread_local with a destructor registers it through __cxa_thread_atexit,
which
+// takes the dynamic loader lock; doing that from a match would invert lock
order
+// against a dlopen caller running a plugin's static initialization. See the
same
+// hazard described at Diags::tag_activated. A pthread key registers its
destructor
+// once, at key creation, and never from the matching path.
+pthread_key_t jit_stack_key;
+bool jit_stack_key_valid = false;
+pthread_once_t jit_stack_key_once = PTHREAD_ONCE_INIT;
Review Comment:
These already have internal linkage. They sit inside the unnamed namespace
that opens at line 69 and closes at line 199, which is too far above this hunk
to show in the diff. JosiahWI asked the same thing earlier and the answer is in
that thread.
##########
src/tsutil/Regex.cc:
##########
@@ -80,6 +81,70 @@ my_free(void *ptr, void * /*caller*/)
free(ptr);
}
+//----------------------------------------------------------------------------
+// One match context is shared by every thread that matches through it, and
PCRE2
+// requires a distinct JIT stack per thread, so the stack comes from a callback
+// invoked at match time rather than a pointer baked in when the context is
built.
+//
+// The per thread stack is held in a pthread key rather than a thread_local. A
+// thread_local with a destructor registers it through __cxa_thread_atexit,
which
+// takes the dynamic loader lock; doing that from a match would invert lock
order
+// against a dlopen caller running a plugin's static initialization. See the
same
+// hazard described at Diags::tag_activated. A pthread key registers its
destructor
+// once, at key creation, and never from the matching path.
+pthread_key_t jit_stack_key;
+bool jit_stack_key_valid = false;
+pthread_once_t jit_stack_key_once = PTHREAD_ONCE_INIT;
+
+void
+destroy_jit_stack(void *stack)
+{
+ if (stack != nullptr) {
+ pcre2_jit_stack_free(static_cast<pcre2_jit_stack *>(stack));
+ }
+}
+
+void
+make_jit_stack_key()
+{
+ jit_stack_key_valid = pthread_key_create(&jit_stack_key, destroy_jit_stack)
== 0;
+}
+
+pcre2_jit_stack *
+jit_stack_for_this_thread(void *)
+{
+ pthread_once(&jit_stack_key_once, make_jit_stack_key);
+ if (!jit_stack_key_valid) {
+ // Without a key there is nowhere to keep a stack, and jit_stack_key holds
a
+ // default value that may name an unrelated key. Returning null tells
PCRE2 to
+ // use its own default stack, which pcre2jit documents as thread safe.
+ return nullptr;
+ }
+
+ auto *stack = static_cast<pcre2_jit_stack
*>(pthread_getspecific(jit_stack_key));
+ if (stack == nullptr) {
+ // One page to start, one mebibyte at most. The maximum is address space
reserved at
+ // creation and made resident only as deep as a match actually goes, so a
larger one
+ // costs nothing per match.
+ //
+ // It does NOT cover every subject a client can send. Measured with the
pattern the
+ // unit tests use, a mebibyte resolves about 26,213 characters, while
+ // proxy.config.http.request_header_max_size defaults to 32768. A longer
subject
+ // fails with PCRE2_ERROR_JIT_STACKLIMIT, which exec() returns as a
negative code.
+ // There is no retreat to a smaller stack and none to the interpreter:
measured at
+ // 26,214 characters and again at 100,000, the match returns -46 rather
than
+ // completing by another route.
+ stack = pcre2_jit_stack_create(4096, 1024 * 1024, nullptr);
+ if (pthread_setspecific(jit_stack_key, stack) != 0) {
+ // Nothing holds the stack now, so it would leak once per match. Give it
back and
+ // let PCRE2 use its own default stack for this call.
+ pcre2_jit_stack_free(stack);
Review Comment:
Both cases are already safe. If `pcre2_jit_stack_create()` returns null,
`pthread_setspecific()` stores null and this returns null, and a null from the
callback makes PCRE2 use its internal 32 KiB stack for that match
(`pcre2_jit_stack_assign(3)`). The next match on the thread tries to create the
stack again. On the failure branch, `pcre2_jit_stack_free(NULL)` is documented
to return without doing anything. So I've left it as is rather than add a
branch that changes no behavior.
##########
src/tsutil/Regex.cc:
##########
@@ -279,22 +355,58 @@ RegexMatchContext::operator=(RegexMatchContext const
&other)
{
if (&other != this) {
auto ptr = _MatchContext::get(other._match_context);
- if (nullptr != ptr) {
- pcre2_match_context *const ctx = pcre2_match_context_copy(ptr);
- _MatchContext::set(_match_context, ctx);
- } else {
- _MatchContext::set(_match_context, nullptr);
+
+ // Take the copy before releasing what this object already holds, so a
failing
+ // copy leaves it holding its old context rather than a freed one.
Releasing it
+ // is what this operator used to omit, and every assignment leaked one
context.
+ pcre2_match_context *const ctx = nullptr != ptr ?
pcre2_match_context_copy(ptr) : nullptr;
+
+ // pcre2_match_context_copy() returns nullptr when it cannot allocate.
Assigning it
+ // anyway would free the old context and leave this object empty, which is
the very
+ // thing the ordering above exists to prevent, so leave the object
untouched instead.
+ if (nullptr != ptr && nullptr == ctx) {
Review Comment:
This is deliberate. A copy assignment has no way to report failure, and when
`pcre2_match_context_copy()` cannot allocate the two outcomes are an object
with no context or an object that keeps the one it had. The comment above the
check explains why it keeps the old one. The trade is that the object keeps its
previous settings instead of `other`'s, and only when allocating a match
context fails.
##########
src/tsutil/unit_tests/test_Regex.cc:
##########
@@ -1646,3 +1651,362 @@ TEST_CASE("Regex copies answer the same as their
original", "[libts][Regex][copy
CHECK(original.exec(ordinary, original_ordinary) == copy.exec(ordinary,
copy_ordinary));
}
}
+
+namespace
+{
+/** Does PCRE2 have JIT code for this pattern?
+ *
+ * The two tests below are about the JIT stack, and PCRE2 consults it only
when it
+ * has JIT code to run. Without it both a blank context and the shared one
take the
+ * interpreter and return the same answer, so the tests would pass whether or
not
+ * the behaviour they describe is present. Ask PCRE2 rather than assume.
+ *
+ * This asks about one pattern, deliberately: PCRE2 declines some pattern
items and
+ * leaves PCRE2_INFO_JITSIZE at zero on a build whose JIT is otherwise fine,
so the
+ * library-wide probes (pcre2_config, PCRE2_JIT_TEST_ALLOC) answer a different
+ * question. Call it from the test thread only; Catch2 assertions are not
thread safe.
+ */
+bool
+pattern_has_jit(char const *pattern)
+{
+ int errnum = 0;
+ PCRE2_SIZE erroffset = 0;
+ pcre2_code *code = pcre2_compile(reinterpret_cast<PCRE2_SPTR>(pattern),
PCRE2_ZERO_TERMINATED, 0, &errnum, &erroffset, nullptr);
+
+ // A pattern that will not compile is a broken test, not a build without a
JIT.
+ // Reporting it as "no JIT" would turn a typo into a silent skip.
+ REQUIRE(code != nullptr);
+
+ pcre2_jit_compile(code, PCRE2_JIT_COMPLETE);
+ size_t jit_size = 0;
+
+ pcre2_pattern_info(code, PCRE2_INFO_JITSIZE, &jit_size);
+ pcre2_code_free(code);
+ return jit_size > 0;
+}
+} // namespace
+
+// A caller-supplied RegexMatchContext must behave like the shared context that
+// Regex::exec uses when none is supplied. A context built from scratch
silently
+// drops everything the shared one configures, which is how regex_remap came to
+// run with PCRE2's fallback 32KiB JIT stack instead of the 1MiB one.
+TEST_CASE("RegexMatchContext matches the shared context",
"[libts][Regex][RegexMatchContext]")
+{
+ // Quantified alternation of capture groups: every subject character pushes a
+ // backtracking frame, so the JIT stack size is what bounds this.
+ char const *const pattern = R"(^(?:(a)|(b))+$)";
+ if (!pattern_has_jit(pattern)) {
+ SKIP("PCRE2 has no JIT for this pattern, so the JIT stack is never
consulted");
+ }
+
+ Regex re;
+ REQUIRE(re.compile(pattern));
+
+ // 40 bytes of JIT stack per subject character, so PCRE2's 32KiB fallback
stops at
+ // 818 characters and the shared context's 1MiB stack at 26,213. 5,000 sits
six
+ // times above the first and five times below the second, so a blank context
and
+ // the shared one give different answers with margin either way.
+ std::string const subject(5000, 'a');
+
+ RegexMatches shared_matches;
+ RegexMatchContext match_context;
+ RegexMatches own_matches;
+
+ int const shared_rc = re.exec(subject, shared_matches);
+ int const own_rc = re.exec(subject, own_matches, 0, &match_context);
+ CAPTURE(shared_rc, own_rc);
+
+ REQUIRE(shared_rc > 0);
+ REQUIRE(own_rc == shared_rc);
+
+ // The copy paths must carry the inherited configuration too. Building a
blank
+ // context in either of them is the same defect, and nothing else here
notices.
+ RegexMatchContext const copied{match_context};
+ RegexMatchContext assigned;
+
+ assigned = match_context;
+
+ RegexMatches copied_matches;
+ RegexMatches assigned_matches;
+
+ REQUIRE(re.exec(subject, copied_matches, 0, &copied) == shared_rc);
+ REQUIRE(re.exec(subject, assigned_matches, 0, &assigned) == shared_rc);
+}
+
+// Ownership only, and deliberately NOT inside the case above.
+//
+// That case SKIPs when pattern_has_jit() is false, which is right for what it
tests and
+// wrong for this: move construction and move assignment have nothing to do
with the JIT.
+// Gating them there meant the only coverage of the double free stopped
running on exactly
+// the builds it was written for, a PCRE2 without JIT or the hardened runtime
where JIT
+// allocation fails. A regression test that disappears on the affected
platform is worse
+// than none, because it reports green.
+TEST_CASE("RegexMatchContext move operations transfer ownership",
"[libts][Regex][RegexMatchContext]")
+{
+ Regex re;
+
+ REQUIRE(re.compile("^(a+)(b+)$"));
+
+ std::string_view const subject{"aaabbb"};
+
+ auto matches_through = [&](RegexMatchContext const *ctx) {
+ RegexMatches m;
+ return re.exec(subject, m, 0, ctx);
+ };
+
+ // Baseline through a plain context, so a later mismatch means the move
broke something
+ // rather than the pattern never having matched.
+ RegexMatchContext plain;
+ int const expected = matches_through(&plain);
+
+ REQUIRE(expected > 0);
+
+ SECTION("move construction")
+ {
+ RegexMatchContext donor;
+ RegexMatchContext const moved{std::move(donor)};
+
+ REQUIRE(matches_through(&moved) == expected);
+ // A double free aborts when donor and moved are both destroyed, so
leaving this
+ // section is itself the second assertion.
+ }
+
+ SECTION("move assignment onto a destination that already holds a context")
+ {
+ // HONEST LABEL: on an ordinary build the assertions below do NOT detect a
dropped
+ // pcre2_match_context_free(old). Measured: removing that call leaves this
section
+ // passing, because a leak has no observer here. The oracle for the leak is
+ // LeakSanitizer on the ci-rocky lane, which is the only PR check
inheriting asan.
+ //
+ // The section still earns its place: it is what gives LSan an assignment
onto a
+ // live context to observe, which no other case in this file produces.
Without it
+ // the leak is unreachable and therefore undetectable anywhere.
+ RegexMatchContext destination;
+
+ REQUIRE(matches_through(&destination) == expected);
+
+ RegexMatchContext donor;
+
+ destination = std::move(donor);
+
+ REQUIRE(matches_through(&destination) == expected);
+ }
+
+ SECTION("self move assignment")
+ {
+ RegexMatchContext ctx;
+
+ REQUIRE(matches_through(&ctx) == expected);
+
+ // Without the this != &that guard this frees the context and then reads
the freed
+ // pointer back into itself, so the match below runs on freed memory.
+ //
+ // HONEST LABEL: measured, removing that guard leaves this section passing
on an
+ // ordinary build, because a use after free does not reliably fault.
AddressSanitizer
+ // on ci-rocky is the oracle. As above, the value here is producing the
access at all.
+ auto &alias = ctx;
+
+ ctx = std::move(alias);
+
+ REQUIRE(matches_through(&ctx) == expected);
+ }
+}
+
+// The guard from #5762: a pattern that backtracks once per character must fail
+// cleanly rather than run the thread out of stack. PCRE1 recursed on the
machine
+// stack and a long enough subject crashed the server; PCRE2 must report an
error
+// instead.
+TEST_CASE("Regex reports resource exhaustion rather than crashing",
"[libts][Regex][limits]")
+{
+ // Only the JIT path has a bound to exhaust here. PCRE2's interpreter keeps
its
+ // backtracking frames on the heap, so it matches this subject rather than
running
+ // out of anything, and there is no resource error to assert.
+ char const *const pattern =
R"(^/alpha/bravo/[?]((?!action=(newsfeed|calendar|contacts|notepad)).)*$)";
+ if (!pattern_has_jit(pattern)) {
+ SKIP("PCRE2 has no JIT for this pattern, so there is no stack bound to
exhaust");
+ }
+
+ Regex re;
+ REQUIRE(re.compile(pattern));
+
+ // This pattern starts failing at roughly 43KiB of subject against a 1MiB JIT
+ // stack, measured identically on x86_64 and arm64. Smaller JIT frames get
more
+ // subject out of the same stack and so push that threshold up; 256KiB keeps
a
+ // six times margin against that. Do not trim this to just above 43KiB.
+ std::string subject{"/alpha/bravo/?"};
+ subject.append(256 * 1024, 'x');
+
+ RegexMatches matches;
+ int const rc = re.exec(subject, matches);
+ CAPTURE(rc);
+
+ // Reaching this line at all is the crash assertion.
+ REQUIRE(rc < 0);
+ REQUIRE(rc != RE_ERROR_NOMATCH);
+
+ // The paired positive control, and the only part of this case that can tell
a
+ // 1MiB stack from PCRE2's 32KiB fallback: 256KiB overruns both bounds, so
every
+ // assertion above also holds with the JIT stack callback deleted. 10,000 is
seven
+ // times over the fallback's bound and four times under the 1MiB stack's.
Neither
+ // bound pins 1MiB itself; the suite only requires a maximum in roughly
+ // [240KB, 6MB].
+ std::string smaller{"/alpha/bravo/?"};
+ smaller.append(10000, 'x');
+
+ RegexMatches small_matches;
+ int const small_rc = re.exec(smaller, small_matches);
+ CAPTURE(small_rc);
+
+ REQUIRE(small_rc > 0);
+}
+
+namespace
+{
+/** A reusable barrier, so every round starts with all threads alive and
inside it. The
+ * matches of one round then start together on every thread. std::barrier
would say this
+ * directly; it is hand-rolled here to match notstd::barrier in
benchmark_LogObject.cc.
+ *
+ * Each completed round advances the generation, which is what releases the
waiters, so
+ * the barrier can be crossed any number of times. Exactly `expected` threads
must call
+ * arrive_and_wait() every round or the rest block forever, and the barrier
must outlive
+ * all of them, since destroying a condition_variable with waiters on it is
undefined.
+ */
+class RoundBarrier
+{
+public:
+ explicit RoundBarrier(int expected) : _expected{expected} { REQUIRE(expected
> 0); }
+
+ /// Count this thread in, then block until every expected thread has done
the same.
+ void
+ arrive_and_wait()
+ {
+ std::unique_lock<std::mutex> lock{_mutex};
+ auto const generation = _generation;
+
+ if (++_arrived == _expected) {
+ _arrived = 0;
+ ++_generation;
+ _cv.notify_all();
+ return;
+ }
+ _cv.wait(lock, [this, generation]() { return _generation != generation; });
+ }
+
+private:
+ std::mutex _mutex;
+ std::condition_variable _cv;
+ int const _expected;
+ int _arrived = 0;
+ unsigned _generation = 0;
+};
+
+/** A subject the deep pattern below backtracks through once per character.
+ *
+ * At 40 bytes of JIT stack per character the longest of these needs about
223KiB: six
+ * times what PCRE2's 32KiB fallback resolves, and under a quarter of the
shared
+ * context's 1MiB stack. Each thread gets its own length and its own content,
so no two
+ * threads are matching identical bytes.
+ */
+std::string
+stack_hungry_subject(int thread_index)
+{
+ std::string subject(5000 + thread_index * 100, 'a');
+
+ subject[11 + thread_index] = 'b';
+ return subject;
+}
+
+// One pass of the three matches every thread in the concurrency case
performs: a hit that
+// must yield the same three captures, a miss that must stay a miss, and a
deep subject that
+// only resolves on the 1MiB stack. Returns false on the first disagreement,
so a caller
+// counts rounds that went wrong rather than individual assertions. The case
asserts on zero
+// either way, so the change of denominator does not weaken it.
+bool
+concurrent_match_round(Regex const &re_captures, Regex const &re_deep,
std::string const &hit, std::string const &miss,
+ std::string const &deep, RegexMatchContext const *const
use)
+{
+ RegexMatches matches;
+ if (re_captures.exec(hit, matches, 0, use) != 4 || matches[1] != "alpha" ||
matches[2] != "42" || matches[3] != "tail") {
+ return false;
+ }
+
+ RegexMatches no_matches;
+ if (re_captures.exec(miss, no_matches, 0, use) != RE_ERROR_NOMATCH) {
+ return false;
+ }
+
+ // Needs the 1MiB stack, and must consume the whole subject to have used it.
+ RegexMatches deep_matches;
+ if (re_deep.exec(deep, deep_matches, 0, use) <= 0 || deep_matches[0].size()
!= deep.size()) {
+ return false;
+ }
+
+ return true;
+}
+
+} // namespace
+
+// The header promises that exec() may be called concurrently on one instance.
Every thread
+// must reach the same verdict, whether it matches through a caller-supplied
context shared
+// by several threads or through the thread-global one.
+//
+// The deep subjects are longer than PCRE2's 32KiB fallback stack can resolve,
so on a JIT
+// build this also fails if the callback stops handing every thread a 1MiB
stack: deleting
+// the callback, or returning null from it, turns every deep match here into
+// PCRE2_ERROR_JIT_STACKLIMIT. Without a JIT the interpreter resolves them
either way, so
+// only the concurrency half of this case carries over to such a build.
+//
+// Every round starts at a barrier, so all eight threads are alive for the
whole case and
+// each round's matches start together on every thread. The test is
deterministic when the
+// stacks are distinct, but it still does not prove that they are: one stack
shared by all
+// eight corrupts a result only when two deep matches touch the same frames at
once, which
+// the barrier invites but cannot force. Measured on arm64 macOS with the
callback returning
+// one shared stack, about 70 of 100 runs fail, with or without the barrier.
An oracle
+// would need a thread paused inside a match, which the public API does not
offer.
+// ThreadSanitizer does not see those writes either, because they come from
sljit code.
+TEST_CASE("Regex matches concurrently on one instance",
"[libts][Regex][threads]")
+{
+ Regex re_captures;
+ REQUIRE(re_captures.compile(R"(^/([a-z]+)/([0-9]+)/(.*)$)"));
+
+ Regex re_deep;
+ REQUIRE(re_deep.compile(R"(^(?:(a)|(b))+$)"));
+
+ constexpr int THREADS = 8;
+ constexpr int ITERATIONS = 2000;
Review Comment:
Measured on this branch (local arm64 macOS build), the test case takes 0.07
to 0.17 seconds across three runs, so I've left `ITERATIONS` at 2000. The count
matters for what the test can catch: as the comment above it says, a shared
stack corrupts a result only when two deep matches overlap, and fewer rounds
would make that less likely to show up.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]