Fix regex compiler/source generator resumeAt handling of conditionals inside loops - #126561

Merged
stephentoub merged 3 commits into
mainfrom
stoub/fix126556
Apr 8, 2026
Merged

Fix regex compiler/source generator resumeAt handling of conditionals inside loops#126561
stephentoub merged 3 commits into
mainfrom
stoub/fix126556

Conversation

@stephentoub

Copy link
Copy Markdown
Member

Update EmitExpressionConditional to reset resumeAt when inside loops, preventing stale values and incorrect matches.

Fixes#126556

… inside loops
Update EmitExpressionConditional to reset resumeAt when inside loops, preventing stale values and incorrect matches.
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-system-text-regularexpressions
See info in area-owners.md if you want to be subscribed.

@github-actions

This comment has been minimized.

CopilotAI 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.

Pull request overview

Fixes a compiled/source-generated regex correctness bug where EmitExpressionConditional could push a stale resumeAt value when the conditional is inside a loop, leading to incorrect multiple-match results and (per linked issue) possible exceptions.

Changes:

  • Update EmitExpressionConditional in both the JIT compiler (RegexCompiler) and the source generator emitter to always set resumeAt when the conditional is within a loop.
  • Add functional tests covering expression conditionals with balancing groups and alternation inside loop constructs to prevent regressions.
Show a summary per file
FileDescription
src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/Regex.MultipleMatches.Tests.csAdds regression test cases for expression-conditionals with balancing groups/alternation inside quantified loops across engines (excluding NonBacktracking).
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexCompiler.csEnsures resumeAt is reset for each branch when the conditional is in a loop, preventing stale branch state from being pushed.
src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.csMirrors the RegexCompiler fix for source-generated regex code emission.

Copilot's findings

  • Files reviewed: 3/3 changed files
  • Comments generated: 0

@stephentoub
stephentoub enabled auto-merge (squash) April 6, 2026 00:26
@danmoseley

Copy link
Copy Markdown
Contributor

I set a copilot to in background look for missing tests and simpler tests.

Add 4 more test cases covering:
- Auto-numbered capture groups with dot and literal patterns
- Alternation in no-branch with empty second branch
- Quantified balancing group pop {2}
Move all conditional/balancing group tests outside #if !NETFRAMEWORK
since this bug is specific to the .NET Core regex compiler rewrite
and these patterns work correctly on .NET Framework.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

@danmoseleydanmoseley 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.

Added more tests. LGTM

@github-actions

This comment has been minimized.

CopilotAI review requested due to automatic review settings April 8, 2026 13:06

CopilotAI 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.

Pull request overview

This PR fixes a bug in the regex compiler and source generator where EmitExpressionConditional could reuse a stale resumeAt value when the conditional occurs inside a loop, leading to incorrect matches (and exceptions) compared to the interpreter.

Changes:

  • Update RegexCompiler.EmitExpressionConditional to always set resumeAt for each executed branch when the conditional is inside a loop.
  • Apply the same resumeAt handling fix in the source generator emitter.
  • Add regression coverage for balancing-group conditionals inside loops in the multiple-matches functional tests.
Show a summary per file
FileDescription
src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/Regex.MultipleMatches.Tests.csAdds test cases covering expression conditionals with balancing groups inside loops across engines (excluding non-backtracking).
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexCompiler.csEnsures resumeAt is reset/set when inside loops to prevent stale branch selection during backtracking.
src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.csMirrors the compiler fix in generated code so source-generated regex behaves consistently.

Copilot's findings

  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new

@github-actions

Copy link
Copy Markdown
Contributor

Note

This review was generated by Copilot.

Regex Conditional-in-Loop Fix — Code Review

✅ (1) Fix is correct and complete — all three resumeAt sites are patched symmetrically

The three changed conditions in EmitExpressionConditional (resumeAt = 0, 1, 2) now match the pattern already established in EmitBackreferenceConditional.

SiteEmitBackreferenceConditional (reference)EmitExpressionConditional (after fix)
resumeAt=0(!isAtomic && post != orig) || isInLoop (RegexCompiler.cs:2333)(!isAtomic && post != orig) || isInLoop (RegexCompiler.cs:2532) ✅
resumeAt=1same (RegexCompiler.cs:2356)same (RegexCompiler.cs:2563) ✅
resumeAt=2same (RegexCompiler.cs:2368)same (RegexCompiler.cs:2575) ✅

Both RegexCompiler.cs and RegexGenerator.Emitter.cs are updated in lockstep. ✅

No other sites in EmitExpressionConditional need the isInLoop check. The gate condition at line 2585/2577:

if(isAtomic||(postYesDoneLabel==originalDoneLabel&&postNoDoneLabel==originalDoneLabel))
```
correctlyskips the backtracking section when neither branch backtracks — in that case there is no switch on `resumeAt`,so no stale value can cause harm,regardless of loop status. The stack push/pop at lines 26022640(compiler)/25942620(emitter)isalreadyinside the `else` block and gatedon `isInLoop`.---
### ✅ (2) `isInLoop` true while both post-labels equal `originalDoneLabel` — harmless dead code
This can happen when the conditional is inside a loop but neither branch introduces backtracking. In that case:- The `|| isInLoop` causes `resumeAt` assignments to execute (dead stores).- The gate condition takes the **if**-branch(line 2585), so no backtracking section, no stack push/pop, and no switch on `resumeAt`.-Since `isInLoop` is only setwhen `!isAtomic` (lines 24802484), and `resumeAt` isonlydeclaredwhen `!isAtomic`, there's no risk of writing to an uninitialized local or undeclared variable.
Net effect: a harmless extra store that never gets read. Same patternas `EmitBackreferenceConditional`.---
### ⚠️ (3) Tests are good for resumeAt=0 and resumeAt=1, but miss the resumeAt=2(pass-through,nono-branch) case
Everynew test pattern uses `(?(condition)|no-branch)+` — the yes branch is always empty and a no-branchisalwayspresent.This means:-**resumeAt=0**(yesbranch): exercised via `|| isInLoop` because the empty yes branch doesn't change `doneLabel` (`postYesDoneLabel==originalDoneLabel`),so the old condition would have been false.Good.-**resumeAt=1**(nobranch): exercised both ways — some tests have a backtracking no-branch(e.g., `(.)+`),and `(?((?'-1'))|(?'1'a))+` has a non-backtrackingno-branch where only `|| isInLoop` triggers the assignment.-**resumeAt=2**(pass-through,nono-branch):**NOT tested.**This requires a pattern like `(?(condition)yes-branch)+` (no `|`)where the yes branch backtracks,insidea loop. The fix forthiscaseis structurally identical to the other two,buta regression test would harden it. Consider adding e.g.:
```
@"(?((?'-1'))(?'1'.)+)+(?!(?'-1'))"
```
---
### ✅ (4) NETFRAMEWORK guard/comment change is correct
The new tests are placed **before** the `#if !NETFRAMEWORK` block, so they run on all target frameworks.Thisisappropriate — the tests exercise balancing groups via `IsNonBacktracking` exclusion,nota framework-specificAPI.
The comment simplification from:
```
// these tests currently fail on .NET Framework, and we need to check IsDynamicCodeCompiled but that doesn't exist on .NET Framework
```
to:
```
// these tests currently fail on .NET Framework

is a benign editorial cleanup — the removed clause was about the tests below the guard, not the new ones. ✅

Generated by Code Review for issue #126561 ·

@danmoseley

Copy link
Copy Markdown
Contributor

/ba-g unrelated

@stephentoub
stephentoub merged commit 4bd0bf4 into mainApr 8, 2026
87 of 98 checks passed
@stephentoub
stephentoub deleted the stoub/fix126556 branch April 8, 2026 19:05
danmoseley added a commit that referenced this pull request Apr 9, 2026
…#126657)
## Description
The Code Review workflow on PR #126561 identified that the new tests
cover `resumeAt=0` (yes-branch) and `resumeAt=1` (no-branch) paths but
miss the `resumeAt=2` pass-through path — expression conditional with
only a yes-branch (no no-branch) inside a loop.
Adds 3 test cases to `Regex.MultipleMatches.Tests.cs`:
- **Yes-only conditional, condition always fails** —
`(?((?'-1'))(?'1'.)+)+(?!(?'-1'))` with `"abc"` exercises the
pass-through path producing empty matches at each position (guarded with
`#if !NETFRAMEWORK` since .NET Framework produces 3 empty matches vs 4
on .NET Core due to different empty-match-at-end-of-string semantics)
- **Yes-only conditional with prefix capture, even input** —
`((?'1'.)(?((?'-1'))(?'1'.)))+` with `"abcd"` exercises the
yes-branch-taken path inside a loop
- **Yes-only conditional with prefix capture, odd input** — same pattern
with `"abc"` verifying partial match behavior
The latter two test cases are the primary coverage for the `|| isInLoop`
fix, since their non-backtracking yes-branch means `isInLoop` is the
necessary trigger for `resumeAt = 2` (the old condition
`postYesDoneLabel != originalDoneLabel` would have been false). These
run on both .NET Core and .NET Framework.
All 32,100 existing tests continue to pass.
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: danmoseley <6385855+danmoseley@users.noreply.github.com>
Co-authored-by: Dan Moseley <danmose@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actionsgithub-actionsBot locked and limited conversation to collaborators May 9, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Compiled conditional regex match different with Interpretor, and it throws exception

3 participants

@stephentoub@danmoseley
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Fix regex compiler/source generator resumeAt handling of conditionals inside loops - #126561

Merged
stephentoub merged 3 commits into
mainfrom
stoub/fix126556
Apr 8, 2026
Merged

Fix regex compiler/source generator resumeAt handling of conditionals inside loops#126561
stephentoub merged 3 commits into
mainfrom
stoub/fix126556

Conversation

@stephentoub

Copy link
Copy Markdown
Member

Update EmitExpressionConditional to reset resumeAt when inside loops, preventing stale values and incorrect matches.

Fixes#126556

… inside loops
Update EmitExpressionConditional to reset resumeAt when inside loops, preventing stale values and incorrect matches.
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-system-text-regularexpressions
See info in area-owners.md if you want to be subscribed.

@github-actions

This comment has been minimized.

CopilotAI 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.

Pull request overview

Fixes a compiled/source-generated regex correctness bug where EmitExpressionConditional could push a stale resumeAt value when the conditional is inside a loop, leading to incorrect multiple-match results and (per linked issue) possible exceptions.

Changes:

  • Update EmitExpressionConditional in both the JIT compiler (RegexCompiler) and the source generator emitter to always set resumeAt when the conditional is within a loop.
  • Add functional tests covering expression conditionals with balancing groups and alternation inside loop constructs to prevent regressions.
Show a summary per file
FileDescription
src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/Regex.MultipleMatches.Tests.csAdds regression test cases for expression-conditionals with balancing groups/alternation inside quantified loops across engines (excluding NonBacktracking).
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexCompiler.csEnsures resumeAt is reset for each branch when the conditional is in a loop, preventing stale branch state from being pushed.
src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.csMirrors the RegexCompiler fix for source-generated regex code emission.

Copilot's findings

  • Files reviewed: 3/3 changed files
  • Comments generated: 0

@stephentoub
stephentoub enabled auto-merge (squash) April 6, 2026 00:26
@danmoseley

Copy link
Copy Markdown
Contributor

I set a copilot to in background look for missing tests and simpler tests.

Add 4 more test cases covering:
- Auto-numbered capture groups with dot and literal patterns
- Alternation in no-branch with empty second branch
- Quantified balancing group pop {2}
Move all conditional/balancing group tests outside #if !NETFRAMEWORK
since this bug is specific to the .NET Core regex compiler rewrite
and these patterns work correctly on .NET Framework.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

@danmoseleydanmoseley 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.

Added more tests. LGTM

@github-actions

This comment has been minimized.

CopilotAI review requested due to automatic review settings April 8, 2026 13:06

CopilotAI 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.

Pull request overview

This PR fixes a bug in the regex compiler and source generator where EmitExpressionConditional could reuse a stale resumeAt value when the conditional occurs inside a loop, leading to incorrect matches (and exceptions) compared to the interpreter.

Changes:

  • Update RegexCompiler.EmitExpressionConditional to always set resumeAt for each executed branch when the conditional is inside a loop.
  • Apply the same resumeAt handling fix in the source generator emitter.
  • Add regression coverage for balancing-group conditionals inside loops in the multiple-matches functional tests.
Show a summary per file
FileDescription
src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/Regex.MultipleMatches.Tests.csAdds test cases covering expression conditionals with balancing groups inside loops across engines (excluding non-backtracking).
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexCompiler.csEnsures resumeAt is reset/set when inside loops to prevent stale branch selection during backtracking.
src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.csMirrors the compiler fix in generated code so source-generated regex behaves consistently.

Copilot's findings

  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new

@github-actions

Copy link
Copy Markdown
Contributor

Note

This review was generated by Copilot.

Regex Conditional-in-Loop Fix — Code Review

✅ (1) Fix is correct and complete — all three resumeAt sites are patched symmetrically

The three changed conditions in EmitExpressionConditional (resumeAt = 0, 1, 2) now match the pattern already established in EmitBackreferenceConditional.

SiteEmitBackreferenceConditional (reference)EmitExpressionConditional (after fix)
resumeAt=0(!isAtomic && post != orig) || isInLoop (RegexCompiler.cs:2333)(!isAtomic && post != orig) || isInLoop (RegexCompiler.cs:2532) ✅
resumeAt=1same (RegexCompiler.cs:2356)same (RegexCompiler.cs:2563) ✅
resumeAt=2same (RegexCompiler.cs:2368)same (RegexCompiler.cs:2575) ✅

Both RegexCompiler.cs and RegexGenerator.Emitter.cs are updated in lockstep. ✅

No other sites in EmitExpressionConditional need the isInLoop check. The gate condition at line 2585/2577:

if(isAtomic||(postYesDoneLabel==originalDoneLabel&&postNoDoneLabel==originalDoneLabel))
```
correctlyskips the backtracking section when neither branch backtracks — in that case there is no switch on `resumeAt`,so no stale value can cause harm,regardless of loop status. The stack push/pop at lines 26022640(compiler)/25942620(emitter)isalreadyinside the `else` block and gatedon `isInLoop`.---
### ✅ (2) `isInLoop` true while both post-labels equal `originalDoneLabel` — harmless dead code
This can happen when the conditional is inside a loop but neither branch introduces backtracking. In that case:- The `|| isInLoop` causes `resumeAt` assignments to execute (dead stores).- The gate condition takes the **if**-branch(line 2585), so no backtracking section, no stack push/pop, and no switch on `resumeAt`.-Since `isInLoop` is only setwhen `!isAtomic` (lines 24802484), and `resumeAt` isonlydeclaredwhen `!isAtomic`, there's no risk of writing to an uninitialized local or undeclared variable.
Net effect: a harmless extra store that never gets read. Same patternas `EmitBackreferenceConditional`.---
### ⚠️ (3) Tests are good for resumeAt=0 and resumeAt=1, but miss the resumeAt=2(pass-through,nono-branch) case
Everynew test pattern uses `(?(condition)|no-branch)+` — the yes branch is always empty and a no-branchisalwayspresent.This means:-**resumeAt=0**(yesbranch): exercised via `|| isInLoop` because the empty yes branch doesn't change `doneLabel` (`postYesDoneLabel==originalDoneLabel`),so the old condition would have been false.Good.-**resumeAt=1**(nobranch): exercised both ways — some tests have a backtracking no-branch(e.g., `(.)+`),and `(?((?'-1'))|(?'1'a))+` has a non-backtrackingno-branch where only `|| isInLoop` triggers the assignment.-**resumeAt=2**(pass-through,nono-branch):**NOT tested.**This requires a pattern like `(?(condition)yes-branch)+` (no `|`)where the yes branch backtracks,insidea loop. The fix forthiscaseis structurally identical to the other two,buta regression test would harden it. Consider adding e.g.:
```
@"(?((?'-1'))(?'1'.)+)+(?!(?'-1'))"
```
---
### ✅ (4) NETFRAMEWORK guard/comment change is correct
The new tests are placed **before** the `#if !NETFRAMEWORK` block, so they run on all target frameworks.Thisisappropriate — the tests exercise balancing groups via `IsNonBacktracking` exclusion,nota framework-specificAPI.
The comment simplification from:
```
// these tests currently fail on .NET Framework, and we need to check IsDynamicCodeCompiled but that doesn't exist on .NET Framework
```
to:
```
// these tests currently fail on .NET Framework

is a benign editorial cleanup — the removed clause was about the tests below the guard, not the new ones. ✅

Generated by Code Review for issue #126561 ·

@danmoseley

Copy link
Copy Markdown
Contributor

/ba-g unrelated

@stephentoub
stephentoub merged commit 4bd0bf4 into mainApr 8, 2026
87 of 98 checks passed
@stephentoub
stephentoub deleted the stoub/fix126556 branch April 8, 2026 19:05
danmoseley added a commit that referenced this pull request Apr 9, 2026
…#126657)
## Description
The Code Review workflow on PR #126561 identified that the new tests
cover `resumeAt=0` (yes-branch) and `resumeAt=1` (no-branch) paths but
miss the `resumeAt=2` pass-through path — expression conditional with
only a yes-branch (no no-branch) inside a loop.
Adds 3 test cases to `Regex.MultipleMatches.Tests.cs`:
- **Yes-only conditional, condition always fails** —
`(?((?'-1'))(?'1'.)+)+(?!(?'-1'))` with `"abc"` exercises the
pass-through path producing empty matches at each position (guarded with
`#if !NETFRAMEWORK` since .NET Framework produces 3 empty matches vs 4
on .NET Core due to different empty-match-at-end-of-string semantics)
- **Yes-only conditional with prefix capture, even input** —
`((?'1'.)(?((?'-1'))(?'1'.)))+` with `"abcd"` exercises the
yes-branch-taken path inside a loop
- **Yes-only conditional with prefix capture, odd input** — same pattern
with `"abc"` verifying partial match behavior
The latter two test cases are the primary coverage for the `|| isInLoop`
fix, since their non-backtracking yes-branch means `isInLoop` is the
necessary trigger for `resumeAt = 2` (the old condition
`postYesDoneLabel != originalDoneLabel` would have been false). These
run on both .NET Core and .NET Framework.
All 32,100 existing tests continue to pass.
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: danmoseley <6385855+danmoseley@users.noreply.github.com>
Co-authored-by: Dan Moseley <danmose@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actionsgithub-actionsBot locked and limited conversation to collaborators May 9, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Compiled conditional regex match different with Interpretor, and it throws exception

3 participants

@stephentoub@danmoseley
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Fix regex compiler/source generator resumeAt handling of conditionals inside loops - #126561

Merged
stephentoub merged 3 commits into
mainfrom
stoub/fix126556
Apr 8, 2026
Merged

Fix regex compiler/source generator resumeAt handling of conditionals inside loops#126561
stephentoub merged 3 commits into
mainfrom
stoub/fix126556

Conversation

@stephentoub

Copy link
Copy Markdown
Member

Update EmitExpressionConditional to reset resumeAt when inside loops, preventing stale values and incorrect matches.

Fixes#126556

… inside loops
Update EmitExpressionConditional to reset resumeAt when inside loops, preventing stale values and incorrect matches.
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-system-text-regularexpressions
See info in area-owners.md if you want to be subscribed.

@github-actions

This comment has been minimized.

CopilotAI 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.

Pull request overview

Fixes a compiled/source-generated regex correctness bug where EmitExpressionConditional could push a stale resumeAt value when the conditional is inside a loop, leading to incorrect multiple-match results and (per linked issue) possible exceptions.

Changes:

  • Update EmitExpressionConditional in both the JIT compiler (RegexCompiler) and the source generator emitter to always set resumeAt when the conditional is within a loop.
  • Add functional tests covering expression conditionals with balancing groups and alternation inside loop constructs to prevent regressions.
Show a summary per file
FileDescription
src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/Regex.MultipleMatches.Tests.csAdds regression test cases for expression-conditionals with balancing groups/alternation inside quantified loops across engines (excluding NonBacktracking).
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexCompiler.csEnsures resumeAt is reset for each branch when the conditional is in a loop, preventing stale branch state from being pushed.
src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.csMirrors the RegexCompiler fix for source-generated regex code emission.

Copilot's findings

  • Files reviewed: 3/3 changed files
  • Comments generated: 0

@stephentoub
stephentoub enabled auto-merge (squash) April 6, 2026 00:26
@danmoseley

Copy link
Copy Markdown
Contributor

I set a copilot to in background look for missing tests and simpler tests.

Add 4 more test cases covering:
- Auto-numbered capture groups with dot and literal patterns
- Alternation in no-branch with empty second branch
- Quantified balancing group pop {2}
Move all conditional/balancing group tests outside #if !NETFRAMEWORK
since this bug is specific to the .NET Core regex compiler rewrite
and these patterns work correctly on .NET Framework.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

@danmoseleydanmoseley 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.

Added more tests. LGTM

@github-actions

This comment has been minimized.

CopilotAI review requested due to automatic review settings April 8, 2026 13:06

CopilotAI 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.

Pull request overview

This PR fixes a bug in the regex compiler and source generator where EmitExpressionConditional could reuse a stale resumeAt value when the conditional occurs inside a loop, leading to incorrect matches (and exceptions) compared to the interpreter.

Changes:

  • Update RegexCompiler.EmitExpressionConditional to always set resumeAt for each executed branch when the conditional is inside a loop.
  • Apply the same resumeAt handling fix in the source generator emitter.
  • Add regression coverage for balancing-group conditionals inside loops in the multiple-matches functional tests.
Show a summary per file
FileDescription
src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/Regex.MultipleMatches.Tests.csAdds test cases covering expression conditionals with balancing groups inside loops across engines (excluding non-backtracking).
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexCompiler.csEnsures resumeAt is reset/set when inside loops to prevent stale branch selection during backtracking.
src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.csMirrors the compiler fix in generated code so source-generated regex behaves consistently.

Copilot's findings

  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new

@github-actions

Copy link
Copy Markdown
Contributor

Note

This review was generated by Copilot.

Regex Conditional-in-Loop Fix — Code Review

✅ (1) Fix is correct and complete — all three resumeAt sites are patched symmetrically

The three changed conditions in EmitExpressionConditional (resumeAt = 0, 1, 2) now match the pattern already established in EmitBackreferenceConditional.

SiteEmitBackreferenceConditional (reference)EmitExpressionConditional (after fix)
resumeAt=0(!isAtomic && post != orig) || isInLoop (RegexCompiler.cs:2333)(!isAtomic && post != orig) || isInLoop (RegexCompiler.cs:2532) ✅
resumeAt=1same (RegexCompiler.cs:2356)same (RegexCompiler.cs:2563) ✅
resumeAt=2same (RegexCompiler.cs:2368)same (RegexCompiler.cs:2575) ✅

Both RegexCompiler.cs and RegexGenerator.Emitter.cs are updated in lockstep. ✅

No other sites in EmitExpressionConditional need the isInLoop check. The gate condition at line 2585/2577:

if(isAtomic||(postYesDoneLabel==originalDoneLabel&&postNoDoneLabel==originalDoneLabel))
```
correctlyskips the backtracking section when neither branch backtracks — in that case there is no switch on `resumeAt`,so no stale value can cause harm,regardless of loop status. The stack push/pop at lines 26022640(compiler)/25942620(emitter)isalreadyinside the `else` block and gatedon `isInLoop`.---
### ✅ (2) `isInLoop` true while both post-labels equal `originalDoneLabel` — harmless dead code
This can happen when the conditional is inside a loop but neither branch introduces backtracking. In that case:- The `|| isInLoop` causes `resumeAt` assignments to execute (dead stores).- The gate condition takes the **if**-branch(line 2585), so no backtracking section, no stack push/pop, and no switch on `resumeAt`.-Since `isInLoop` is only setwhen `!isAtomic` (lines 24802484), and `resumeAt` isonlydeclaredwhen `!isAtomic`, there's no risk of writing to an uninitialized local or undeclared variable.
Net effect: a harmless extra store that never gets read. Same patternas `EmitBackreferenceConditional`.---
### ⚠️ (3) Tests are good for resumeAt=0 and resumeAt=1, but miss the resumeAt=2(pass-through,nono-branch) case
Everynew test pattern uses `(?(condition)|no-branch)+` — the yes branch is always empty and a no-branchisalwayspresent.This means:-**resumeAt=0**(yesbranch): exercised via `|| isInLoop` because the empty yes branch doesn't change `doneLabel` (`postYesDoneLabel==originalDoneLabel`),so the old condition would have been false.Good.-**resumeAt=1**(nobranch): exercised both ways — some tests have a backtracking no-branch(e.g., `(.)+`),and `(?((?'-1'))|(?'1'a))+` has a non-backtrackingno-branch where only `|| isInLoop` triggers the assignment.-**resumeAt=2**(pass-through,nono-branch):**NOT tested.**This requires a pattern like `(?(condition)yes-branch)+` (no `|`)where the yes branch backtracks,insidea loop. The fix forthiscaseis structurally identical to the other two,buta regression test would harden it. Consider adding e.g.:
```
@"(?((?'-1'))(?'1'.)+)+(?!(?'-1'))"
```
---
### ✅ (4) NETFRAMEWORK guard/comment change is correct
The new tests are placed **before** the `#if !NETFRAMEWORK` block, so they run on all target frameworks.Thisisappropriate — the tests exercise balancing groups via `IsNonBacktracking` exclusion,nota framework-specificAPI.
The comment simplification from:
```
// these tests currently fail on .NET Framework, and we need to check IsDynamicCodeCompiled but that doesn't exist on .NET Framework
```
to:
```
// these tests currently fail on .NET Framework

is a benign editorial cleanup — the removed clause was about the tests below the guard, not the new ones. ✅

Generated by Code Review for issue #126561 ·

@danmoseley

Copy link
Copy Markdown
Contributor

/ba-g unrelated

@stephentoub
stephentoub merged commit 4bd0bf4 into mainApr 8, 2026
87 of 98 checks passed
@stephentoub
stephentoub deleted the stoub/fix126556 branch April 8, 2026 19:05
danmoseley added a commit that referenced this pull request Apr 9, 2026
…#126657)
## Description
The Code Review workflow on PR #126561 identified that the new tests
cover `resumeAt=0` (yes-branch) and `resumeAt=1` (no-branch) paths but
miss the `resumeAt=2` pass-through path — expression conditional with
only a yes-branch (no no-branch) inside a loop.
Adds 3 test cases to `Regex.MultipleMatches.Tests.cs`:
- **Yes-only conditional, condition always fails** —
`(?((?'-1'))(?'1'.)+)+(?!(?'-1'))` with `"abc"` exercises the
pass-through path producing empty matches at each position (guarded with
`#if !NETFRAMEWORK` since .NET Framework produces 3 empty matches vs 4
on .NET Core due to different empty-match-at-end-of-string semantics)
- **Yes-only conditional with prefix capture, even input** —
`((?'1'.)(?((?'-1'))(?'1'.)))+` with `"abcd"` exercises the
yes-branch-taken path inside a loop
- **Yes-only conditional with prefix capture, odd input** — same pattern
with `"abc"` verifying partial match behavior
The latter two test cases are the primary coverage for the `|| isInLoop`
fix, since their non-backtracking yes-branch means `isInLoop` is the
necessary trigger for `resumeAt = 2` (the old condition
`postYesDoneLabel != originalDoneLabel` would have been false). These
run on both .NET Core and .NET Framework.
All 32,100 existing tests continue to pass.
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: danmoseley <6385855+danmoseley@users.noreply.github.com>
Co-authored-by: Dan Moseley <danmose@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actionsgithub-actionsBot locked and limited conversation to collaborators May 9, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Compiled conditional regex match different with Interpretor, and it throws exception

3 participants

@stephentoub@danmoseley
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Fix regex compiler/source generator resumeAt handling of conditionals inside loops - #126561

Merged
stephentoub merged 3 commits into
mainfrom
stoub/fix126556
Apr 8, 2026
Merged

Fix regex compiler/source generator resumeAt handling of conditionals inside loops#126561
stephentoub merged 3 commits into
mainfrom
stoub/fix126556

Conversation

@stephentoub

Copy link
Copy Markdown
Member

Update EmitExpressionConditional to reset resumeAt when inside loops, preventing stale values and incorrect matches.

Fixes#126556

… inside loops
Update EmitExpressionConditional to reset resumeAt when inside loops, preventing stale values and incorrect matches.
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-system-text-regularexpressions
See info in area-owners.md if you want to be subscribed.

@github-actions

This comment has been minimized.

CopilotAI 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.

Pull request overview

Fixes a compiled/source-generated regex correctness bug where EmitExpressionConditional could push a stale resumeAt value when the conditional is inside a loop, leading to incorrect multiple-match results and (per linked issue) possible exceptions.

Changes:

  • Update EmitExpressionConditional in both the JIT compiler (RegexCompiler) and the source generator emitter to always set resumeAt when the conditional is within a loop.
  • Add functional tests covering expression conditionals with balancing groups and alternation inside loop constructs to prevent regressions.
Show a summary per file
FileDescription
src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/Regex.MultipleMatches.Tests.csAdds regression test cases for expression-conditionals with balancing groups/alternation inside quantified loops across engines (excluding NonBacktracking).
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexCompiler.csEnsures resumeAt is reset for each branch when the conditional is in a loop, preventing stale branch state from being pushed.
src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.csMirrors the RegexCompiler fix for source-generated regex code emission.

Copilot's findings

  • Files reviewed: 3/3 changed files
  • Comments generated: 0

@stephentoub
stephentoub enabled auto-merge (squash) April 6, 2026 00:26
@danmoseley

Copy link
Copy Markdown
Contributor

I set a copilot to in background look for missing tests and simpler tests.

Add 4 more test cases covering:
- Auto-numbered capture groups with dot and literal patterns
- Alternation in no-branch with empty second branch
- Quantified balancing group pop {2}
Move all conditional/balancing group tests outside #if !NETFRAMEWORK
since this bug is specific to the .NET Core regex compiler rewrite
and these patterns work correctly on .NET Framework.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

@danmoseleydanmoseley 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.

Added more tests. LGTM

@github-actions

This comment has been minimized.

CopilotAI review requested due to automatic review settings April 8, 2026 13:06

CopilotAI 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.

Pull request overview

This PR fixes a bug in the regex compiler and source generator where EmitExpressionConditional could reuse a stale resumeAt value when the conditional occurs inside a loop, leading to incorrect matches (and exceptions) compared to the interpreter.

Changes:

  • Update RegexCompiler.EmitExpressionConditional to always set resumeAt for each executed branch when the conditional is inside a loop.
  • Apply the same resumeAt handling fix in the source generator emitter.
  • Add regression coverage for balancing-group conditionals inside loops in the multiple-matches functional tests.
Show a summary per file
FileDescription
src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/Regex.MultipleMatches.Tests.csAdds test cases covering expression conditionals with balancing groups inside loops across engines (excluding non-backtracking).
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexCompiler.csEnsures resumeAt is reset/set when inside loops to prevent stale branch selection during backtracking.
src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.csMirrors the compiler fix in generated code so source-generated regex behaves consistently.

Copilot's findings

  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new

@github-actions

Copy link
Copy Markdown
Contributor

Note

This review was generated by Copilot.

Regex Conditional-in-Loop Fix — Code Review

✅ (1) Fix is correct and complete — all three resumeAt sites are patched symmetrically

The three changed conditions in EmitExpressionConditional (resumeAt = 0, 1, 2) now match the pattern already established in EmitBackreferenceConditional.

SiteEmitBackreferenceConditional (reference)EmitExpressionConditional (after fix)
resumeAt=0(!isAtomic && post != orig) || isInLoop (RegexCompiler.cs:2333)(!isAtomic && post != orig) || isInLoop (RegexCompiler.cs:2532) ✅
resumeAt=1same (RegexCompiler.cs:2356)same (RegexCompiler.cs:2563) ✅
resumeAt=2same (RegexCompiler.cs:2368)same (RegexCompiler.cs:2575) ✅

Both RegexCompiler.cs and RegexGenerator.Emitter.cs are updated in lockstep. ✅

No other sites in EmitExpressionConditional need the isInLoop check. The gate condition at line 2585/2577:

if(isAtomic||(postYesDoneLabel==originalDoneLabel&&postNoDoneLabel==originalDoneLabel))
```
correctlyskips the backtracking section when neither branch backtracks — in that case there is no switch on `resumeAt`,so no stale value can cause harm,regardless of loop status. The stack push/pop at lines 26022640(compiler)/25942620(emitter)isalreadyinside the `else` block and gatedon `isInLoop`.---
### ✅ (2) `isInLoop` true while both post-labels equal `originalDoneLabel` — harmless dead code
This can happen when the conditional is inside a loop but neither branch introduces backtracking. In that case:- The `|| isInLoop` causes `resumeAt` assignments to execute (dead stores).- The gate condition takes the **if**-branch(line 2585), so no backtracking section, no stack push/pop, and no switch on `resumeAt`.-Since `isInLoop` is only setwhen `!isAtomic` (lines 24802484), and `resumeAt` isonlydeclaredwhen `!isAtomic`, there's no risk of writing to an uninitialized local or undeclared variable.
Net effect: a harmless extra store that never gets read. Same patternas `EmitBackreferenceConditional`.---
### ⚠️ (3) Tests are good for resumeAt=0 and resumeAt=1, but miss the resumeAt=2(pass-through,nono-branch) case
Everynew test pattern uses `(?(condition)|no-branch)+` — the yes branch is always empty and a no-branchisalwayspresent.This means:-**resumeAt=0**(yesbranch): exercised via `|| isInLoop` because the empty yes branch doesn't change `doneLabel` (`postYesDoneLabel==originalDoneLabel`),so the old condition would have been false.Good.-**resumeAt=1**(nobranch): exercised both ways — some tests have a backtracking no-branch(e.g., `(.)+`),and `(?((?'-1'))|(?'1'a))+` has a non-backtrackingno-branch where only `|| isInLoop` triggers the assignment.-**resumeAt=2**(pass-through,nono-branch):**NOT tested.**This requires a pattern like `(?(condition)yes-branch)+` (no `|`)where the yes branch backtracks,insidea loop. The fix forthiscaseis structurally identical to the other two,buta regression test would harden it. Consider adding e.g.:
```
@"(?((?'-1'))(?'1'.)+)+(?!(?'-1'))"
```
---
### ✅ (4) NETFRAMEWORK guard/comment change is correct
The new tests are placed **before** the `#if !NETFRAMEWORK` block, so they run on all target frameworks.Thisisappropriate — the tests exercise balancing groups via `IsNonBacktracking` exclusion,nota framework-specificAPI.
The comment simplification from:
```
// these tests currently fail on .NET Framework, and we need to check IsDynamicCodeCompiled but that doesn't exist on .NET Framework
```
to:
```
// these tests currently fail on .NET Framework

is a benign editorial cleanup — the removed clause was about the tests below the guard, not the new ones. ✅

Generated by Code Review for issue #126561 ·

@danmoseley

Copy link
Copy Markdown
Contributor

/ba-g unrelated

@stephentoub
stephentoub merged commit 4bd0bf4 into mainApr 8, 2026
87 of 98 checks passed
@stephentoub
stephentoub deleted the stoub/fix126556 branch April 8, 2026 19:05
danmoseley added a commit that referenced this pull request Apr 9, 2026
…#126657)
## Description
The Code Review workflow on PR #126561 identified that the new tests
cover `resumeAt=0` (yes-branch) and `resumeAt=1` (no-branch) paths but
miss the `resumeAt=2` pass-through path — expression conditional with
only a yes-branch (no no-branch) inside a loop.
Adds 3 test cases to `Regex.MultipleMatches.Tests.cs`:
- **Yes-only conditional, condition always fails** —
`(?((?'-1'))(?'1'.)+)+(?!(?'-1'))` with `"abc"` exercises the
pass-through path producing empty matches at each position (guarded with
`#if !NETFRAMEWORK` since .NET Framework produces 3 empty matches vs 4
on .NET Core due to different empty-match-at-end-of-string semantics)
- **Yes-only conditional with prefix capture, even input** —
`((?'1'.)(?((?'-1'))(?'1'.)))+` with `"abcd"` exercises the
yes-branch-taken path inside a loop
- **Yes-only conditional with prefix capture, odd input** — same pattern
with `"abc"` verifying partial match behavior
The latter two test cases are the primary coverage for the `|| isInLoop`
fix, since their non-backtracking yes-branch means `isInLoop` is the
necessary trigger for `resumeAt = 2` (the old condition
`postYesDoneLabel != originalDoneLabel` would have been false). These
run on both .NET Core and .NET Framework.
All 32,100 existing tests continue to pass.
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: danmoseley <6385855+danmoseley@users.noreply.github.com>
Co-authored-by: Dan Moseley <danmose@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actionsgithub-actionsBot locked and limited conversation to collaborators May 9, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Compiled conditional regex match different with Interpretor, and it throws exception

3 participants

@stephentoub@danmoseley
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Fix regex compiler/source generator resumeAt handling of conditionals inside loops - #126561

Merged
stephentoub merged 3 commits into
mainfrom
stoub/fix126556
Apr 8, 2026
Merged

Fix regex compiler/source generator resumeAt handling of conditionals inside loops#126561
stephentoub merged 3 commits into
mainfrom
stoub/fix126556

Conversation

@stephentoub

Copy link
Copy Markdown
Member

Update EmitExpressionConditional to reset resumeAt when inside loops, preventing stale values and incorrect matches.

Fixes#126556

… inside loops
Update EmitExpressionConditional to reset resumeAt when inside loops, preventing stale values and incorrect matches.
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-system-text-regularexpressions
See info in area-owners.md if you want to be subscribed.

@github-actions

This comment has been minimized.

CopilotAI 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.

Pull request overview

Fixes a compiled/source-generated regex correctness bug where EmitExpressionConditional could push a stale resumeAt value when the conditional is inside a loop, leading to incorrect multiple-match results and (per linked issue) possible exceptions.

Changes:

  • Update EmitExpressionConditional in both the JIT compiler (RegexCompiler) and the source generator emitter to always set resumeAt when the conditional is within a loop.
  • Add functional tests covering expression conditionals with balancing groups and alternation inside loop constructs to prevent regressions.
Show a summary per file
FileDescription
src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/Regex.MultipleMatches.Tests.csAdds regression test cases for expression-conditionals with balancing groups/alternation inside quantified loops across engines (excluding NonBacktracking).
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexCompiler.csEnsures resumeAt is reset for each branch when the conditional is in a loop, preventing stale branch state from being pushed.
src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.csMirrors the RegexCompiler fix for source-generated regex code emission.

Copilot's findings

  • Files reviewed: 3/3 changed files
  • Comments generated: 0

@stephentoub
stephentoub enabled auto-merge (squash) April 6, 2026 00:26
@danmoseley

Copy link
Copy Markdown
Contributor

I set a copilot to in background look for missing tests and simpler tests.

Add 4 more test cases covering:
- Auto-numbered capture groups with dot and literal patterns
- Alternation in no-branch with empty second branch
- Quantified balancing group pop {2}
Move all conditional/balancing group tests outside #if !NETFRAMEWORK
since this bug is specific to the .NET Core regex compiler rewrite
and these patterns work correctly on .NET Framework.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

@danmoseleydanmoseley 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.

Added more tests. LGTM

@github-actions

This comment has been minimized.

CopilotAI review requested due to automatic review settings April 8, 2026 13:06

CopilotAI 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.

Pull request overview

This PR fixes a bug in the regex compiler and source generator where EmitExpressionConditional could reuse a stale resumeAt value when the conditional occurs inside a loop, leading to incorrect matches (and exceptions) compared to the interpreter.

Changes:

  • Update RegexCompiler.EmitExpressionConditional to always set resumeAt for each executed branch when the conditional is inside a loop.
  • Apply the same resumeAt handling fix in the source generator emitter.
  • Add regression coverage for balancing-group conditionals inside loops in the multiple-matches functional tests.
Show a summary per file
FileDescription
src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/Regex.MultipleMatches.Tests.csAdds test cases covering expression conditionals with balancing groups inside loops across engines (excluding non-backtracking).
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexCompiler.csEnsures resumeAt is reset/set when inside loops to prevent stale branch selection during backtracking.
src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.csMirrors the compiler fix in generated code so source-generated regex behaves consistently.

Copilot's findings

  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new

@github-actions

Copy link
Copy Markdown
Contributor

Note

This review was generated by Copilot.

Regex Conditional-in-Loop Fix — Code Review

✅ (1) Fix is correct and complete — all three resumeAt sites are patched symmetrically

The three changed conditions in EmitExpressionConditional (resumeAt = 0, 1, 2) now match the pattern already established in EmitBackreferenceConditional.

SiteEmitBackreferenceConditional (reference)EmitExpressionConditional (after fix)
resumeAt=0(!isAtomic && post != orig) || isInLoop (RegexCompiler.cs:2333)(!isAtomic && post != orig) || isInLoop (RegexCompiler.cs:2532) ✅
resumeAt=1same (RegexCompiler.cs:2356)same (RegexCompiler.cs:2563) ✅
resumeAt=2same (RegexCompiler.cs:2368)same (RegexCompiler.cs:2575) ✅

Both RegexCompiler.cs and RegexGenerator.Emitter.cs are updated in lockstep. ✅

No other sites in EmitExpressionConditional need the isInLoop check. The gate condition at line 2585/2577:

if(isAtomic||(postYesDoneLabel==originalDoneLabel&&postNoDoneLabel==originalDoneLabel))
```
correctlyskips the backtracking section when neither branch backtracks — in that case there is no switch on `resumeAt`,so no stale value can cause harm,regardless of loop status. The stack push/pop at lines 26022640(compiler)/25942620(emitter)isalreadyinside the `else` block and gatedon `isInLoop`.---
### ✅ (2) `isInLoop` true while both post-labels equal `originalDoneLabel` — harmless dead code
This can happen when the conditional is inside a loop but neither branch introduces backtracking. In that case:- The `|| isInLoop` causes `resumeAt` assignments to execute (dead stores).- The gate condition takes the **if**-branch(line 2585), so no backtracking section, no stack push/pop, and no switch on `resumeAt`.-Since `isInLoop` is only setwhen `!isAtomic` (lines 24802484), and `resumeAt` isonlydeclaredwhen `!isAtomic`, there's no risk of writing to an uninitialized local or undeclared variable.
Net effect: a harmless extra store that never gets read. Same patternas `EmitBackreferenceConditional`.---
### ⚠️ (3) Tests are good for resumeAt=0 and resumeAt=1, but miss the resumeAt=2(pass-through,nono-branch) case
Everynew test pattern uses `(?(condition)|no-branch)+` — the yes branch is always empty and a no-branchisalwayspresent.This means:-**resumeAt=0**(yesbranch): exercised via `|| isInLoop` because the empty yes branch doesn't change `doneLabel` (`postYesDoneLabel==originalDoneLabel`),so the old condition would have been false.Good.-**resumeAt=1**(nobranch): exercised both ways — some tests have a backtracking no-branch(e.g., `(.)+`),and `(?((?'-1'))|(?'1'a))+` has a non-backtrackingno-branch where only `|| isInLoop` triggers the assignment.-**resumeAt=2**(pass-through,nono-branch):**NOT tested.**This requires a pattern like `(?(condition)yes-branch)+` (no `|`)where the yes branch backtracks,insidea loop. The fix forthiscaseis structurally identical to the other two,buta regression test would harden it. Consider adding e.g.:
```
@"(?((?'-1'))(?'1'.)+)+(?!(?'-1'))"
```
---
### ✅ (4) NETFRAMEWORK guard/comment change is correct
The new tests are placed **before** the `#if !NETFRAMEWORK` block, so they run on all target frameworks.Thisisappropriate — the tests exercise balancing groups via `IsNonBacktracking` exclusion,nota framework-specificAPI.
The comment simplification from:
```
// these tests currently fail on .NET Framework, and we need to check IsDynamicCodeCompiled but that doesn't exist on .NET Framework
```
to:
```
// these tests currently fail on .NET Framework

is a benign editorial cleanup — the removed clause was about the tests below the guard, not the new ones. ✅

Generated by Code Review for issue #126561 ·

@danmoseley

Copy link
Copy Markdown
Contributor

/ba-g unrelated

@stephentoub
stephentoub merged commit 4bd0bf4 into mainApr 8, 2026
87 of 98 checks passed
@stephentoub
stephentoub deleted the stoub/fix126556 branch April 8, 2026 19:05
danmoseley added a commit that referenced this pull request Apr 9, 2026
…#126657)
## Description
The Code Review workflow on PR #126561 identified that the new tests
cover `resumeAt=0` (yes-branch) and `resumeAt=1` (no-branch) paths but
miss the `resumeAt=2` pass-through path — expression conditional with
only a yes-branch (no no-branch) inside a loop.
Adds 3 test cases to `Regex.MultipleMatches.Tests.cs`:
- **Yes-only conditional, condition always fails** —
`(?((?'-1'))(?'1'.)+)+(?!(?'-1'))` with `"abc"` exercises the
pass-through path producing empty matches at each position (guarded with
`#if !NETFRAMEWORK` since .NET Framework produces 3 empty matches vs 4
on .NET Core due to different empty-match-at-end-of-string semantics)
- **Yes-only conditional with prefix capture, even input** —
`((?'1'.)(?((?'-1'))(?'1'.)))+` with `"abcd"` exercises the
yes-branch-taken path inside a loop
- **Yes-only conditional with prefix capture, odd input** — same pattern
with `"abc"` verifying partial match behavior
The latter two test cases are the primary coverage for the `|| isInLoop`
fix, since their non-backtracking yes-branch means `isInLoop` is the
necessary trigger for `resumeAt = 2` (the old condition
`postYesDoneLabel != originalDoneLabel` would have been false). These
run on both .NET Core and .NET Framework.
All 32,100 existing tests continue to pass.
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: danmoseley <6385855+danmoseley@users.noreply.github.com>
Co-authored-by: Dan Moseley <danmose@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actionsgithub-actionsBot locked and limited conversation to collaborators May 9, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Compiled conditional regex match different with Interpretor, and it throws exception

3 participants

@stephentoub@danmoseley
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Fix regex compiler/source generator resumeAt handling of conditionals inside loops - #126561

Merged
stephentoub merged 3 commits into
mainfrom
stoub/fix126556
Apr 8, 2026
Merged

Fix regex compiler/source generator resumeAt handling of conditionals inside loops#126561
stephentoub merged 3 commits into
mainfrom
stoub/fix126556

Conversation

@stephentoub

Copy link
Copy Markdown
Member

Update EmitExpressionConditional to reset resumeAt when inside loops, preventing stale values and incorrect matches.

Fixes#126556

… inside loops
Update EmitExpressionConditional to reset resumeAt when inside loops, preventing stale values and incorrect matches.
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-system-text-regularexpressions
See info in area-owners.md if you want to be subscribed.

@github-actions

This comment has been minimized.

CopilotAI 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.

Pull request overview

Fixes a compiled/source-generated regex correctness bug where EmitExpressionConditional could push a stale resumeAt value when the conditional is inside a loop, leading to incorrect multiple-match results and (per linked issue) possible exceptions.

Changes:

  • Update EmitExpressionConditional in both the JIT compiler (RegexCompiler) and the source generator emitter to always set resumeAt when the conditional is within a loop.
  • Add functional tests covering expression conditionals with balancing groups and alternation inside loop constructs to prevent regressions.
Show a summary per file
FileDescription
src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/Regex.MultipleMatches.Tests.csAdds regression test cases for expression-conditionals with balancing groups/alternation inside quantified loops across engines (excluding NonBacktracking).
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexCompiler.csEnsures resumeAt is reset for each branch when the conditional is in a loop, preventing stale branch state from being pushed.
src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.csMirrors the RegexCompiler fix for source-generated regex code emission.

Copilot's findings

  • Files reviewed: 3/3 changed files
  • Comments generated: 0

@stephentoub
stephentoub enabled auto-merge (squash) April 6, 2026 00:26
@danmoseley

Copy link
Copy Markdown
Contributor

I set a copilot to in background look for missing tests and simpler tests.

Add 4 more test cases covering:
- Auto-numbered capture groups with dot and literal patterns
- Alternation in no-branch with empty second branch
- Quantified balancing group pop {2}
Move all conditional/balancing group tests outside #if !NETFRAMEWORK
since this bug is specific to the .NET Core regex compiler rewrite
and these patterns work correctly on .NET Framework.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

@danmoseleydanmoseley 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.

Added more tests. LGTM

@github-actions

This comment has been minimized.

CopilotAI review requested due to automatic review settings April 8, 2026 13:06

CopilotAI 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.

Pull request overview

This PR fixes a bug in the regex compiler and source generator where EmitExpressionConditional could reuse a stale resumeAt value when the conditional occurs inside a loop, leading to incorrect matches (and exceptions) compared to the interpreter.

Changes:

  • Update RegexCompiler.EmitExpressionConditional to always set resumeAt for each executed branch when the conditional is inside a loop.
  • Apply the same resumeAt handling fix in the source generator emitter.
  • Add regression coverage for balancing-group conditionals inside loops in the multiple-matches functional tests.
Show a summary per file
FileDescription
src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/Regex.MultipleMatches.Tests.csAdds test cases covering expression conditionals with balancing groups inside loops across engines (excluding non-backtracking).
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexCompiler.csEnsures resumeAt is reset/set when inside loops to prevent stale branch selection during backtracking.
src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.csMirrors the compiler fix in generated code so source-generated regex behaves consistently.

Copilot's findings

  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new

@github-actions

Copy link
Copy Markdown
Contributor

Note

This review was generated by Copilot.

Regex Conditional-in-Loop Fix — Code Review

✅ (1) Fix is correct and complete — all three resumeAt sites are patched symmetrically

The three changed conditions in EmitExpressionConditional (resumeAt = 0, 1, 2) now match the pattern already established in EmitBackreferenceConditional.

SiteEmitBackreferenceConditional (reference)EmitExpressionConditional (after fix)
resumeAt=0(!isAtomic && post != orig) || isInLoop (RegexCompiler.cs:2333)(!isAtomic && post != orig) || isInLoop (RegexCompiler.cs:2532) ✅
resumeAt=1same (RegexCompiler.cs:2356)same (RegexCompiler.cs:2563) ✅
resumeAt=2same (RegexCompiler.cs:2368)same (RegexCompiler.cs:2575) ✅

Both RegexCompiler.cs and RegexGenerator.Emitter.cs are updated in lockstep. ✅

No other sites in EmitExpressionConditional need the isInLoop check. The gate condition at line 2585/2577:

if(isAtomic||(postYesDoneLabel==originalDoneLabel&&postNoDoneLabel==originalDoneLabel))
```
correctlyskips the backtracking section when neither branch backtracks — in that case there is no switch on `resumeAt`,so no stale value can cause harm,regardless of loop status. The stack push/pop at lines 26022640(compiler)/25942620(emitter)isalreadyinside the `else` block and gatedon `isInLoop`.---
### ✅ (2) `isInLoop` true while both post-labels equal `originalDoneLabel` — harmless dead code
This can happen when the conditional is inside a loop but neither branch introduces backtracking. In that case:- The `|| isInLoop` causes `resumeAt` assignments to execute (dead stores).- The gate condition takes the **if**-branch(line 2585), so no backtracking section, no stack push/pop, and no switch on `resumeAt`.-Since `isInLoop` is only setwhen `!isAtomic` (lines 24802484), and `resumeAt` isonlydeclaredwhen `!isAtomic`, there's no risk of writing to an uninitialized local or undeclared variable.
Net effect: a harmless extra store that never gets read. Same patternas `EmitBackreferenceConditional`.---
### ⚠️ (3) Tests are good for resumeAt=0 and resumeAt=1, but miss the resumeAt=2(pass-through,nono-branch) case
Everynew test pattern uses `(?(condition)|no-branch)+` — the yes branch is always empty and a no-branchisalwayspresent.This means:-**resumeAt=0**(yesbranch): exercised via `|| isInLoop` because the empty yes branch doesn't change `doneLabel` (`postYesDoneLabel==originalDoneLabel`),so the old condition would have been false.Good.-**resumeAt=1**(nobranch): exercised both ways — some tests have a backtracking no-branch(e.g., `(.)+`),and `(?((?'-1'))|(?'1'a))+` has a non-backtrackingno-branch where only `|| isInLoop` triggers the assignment.-**resumeAt=2**(pass-through,nono-branch):**NOT tested.**This requires a pattern like `(?(condition)yes-branch)+` (no `|`)where the yes branch backtracks,insidea loop. The fix forthiscaseis structurally identical to the other two,buta regression test would harden it. Consider adding e.g.:
```
@"(?((?'-1'))(?'1'.)+)+(?!(?'-1'))"
```
---
### ✅ (4) NETFRAMEWORK guard/comment change is correct
The new tests are placed **before** the `#if !NETFRAMEWORK` block, so they run on all target frameworks.Thisisappropriate — the tests exercise balancing groups via `IsNonBacktracking` exclusion,nota framework-specificAPI.
The comment simplification from:
```
// these tests currently fail on .NET Framework, and we need to check IsDynamicCodeCompiled but that doesn't exist on .NET Framework
```
to:
```
// these tests currently fail on .NET Framework

is a benign editorial cleanup — the removed clause was about the tests below the guard, not the new ones. ✅

Generated by Code Review for issue #126561 ·

@danmoseley

Copy link
Copy Markdown
Contributor

/ba-g unrelated

@stephentoub
stephentoub merged commit 4bd0bf4 into mainApr 8, 2026
87 of 98 checks passed
@stephentoub
stephentoub deleted the stoub/fix126556 branch April 8, 2026 19:05
danmoseley added a commit that referenced this pull request Apr 9, 2026
…#126657)
## Description
The Code Review workflow on PR #126561 identified that the new tests
cover `resumeAt=0` (yes-branch) and `resumeAt=1` (no-branch) paths but
miss the `resumeAt=2` pass-through path — expression conditional with
only a yes-branch (no no-branch) inside a loop.
Adds 3 test cases to `Regex.MultipleMatches.Tests.cs`:
- **Yes-only conditional, condition always fails** —
`(?((?'-1'))(?'1'.)+)+(?!(?'-1'))` with `"abc"` exercises the
pass-through path producing empty matches at each position (guarded with
`#if !NETFRAMEWORK` since .NET Framework produces 3 empty matches vs 4
on .NET Core due to different empty-match-at-end-of-string semantics)
- **Yes-only conditional with prefix capture, even input** —
`((?'1'.)(?((?'-1'))(?'1'.)))+` with `"abcd"` exercises the
yes-branch-taken path inside a loop
- **Yes-only conditional with prefix capture, odd input** — same pattern
with `"abc"` verifying partial match behavior
The latter two test cases are the primary coverage for the `|| isInLoop`
fix, since their non-backtracking yes-branch means `isInLoop` is the
necessary trigger for `resumeAt = 2` (the old condition
`postYesDoneLabel != originalDoneLabel` would have been false). These
run on both .NET Core and .NET Framework.
All 32,100 existing tests continue to pass.
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: danmoseley <6385855+danmoseley@users.noreply.github.com>
Co-authored-by: Dan Moseley <danmose@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actionsgithub-actionsBot locked and limited conversation to collaborators May 9, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Compiled conditional regex match different with Interpretor, and it throws exception

3 participants

@stephentoub@danmoseley
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Fix regex compiler/source generator resumeAt handling of conditionals inside loops - #126561

Merged
stephentoub merged 3 commits into
mainfrom
stoub/fix126556
Apr 8, 2026
Merged

Fix regex compiler/source generator resumeAt handling of conditionals inside loops#126561
stephentoub merged 3 commits into
mainfrom
stoub/fix126556

Conversation

@stephentoub

Copy link
Copy Markdown
Member

Update EmitExpressionConditional to reset resumeAt when inside loops, preventing stale values and incorrect matches.

Fixes#126556

… inside loops
Update EmitExpressionConditional to reset resumeAt when inside loops, preventing stale values and incorrect matches.
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-system-text-regularexpressions
See info in area-owners.md if you want to be subscribed.

@github-actions

This comment has been minimized.

CopilotAI 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.

Pull request overview

Fixes a compiled/source-generated regex correctness bug where EmitExpressionConditional could push a stale resumeAt value when the conditional is inside a loop, leading to incorrect multiple-match results and (per linked issue) possible exceptions.

Changes:

  • Update EmitExpressionConditional in both the JIT compiler (RegexCompiler) and the source generator emitter to always set resumeAt when the conditional is within a loop.
  • Add functional tests covering expression conditionals with balancing groups and alternation inside loop constructs to prevent regressions.
Show a summary per file
FileDescription
src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/Regex.MultipleMatches.Tests.csAdds regression test cases for expression-conditionals with balancing groups/alternation inside quantified loops across engines (excluding NonBacktracking).
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexCompiler.csEnsures resumeAt is reset for each branch when the conditional is in a loop, preventing stale branch state from being pushed.
src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.csMirrors the RegexCompiler fix for source-generated regex code emission.

Copilot's findings

  • Files reviewed: 3/3 changed files
  • Comments generated: 0

@stephentoub
stephentoub enabled auto-merge (squash) April 6, 2026 00:26
@danmoseley

Copy link
Copy Markdown
Contributor

I set a copilot to in background look for missing tests and simpler tests.

Add 4 more test cases covering:
- Auto-numbered capture groups with dot and literal patterns
- Alternation in no-branch with empty second branch
- Quantified balancing group pop {2}
Move all conditional/balancing group tests outside #if !NETFRAMEWORK
since this bug is specific to the .NET Core regex compiler rewrite
and these patterns work correctly on .NET Framework.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

@danmoseleydanmoseley 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.

Added more tests. LGTM

@github-actions

This comment has been minimized.

CopilotAI review requested due to automatic review settings April 8, 2026 13:06

CopilotAI 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.

Pull request overview

This PR fixes a bug in the regex compiler and source generator where EmitExpressionConditional could reuse a stale resumeAt value when the conditional occurs inside a loop, leading to incorrect matches (and exceptions) compared to the interpreter.

Changes:

  • Update RegexCompiler.EmitExpressionConditional to always set resumeAt for each executed branch when the conditional is inside a loop.
  • Apply the same resumeAt handling fix in the source generator emitter.
  • Add regression coverage for balancing-group conditionals inside loops in the multiple-matches functional tests.
Show a summary per file
FileDescription
src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/Regex.MultipleMatches.Tests.csAdds test cases covering expression conditionals with balancing groups inside loops across engines (excluding non-backtracking).
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexCompiler.csEnsures resumeAt is reset/set when inside loops to prevent stale branch selection during backtracking.
src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.csMirrors the compiler fix in generated code so source-generated regex behaves consistently.

Copilot's findings

  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new

@github-actions

Copy link
Copy Markdown
Contributor

Note

This review was generated by Copilot.

Regex Conditional-in-Loop Fix — Code Review

✅ (1) Fix is correct and complete — all three resumeAt sites are patched symmetrically

The three changed conditions in EmitExpressionConditional (resumeAt = 0, 1, 2) now match the pattern already established in EmitBackreferenceConditional.

SiteEmitBackreferenceConditional (reference)EmitExpressionConditional (after fix)
resumeAt=0(!isAtomic && post != orig) || isInLoop (RegexCompiler.cs:2333)(!isAtomic && post != orig) || isInLoop (RegexCompiler.cs:2532) ✅
resumeAt=1same (RegexCompiler.cs:2356)same (RegexCompiler.cs:2563) ✅
resumeAt=2same (RegexCompiler.cs:2368)same (RegexCompiler.cs:2575) ✅

Both RegexCompiler.cs and RegexGenerator.Emitter.cs are updated in lockstep. ✅

No other sites in EmitExpressionConditional need the isInLoop check. The gate condition at line 2585/2577:

if(isAtomic||(postYesDoneLabel==originalDoneLabel&&postNoDoneLabel==originalDoneLabel))
```
correctlyskips the backtracking section when neither branch backtracks — in that case there is no switch on `resumeAt`,so no stale value can cause harm,regardless of loop status. The stack push/pop at lines 26022640(compiler)/25942620(emitter)isalreadyinside the `else` block and gatedon `isInLoop`.---
### ✅ (2) `isInLoop` true while both post-labels equal `originalDoneLabel` — harmless dead code
This can happen when the conditional is inside a loop but neither branch introduces backtracking. In that case:- The `|| isInLoop` causes `resumeAt` assignments to execute (dead stores).- The gate condition takes the **if**-branch(line 2585), so no backtracking section, no stack push/pop, and no switch on `resumeAt`.-Since `isInLoop` is only setwhen `!isAtomic` (lines 24802484), and `resumeAt` isonlydeclaredwhen `!isAtomic`, there's no risk of writing to an uninitialized local or undeclared variable.
Net effect: a harmless extra store that never gets read. Same patternas `EmitBackreferenceConditional`.---
### ⚠️ (3) Tests are good for resumeAt=0 and resumeAt=1, but miss the resumeAt=2(pass-through,nono-branch) case
Everynew test pattern uses `(?(condition)|no-branch)+` — the yes branch is always empty and a no-branchisalwayspresent.This means:-**resumeAt=0**(yesbranch): exercised via `|| isInLoop` because the empty yes branch doesn't change `doneLabel` (`postYesDoneLabel==originalDoneLabel`),so the old condition would have been false.Good.-**resumeAt=1**(nobranch): exercised both ways — some tests have a backtracking no-branch(e.g., `(.)+`),and `(?((?'-1'))|(?'1'a))+` has a non-backtrackingno-branch where only `|| isInLoop` triggers the assignment.-**resumeAt=2**(pass-through,nono-branch):**NOT tested.**This requires a pattern like `(?(condition)yes-branch)+` (no `|`)where the yes branch backtracks,insidea loop. The fix forthiscaseis structurally identical to the other two,buta regression test would harden it. Consider adding e.g.:
```
@"(?((?'-1'))(?'1'.)+)+(?!(?'-1'))"
```
---
### ✅ (4) NETFRAMEWORK guard/comment change is correct
The new tests are placed **before** the `#if !NETFRAMEWORK` block, so they run on all target frameworks.Thisisappropriate — the tests exercise balancing groups via `IsNonBacktracking` exclusion,nota framework-specificAPI.
The comment simplification from:
```
// these tests currently fail on .NET Framework, and we need to check IsDynamicCodeCompiled but that doesn't exist on .NET Framework
```
to:
```
// these tests currently fail on .NET Framework

is a benign editorial cleanup — the removed clause was about the tests below the guard, not the new ones. ✅

Generated by Code Review for issue #126561 ·

@danmoseley

Copy link
Copy Markdown
Contributor

/ba-g unrelated

@stephentoub
stephentoub merged commit 4bd0bf4 into mainApr 8, 2026
87 of 98 checks passed
@stephentoub
stephentoub deleted the stoub/fix126556 branch April 8, 2026 19:05
danmoseley added a commit that referenced this pull request Apr 9, 2026
…#126657)
## Description
The Code Review workflow on PR #126561 identified that the new tests
cover `resumeAt=0` (yes-branch) and `resumeAt=1` (no-branch) paths but
miss the `resumeAt=2` pass-through path — expression conditional with
only a yes-branch (no no-branch) inside a loop.
Adds 3 test cases to `Regex.MultipleMatches.Tests.cs`:
- **Yes-only conditional, condition always fails** —
`(?((?'-1'))(?'1'.)+)+(?!(?'-1'))` with `"abc"` exercises the
pass-through path producing empty matches at each position (guarded with
`#if !NETFRAMEWORK` since .NET Framework produces 3 empty matches vs 4
on .NET Core due to different empty-match-at-end-of-string semantics)
- **Yes-only conditional with prefix capture, even input** —
`((?'1'.)(?((?'-1'))(?'1'.)))+` with `"abcd"` exercises the
yes-branch-taken path inside a loop
- **Yes-only conditional with prefix capture, odd input** — same pattern
with `"abc"` verifying partial match behavior
The latter two test cases are the primary coverage for the `|| isInLoop`
fix, since their non-backtracking yes-branch means `isInLoop` is the
necessary trigger for `resumeAt = 2` (the old condition
`postYesDoneLabel != originalDoneLabel` would have been false). These
run on both .NET Core and .NET Framework.
All 32,100 existing tests continue to pass.
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: danmoseley <6385855+danmoseley@users.noreply.github.com>
Co-authored-by: Dan Moseley <danmose@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actionsgithub-actionsBot locked and limited conversation to collaborators May 9, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Compiled conditional regex match different with Interpretor, and it throws exception

3 participants

@stephentoub@danmoseley
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Fix regex compiler/source generator resumeAt handling of conditionals inside loops - #126561

Merged
stephentoub merged 3 commits into
mainfrom
stoub/fix126556
Apr 8, 2026
Merged

Fix regex compiler/source generator resumeAt handling of conditionals inside loops#126561
stephentoub merged 3 commits into
mainfrom
stoub/fix126556

Conversation

@stephentoub

Copy link
Copy Markdown
Member

Update EmitExpressionConditional to reset resumeAt when inside loops, preventing stale values and incorrect matches.

Fixes#126556

… inside loops
Update EmitExpressionConditional to reset resumeAt when inside loops, preventing stale values and incorrect matches.
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-system-text-regularexpressions
See info in area-owners.md if you want to be subscribed.

@github-actions

This comment has been minimized.

CopilotAI 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.

Pull request overview

Fixes a compiled/source-generated regex correctness bug where EmitExpressionConditional could push a stale resumeAt value when the conditional is inside a loop, leading to incorrect multiple-match results and (per linked issue) possible exceptions.

Changes:

  • Update EmitExpressionConditional in both the JIT compiler (RegexCompiler) and the source generator emitter to always set resumeAt when the conditional is within a loop.
  • Add functional tests covering expression conditionals with balancing groups and alternation inside loop constructs to prevent regressions.
Show a summary per file
FileDescription
src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/Regex.MultipleMatches.Tests.csAdds regression test cases for expression-conditionals with balancing groups/alternation inside quantified loops across engines (excluding NonBacktracking).
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexCompiler.csEnsures resumeAt is reset for each branch when the conditional is in a loop, preventing stale branch state from being pushed.
src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.csMirrors the RegexCompiler fix for source-generated regex code emission.

Copilot's findings

  • Files reviewed: 3/3 changed files
  • Comments generated: 0

@stephentoub
stephentoub enabled auto-merge (squash) April 6, 2026 00:26
@danmoseley

Copy link
Copy Markdown
Contributor

I set a copilot to in background look for missing tests and simpler tests.

Add 4 more test cases covering:
- Auto-numbered capture groups with dot and literal patterns
- Alternation in no-branch with empty second branch
- Quantified balancing group pop {2}
Move all conditional/balancing group tests outside #if !NETFRAMEWORK
since this bug is specific to the .NET Core regex compiler rewrite
and these patterns work correctly on .NET Framework.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

@danmoseleydanmoseley 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.

Added more tests. LGTM

@github-actions

This comment has been minimized.

CopilotAI review requested due to automatic review settings April 8, 2026 13:06

CopilotAI 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.

Pull request overview

This PR fixes a bug in the regex compiler and source generator where EmitExpressionConditional could reuse a stale resumeAt value when the conditional occurs inside a loop, leading to incorrect matches (and exceptions) compared to the interpreter.

Changes:

  • Update RegexCompiler.EmitExpressionConditional to always set resumeAt for each executed branch when the conditional is inside a loop.
  • Apply the same resumeAt handling fix in the source generator emitter.
  • Add regression coverage for balancing-group conditionals inside loops in the multiple-matches functional tests.
Show a summary per file
FileDescription
src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/Regex.MultipleMatches.Tests.csAdds test cases covering expression conditionals with balancing groups inside loops across engines (excluding non-backtracking).
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexCompiler.csEnsures resumeAt is reset/set when inside loops to prevent stale branch selection during backtracking.
src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.csMirrors the compiler fix in generated code so source-generated regex behaves consistently.

Copilot's findings

  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new

@github-actions

Copy link
Copy Markdown
Contributor

Note

This review was generated by Copilot.

Regex Conditional-in-Loop Fix — Code Review

✅ (1) Fix is correct and complete — all three resumeAt sites are patched symmetrically

The three changed conditions in EmitExpressionConditional (resumeAt = 0, 1, 2) now match the pattern already established in EmitBackreferenceConditional.

SiteEmitBackreferenceConditional (reference)EmitExpressionConditional (after fix)
resumeAt=0(!isAtomic && post != orig) || isInLoop (RegexCompiler.cs:2333)(!isAtomic && post != orig) || isInLoop (RegexCompiler.cs:2532) ✅
resumeAt=1same (RegexCompiler.cs:2356)same (RegexCompiler.cs:2563) ✅
resumeAt=2same (RegexCompiler.cs:2368)same (RegexCompiler.cs:2575) ✅

Both RegexCompiler.cs and RegexGenerator.Emitter.cs are updated in lockstep. ✅

No other sites in EmitExpressionConditional need the isInLoop check. The gate condition at line 2585/2577:

if(isAtomic||(postYesDoneLabel==originalDoneLabel&&postNoDoneLabel==originalDoneLabel))
```
correctlyskips the backtracking section when neither branch backtracks — in that case there is no switch on `resumeAt`,so no stale value can cause harm,regardless of loop status. The stack push/pop at lines 26022640(compiler)/25942620(emitter)isalreadyinside the `else` block and gatedon `isInLoop`.---
### ✅ (2) `isInLoop` true while both post-labels equal `originalDoneLabel` — harmless dead code
This can happen when the conditional is inside a loop but neither branch introduces backtracking. In that case:- The `|| isInLoop` causes `resumeAt` assignments to execute (dead stores).- The gate condition takes the **if**-branch(line 2585), so no backtracking section, no stack push/pop, and no switch on `resumeAt`.-Since `isInLoop` is only setwhen `!isAtomic` (lines 24802484), and `resumeAt` isonlydeclaredwhen `!isAtomic`, there's no risk of writing to an uninitialized local or undeclared variable.
Net effect: a harmless extra store that never gets read. Same patternas `EmitBackreferenceConditional`.---
### ⚠️ (3) Tests are good for resumeAt=0 and resumeAt=1, but miss the resumeAt=2(pass-through,nono-branch) case
Everynew test pattern uses `(?(condition)|no-branch)+` — the yes branch is always empty and a no-branchisalwayspresent.This means:-**resumeAt=0**(yesbranch): exercised via `|| isInLoop` because the empty yes branch doesn't change `doneLabel` (`postYesDoneLabel==originalDoneLabel`),so the old condition would have been false.Good.-**resumeAt=1**(nobranch): exercised both ways — some tests have a backtracking no-branch(e.g., `(.)+`),and `(?((?'-1'))|(?'1'a))+` has a non-backtrackingno-branch where only `|| isInLoop` triggers the assignment.-**resumeAt=2**(pass-through,nono-branch):**NOT tested.**This requires a pattern like `(?(condition)yes-branch)+` (no `|`)where the yes branch backtracks,insidea loop. The fix forthiscaseis structurally identical to the other two,buta regression test would harden it. Consider adding e.g.:
```
@"(?((?'-1'))(?'1'.)+)+(?!(?'-1'))"
```
---
### ✅ (4) NETFRAMEWORK guard/comment change is correct
The new tests are placed **before** the `#if !NETFRAMEWORK` block, so they run on all target frameworks.Thisisappropriate — the tests exercise balancing groups via `IsNonBacktracking` exclusion,nota framework-specificAPI.
The comment simplification from:
```
// these tests currently fail on .NET Framework, and we need to check IsDynamicCodeCompiled but that doesn't exist on .NET Framework
```
to:
```
// these tests currently fail on .NET Framework

is a benign editorial cleanup — the removed clause was about the tests below the guard, not the new ones. ✅

Generated by Code Review for issue #126561 ·

@danmoseley

Copy link
Copy Markdown
Contributor

/ba-g unrelated

@stephentoub
stephentoub merged commit 4bd0bf4 into mainApr 8, 2026
87 of 98 checks passed
@stephentoub
stephentoub deleted the stoub/fix126556 branch April 8, 2026 19:05
danmoseley added a commit that referenced this pull request Apr 9, 2026
…#126657)
## Description
The Code Review workflow on PR #126561 identified that the new tests
cover `resumeAt=0` (yes-branch) and `resumeAt=1` (no-branch) paths but
miss the `resumeAt=2` pass-through path — expression conditional with
only a yes-branch (no no-branch) inside a loop.
Adds 3 test cases to `Regex.MultipleMatches.Tests.cs`:
- **Yes-only conditional, condition always fails** —
`(?((?'-1'))(?'1'.)+)+(?!(?'-1'))` with `"abc"` exercises the
pass-through path producing empty matches at each position (guarded with
`#if !NETFRAMEWORK` since .NET Framework produces 3 empty matches vs 4
on .NET Core due to different empty-match-at-end-of-string semantics)
- **Yes-only conditional with prefix capture, even input** —
`((?'1'.)(?((?'-1'))(?'1'.)))+` with `"abcd"` exercises the
yes-branch-taken path inside a loop
- **Yes-only conditional with prefix capture, odd input** — same pattern
with `"abc"` verifying partial match behavior
The latter two test cases are the primary coverage for the `|| isInLoop`
fix, since their non-backtracking yes-branch means `isInLoop` is the
necessary trigger for `resumeAt = 2` (the old condition
`postYesDoneLabel != originalDoneLabel` would have been false). These
run on both .NET Core and .NET Framework.
All 32,100 existing tests continue to pass.
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: danmoseley <6385855+danmoseley@users.noreply.github.com>
Co-authored-by: Dan Moseley <danmose@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actionsgithub-actionsBot locked and limited conversation to collaborators May 9, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Compiled conditional regex match different with Interpretor, and it throws exception

3 participants

@stephentoub@danmoseley