Skip to content

Work around PCRE2 10.46/10.47 JIT start-optimization losing matches - #111

Merged
Qubitium merged 1 commit into
mainfrom
fix/jit-start-optimize-workaround
Aug 24, 2026
Merged

Work around PCRE2 10.46/10.47 JIT start-optimization losing matches#111
Qubitium merged 1 commit into
mainfrom
fix/jit-start-optimize-workaround

Conversation

@Qubitium

Copy link
Copy Markdown
Contributor

Summary

Works around the PCRE2 10.46/10.47 JIT start-optimization bug found by the verifying clobber suite (#110): the JIT fast-forward scan skips valid start positions for patterns beginning with an optional atom + literal + bounded class — pcre2_jit_match finds nothing for 0?a[b ]{0,2} on " ab " while pcre2_match returns (1, 4). Since pypcre enables JIT by default, affected patterns silently returned wrong results.

Affected-version matrix (verified with raw libpcre2-8 builds via ctypes, pypcre not involved):

PCRE2 JIT vs interpreter on the repro family
10.46 (system) diverges (JIT loses matches)
10.47 release (built from tarball) still diverges — all repros
current main (10.48-dev, built with its pinned sljit) fixed

So every currently shipping release is affected; only unreleased main is fixed.

The fix

Follows the existing jit_anchor_fixup_needed() precedent exactly:

  • One-time runtime probe (jit_start_optimize_broken(), run eagerly at module init): compile 0?a[b ]{0,2}, run pcre2_jit_match vs pcre2_match(PCRE2_NO_JIT) on the same compiled code against " ab ", compare results. _jit_start_optimize_broken() is exposed for introspection/tests.
  • Targeted mitigation: on a probe-positive runtime only, Pattern_create injects PCRE2_NO_START_OPTIMIZE into the compile options of JIT-bound patterns. The option bit exists on every PCRE2 version (unlike pcre2_set_optimize, added in 10.43), so there is no new symbol dependency and old runtimes still import.
  • No flag pollution: the injected bit is masked back out of Pattern.flags (it shares the value 0x10000 with Flag.SUBSTITUTE_MATCHED on the Python side); an explicitly caller-requested NO_START_OPTIMIZE is preserved as before.
  • Fixed runtimes probe clean and compile exactly as before — zero cost, no behavior change.

Perf note: on broken runtimes, JIT-bound patterns lose PCRE2's first-code-unit fast-forward (pypcre's own literal prescan is also disabled for them since PCRE2_INFO_FIRSTCODEUNIT is no longer populated). Correctness over speed, and only where the runtime is broken.

Validation

  • Empirical completeness: the clobber suite (Add verifying clobber suite: accuracy/leak/crash fuzzing on all cores - 1 #110) re-run on the affected 10.46 runtime with PYPCRE_CLOBBER_STRICT_JIT=1 (any JIT-vs-interpreter divergence fails the test): 3,549,787 differential cases, zero divergences — previously ~10 divergences per minute. The workaround covers the whole observed bug family, not just the probe pattern.
  • New tests/test_jit_start_optimize_workaround.py: pins six known-diverging pattern/subject pairs against re under default (JIT) compilation — these assertions hold on both broken runtimes (via the workaround) and fixed runtimes (natively) — and asserts the flags contract.
  • Full pytest suite green on CPython 3.14.7 free-threaded (GIL=0 at runtime) and 3.14.7 GIL builds against system PCRE2 10.46.

Worth reporting upstream to PCRE2Project alongside the two still-unfixed engine behaviors documented in #110 (auto-possessification changing results around explicit possessives; {0,0} zero-repetition alternation groups matching nothing) — bug 1 needs a backport request since the fix only exists on main.

🤖 Generated with Claude Code

The JIT fast-forward (start-of-match optimization) in PCRE2 10.46 and
10.47 skips valid start positions for patterns that begin with an
optional atom followed by a literal and a bounded class:
pcre2_jit_match finds nothing for 0?a[b ]{0,2} on " ab " while
pcre2_match returns (1, 4).  Verified against raw libpcre2 builds of the
10.46 and 10.47 releases (both affected) and current upstream main
(10.48-dev, fixed).  Since JIT is enabled by default, affected patterns
silently returned wrong results on every currently shipping PCRE2.

Following the jit_anchor_fixup_needed() precedent, the runtime is probed
once at module init (jit_start_optimize_broken): compile the probe
pattern, run pcre2_jit_match vs pcre2_match(PCRE2_NO_JIT) on the same
code, and compare.  On a broken runtime, Pattern_create injects
PCRE2_NO_START_OPTIMIZE into the compile options of JIT-bound patterns
— an option bit available on every PCRE2 version, so no new symbol
dependency — and masks it back out of the pattern's public flags (the
bit shares a value with Flag.SUBSTITUTE_MATCHED on the Python side; a
caller-requested NO_START_OPTIMIZE is preserved).  Fixed runtimes probe
clean and compile exactly as before.

Validation on the affected 10.46 system runtime: the verifying clobber
suite (PR #110) ran 3.55M differential cases with
PYPCRE_CLOBBER_STRICT_JIT=1 (JIT-vs-interpreter divergence = failure)
and found ZERO divergences, versus roughly ten per minute before the
workaround.  New tests/test_jit_start_optimize_workaround.py pins the
known repro family (must pass on both broken and fixed runtimes) and
asserts the injected bit does not leak into Pattern.flags.  Full pytest
suite green on CPython 3.14.7 free-threaded (GIL=0) and GIL builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Qubitium
Qubitium merged commit ab7ce5d into main Aug 24, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant