Skip to content

Regex: fix four defects in the tsutil regex wrapper - #13671

Open
bryancall wants to merge 7 commits into
apache:masterfrom
bryancall:regex-defect-fixes
Open

Regex: fix four defects in the tsutil regex wrapper#13671
bryancall wants to merge 7 commits into
apache:masterfrom
bryancall:regex-defect-fixes

Conversation

@bryancall

Copy link
Copy Markdown
Contributor

Four defects in the tsutil regex wrapper, all present in shipped releases. Every commit
is a behaviour fix with no change to the public API surface, so the series backports as a
unit.

This supersedes #13661, whose two commits are the first of the four here. That PR bundled
these fixes with a source-breaking change (deleting RegexMatchContext's copy and move
members), which would have kept the whole thing off the release branches. The member
deletion is deferred to #13663, where the type is replaced outright.

The fixes

1. A caller-supplied RegexMatchContext ran on a 32 KiB JIT stack (#13660).
pcre2_match_context_create(nullptr) builds a context that configures nothing, so a
caller who wanted only a match limit silently gave up everything the shared context
provides, including its 1 MiB JIT stack. PCRE2 fell back to its own 32 KiB machine-stack
block, which resolves about 1,362 bytes of a subject that backtracks once per character; a
production regex_remap rule hit that bound at 1,377 bytes of query string. Copy the shared
context instead.

A shared context needs a per thread JIT stack, and a thread_local holding one registers
its destructor through __cxa_thread_atexit, which takes the dynamic loader lock. Doing
that from a match inverts lock order against a dlopen caller running a plugin's static
initialization; Diags::tag_activated documents that exact deadlock. The stack now comes
from a callback backed by a pthread key, whose destructor is registered once at key
creation. pthread_key_create failure is handled, because jit_stack_key is zero
initialized and key 0 can belong to another subsystem.

2. The pcre2 contexts are now process-wide. The general, compile and match contexts
were themselves in a thread_local with a destructor, so the first compile() or exec()
on a thread still took the loader lock. Nothing in them is per thread: they are built once
and never modified, which is pcre2api's stated condition for sharing a context across
threads. One instance, never destroyed, against a destructor that can run while another
thread is still matching.

3. A failed recompile left freed memory in the object. compile() freed the pattern it
already held before calling pcre2_compile(). Every failure path after that returned
with the freed pointer still stored, so empty() reported the object as compiled, exec()
passed the freed block to pcre2_match(), and the destructor freed it again. It now
compiles into a local and replaces the member only on success, so a failed compile leaves
the previous pattern usable.

4. A copied Regex silently changed engine. pcre2_code_copy() duplicates a compiled
pattern but not the machine code the JIT produced for it. The copy constructor called
nothing else, so every copied Regex matched on the interpreter: same answers, far slower,
and a different set of resource limits, so a pattern that reports a JIT stack limit through
the original quietly matched through a copy. plugins/experimental/maxmind_acl copies
every rule. The copy is now compiled for the JIT, which costs what a compile costs
(measured 7.5 ns to 2549 ns, 1 allocation to 6); nothing copies a Regex on a request path.

Tests

Each test fails against the code it fixes. Built against the unfixed implementation, the
recompile test segmentation faults and the copy test returns a match where the original
returns the stack-limit error.

New: two recompile sections, four copy sections, and an eight-thread concurrency test on a
single instance, half the threads through their own match context. The header has always
promised exec() is safe to call concurrently and nothing tested it.

Verification

Fedora 44, gcc 16.2.1, PCRE2 10.47:

  • Full build with experimental plugins, ctest 1058/1058.
  • [Regex] clean under ThreadSanitizer and under AddressSanitizer + UBSan (411 assertions, 22 cases).
  • regex_remap autest passes.
  • RegexContext::~RegexContext() and the thread-local ctx are both gone from the object
    file; what remains is a plain static behind a guard variable. The object still references
    __cxa_thread_atexit, but that belongs to the inline thread_local in
    tsutil/ts_bw_format.h and is present before and after.
  • No measurable change on any match or compile path (medians of three interleaved rounds,
    every delta inside a 5% run-to-run band). The harness is benchmark: measure Regex time and heap allocations #13670.

…ch context

Two problems with the same root: a caller-supplied RegexMatchContext was built
blank, and the JIT stack was held in a thread_local.

pcre2_match_context_create(nullptr) produces a context that configures nothing,
so a caller who wanted only to set a match limit silently gave up everything the
shared context provides, including its 1 MiB JIT stack. PCRE2 then fell back to
its own 32 KiB machine-stack block, which resolves about 1,362 bytes of a subject
that backtracks once per character. A production regex_remap rule hit that bound
at 1,377 bytes of query string. Copy the shared context instead, so a caller
overrides only what it means to override.

A shared context needs a per thread JIT stack, and a thread_local holding one
registers its destructor through __cxa_thread_atexit, which takes the dynamic
loader lock. Doing that from a match inverts lock order against a dlopen caller
running a plugin's static initialization; Diags::tag_activated documents that
exact deadlock. Take the stack from a callback backed by a pthread key instead,
whose destructor is registered once at key creation and never from the matching
path.

pthread_key_create can fail, and jit_stack_key is zero initialized, so key 0
could belong to another subsystem and hand its value to PCRE2 as a JIT stack.
Record whether the key was created and return null when it was not, which
pcre2jit documents as falling back to its own stack. If pthread_setspecific
fails, free the stack rather than leaking one per match.

Record why the maximum is one mebibyte, measured rather than assumed: the
maximum costs nothing per match at any size, and one mebibyte already resolves a
longer subject than request_header_max_size lets a client send.
Moving the JIT stack into a pthread key removed half of the thread_local
hazard. The other half remained: the general, compile and match contexts lived
in a thread_local with a destructor, and initializing one registers that
destructor through __cxa_thread_atexit, which takes the dynamic loader lock. The
first compile() or exec() on a thread therefore still inverted lock order
against a dlopen caller running a plugin's static initialization.

Nothing in those contexts is per thread. They are built once and never modified,
which pcre2api's MULTITHREADING section gives as the condition for sharing a
context between threads, and the one per thread object is resolved through the
callback. Allocate one instance and never destroy it: three small blocks that
live as long as the process, against a destructor that can run while another
thread is still matching. That also retires the "should only be null when
shutting down" check, which a function-local thread_local could never satisfy.

In the object file, RegexContext::~RegexContext() and the thread-local ctx are
both gone; what is left is a plain static pointer behind a guard variable, which
registers nothing at thread exit. The object still references
__cxa_thread_atexit, but that belongs to the inline thread_local in
tsutil/ts_bw_format.h and is there before and after this change.

The new test runs eight threads matching on one instance, half of them through
their own match context, and checks every thread reaches the same verdict. The
header promised this and nothing tested it. It is clean under ThreadSanitizer.
Regex::compile() freed the pattern it already held before calling
pcre2_compile(). Every failure path after that point returned with the freed
pointer still stored, so empty() reported the object as compiled, exec() passed
the freed block to pcre2_match(), and the destructor freed it a second time.

Compile into a local and replace the member only after the new pattern exists. A
failed compile now leaves the previous pattern in place and usable, which is what
a caller checking the return value would expect, and a fresh object that fails to
compile is still empty.

Two tests cover it: a valid compile followed by a failing one must leave the
first pattern matching, including its capture groups. Before this change the
first of those segmentation faults.
pcre2_code_copy() copies a compiled pattern but not the machine code the JIT
produced for it, because that code is position dependent. The copy constructor
called nothing else, so every copied Regex matched on the interpreter: the same
answers, far slower, and under a different set of resource limits. A pattern
that reports a JIT stack limit through the original quietly matched through a
copy, which is how the two disagree about whether a subject is acceptable at all.

Compile the copy for the JIT after copying it, exactly as compile() does for a
new pattern, and describe that in the header, which called it a deep copy.

The test asserts the property that matters: a copy answers the same as its
original on a subject sized past the JIT stack bound. Before this change the
original returned the stack limit error and the copy returned a match. It needs
no knowledge of whether the build has a JIT, because without one both sides
simply agree.
Copilot AI lite review requested due to automatic review settings September 12, 2026 19:27
@bryancall bryancall added Core Bug AuTest Threads Backport Marked for backport for an LTS patch release labels Sep 12, 2026
@bryancall bryancall self-assigned this Sep 12, 2026
@bryancall bryancall added this to the 11.0.0 milestone Sep 12, 2026

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

The concurrency regression test does not yet exercise a shared RegexMatchContext across threads.

Pull request overview

Fixes four shipped defects in the tsutil::Regex PCRE2 wrapper.

Changes:

  • Adds process-wide contexts with per-thread JIT stacks.
  • Makes recompilation transactional and copied regexes JIT-aware.
  • Expands regression, concurrency, and integration coverage.

Review note: Moderate — the concurrency test should share one RegexMatchContext among own_context workers to exercise the production callback path. (1 vote.)

File summaries
File Description
tests/gold_tests/pluginTest/regex_remap/regex_remap.test.py Expands long-query integration coverage.
src/tsutil/unit_tests/test_Regex.cc Adds regression and concurrency tests.
src/tsutil/Regex.cc Implements context, JIT-stack, recompilation, and copy fixes.
include/tsutil/Regex.h Documents corrected behavior.
Review details

Suppressed comments (1)

src/tsutil/unit_tests/test_Regex.cc:1207

  • These contexts are constructed inside each worker, so no RegexMatchContext is ever passed concurrently by multiple threads. That misses the production case (plugins/regex_remap/regex_remap.cc:810) and would still pass if a copied context held one direct JIT stack; share one context among the own_context threads so this test exercises the callback's per-thread stack guarantee.
      RegexMatchContext              context;
      RegexMatchContext const *const use = own_context ? &context : nullptr;
  • Files reviewed: 4/4 changed files
  • Comments generated: 0
  • Review effort level: Lite

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

The CentOS build runs devtoolset-10 and the Ubuntu build a clang of similar
vintage, and libstdc++ did not ship <latch> until 11, so both failed to compile
the new test with "latch: No such file or directory". Nothing else in the change
reaches past C++17 in the library.

Use a mutex and condition variable for the start gate instead. It keeps the
property the latch was there for, that every thread is inside the match loop
before any of them gets far, so the matching actually overlaps rather than
running one thread at a time.
Copilot AI review requested due to automatic review settings September 12, 2026 20:16

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

A critical copy-failure handling issue remains unresolved.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

src/tsutil/unit_tests/test_Regex.cc:1215

  • This test constructs each RegexMatchContext inside the worker that uses it, so it never exercises a caller-supplied context created on one thread and shared by other threads. That is the production shape (regex_remap.cc:810/1060); an implementation that cached a direct per-thread stack in the context would pass this test even though the plugin would be unsafe. Construct one context before launching the workers and pass that same pointer to the caller-context half to verify the callback and cross-thread stack isolation.
      bool const                     own_context = (i % 2) == 0;
      RegexMatchContext              context;
      RegexMatchContext const *const use = own_context ? &context : nullptr;
  • Files reviewed: 4/4 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread src/tsutil/Regex.cc Outdated
…est threads

pcre2_code_copy() returns null when it cannot obtain memory, and the copy
constructor passed that straight to pcre2_jit_compile(). Check it, and leave the
object empty when the copy fails: that is the state a default constructed Regex
is in and the state empty() reports, rather than a Regex holding a null pattern.

The concurrency test built a RegexMatchContext inside each worker, so it never
covered the shape the plugins actually use, where one context is created at
configuration load and every net thread matches through it. An implementation
that cached a JIT stack in the context rather than resolving one per thread
through the callback would have passed the old test and corrupted the real
thing. Build one context before the workers start and hand the same pointer to
every thread that uses one.
Copilot AI review requested due to automatic review settings September 12, 2026 20:37

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

The copied regex’s JIT compilation result must be checked to prevent silent interpreter fallback.

Review details

Suppressed comments (2)

src/tsutil/Regex.cc:403

  • pcre2_code_copy() never carries the original JIT code, so this call is required to preserve the original engine. However, its return value is ignored: if JIT compilation fails (for example because executable-memory allocation fails), the copied code is still published and silently falls back to the interpreter, recreating the behavior this change is meant to eliminate. Check the result and handle the failure based on whether the source had JIT code before assigning _code.
      pcre2_jit_compile(copied_code, PCRE2_JIT_COMPLETE);

src/tsutil/unit_tests/test_Regex.cc:1219

  • own_context is true when the thread uses the one shared shared_caller_context, and false when it uses the default context, so the predicate name describes the opposite of the branch it controls. Rename it to reflect that it selects the caller-supplied context; this test is specifically meant to document the shared-context case.
      bool const                     own_context = (i % 2) == 0;
      RegexMatchContext const *const use         = own_context ? &shared_caller_context : nullptr;
  • Files reviewed: 4/4 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@bryancall bryancall closed this Sep 12, 2026
@bryancall bryancall reopened this Sep 12, 2026
own_context was true for the threads matching through the one shared,
caller-supplied context, which is the opposite of what the name says, and the
shared case is the whole point of the test. Call it use_caller_context.

Also record why the pcre2_jit_compile() result on a copy is not checked. A
pattern the JIT declines still matches correctly on the interpreter, this class
has no way to tell a caller which engine it got, and "no JIT" is not a single
error code across PCRE2 versions and build options. compile() has the same
property. Reporting the engine belongs to the replacement API rather than here.
Copilot AI review requested due to automatic review settings September 12, 2026 20:51

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

The changes affect shared context lifetimes, thread concurrency, and regex memory management, warranting final human review.

Review details
  • Files reviewed: 4/4 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AuTest Backport Marked for backport for an LTS patch release Bug Core Threads

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants