Skip to content

Fix forward progress analysis losing its place in the node list - #907

Open
maxches99 wants to merge 2 commits into
swiftlang:mainfrom
maxches99:fix/forward-progress-cursor
Open

maxches99 wants to merge 2 commits into
swiftlang:mainfrom
maxches99:fix/forward-progress-cursor

Conversation

@maxches99

@maxches99 maxches99 commented Oct 20, 2026 •

Copy link
Copy Markdown

Resolves #897 and #894.

The bug

_guaranteesForwardProgressImpl in ByteCodeGen+DSLList.swift walks the flattened node list with an inout position cursor, and several of its cases answer without visiting every child:

  • .concatenation uses contains, which stops at the first child that progresses.
  • .orderedChoice uses allSatisfy, which stops at the first alternative that doesn't.
  • A lookahead group and a quantification with a zero lower bound don't look at their child at all.

The cases that don't descend skipped their subtree explicitly, but the short-circuiting ones left the cursor in the middle of theirs, and the caller then read a node from inside the subtree as the next sibling.

In (?:.c|)* the first alternative is the concatenation .c. Deciding it only takes ., so c was read as the second, empty alternative. That made every alternative look like it progresses, so the forward progress check was never emitted, and matching the empty alternative looped forever:

let regex = try Regex(#"(?:.c|)*x"#)
print("b".matches(of: regex).count)   // never returns

try Regex("(?:.b|)+.").firstMatch(in: "a")   // never returns

Some variants ended in Fatal error: failed to allocate ... bytes of memory instead of hanging.

The fix

Find the end of the node's subtree up front, with the same skipNode the explicit cases used, and restore the cursor to it on the way out. The invariant - on return, position is just past the node's whole subtree - then holds however much of the subtree a case needs to look at, and the two explicit skips become redundant and are dropped.

Testing

  • testQuantificationForwardProgress gets the three patterns from Infinite loop: forward-progress analysis loses its place (contains/allSatisfy short-circuit), e.g. (?:.c|)*x #897 and the one from Regex infinite loop for empty alternate #894; each one fails its 3 second completion timeout without the fix.

  • testQuantificationForwardProgressCompile checks the progress check instructions are now emitted for those bodies, and - for patterns whose alternatives all progress, or whose concatenation progresses only in a later child - that they are still not emitted, so the analysis didn't just become conservative.

  • testEmptyAlternativeInQuantification checks the match results themselves, including the non-empty inputs that were already fine.

  • Full swift test passes.

  • As a check that nothing else moved, two generated corpora were run against the OS's own _StringProcessing (which predates the flattened DSLList) over a set of short inputs: 4000 assorted patterns, and 2500 of the shape this fixes - an empty or non-progressing alternative beside one that is a multi-node concatenation, under each kind of quantifier. On the assorted corpus the results are identical before and after this change. Against the OS engine, 6498 of the 6500 patterns agree.

    Both disagreements are pre-existing - byte-for-byte the same before this change - and both go away when root.autoPossessify() is skipped in emitRoot, so they are Auto-possessification loses its place after an optional sibling: (?i)(?:a*\+*)A{2} misses "aA" #898, auto-possessification losing its place in AutoPossessification.swift. That is the same class of bookkeeping bug in a different walk, and is not addressed here. In case they are useful there, the two are (b{1,3}\s?)b against "bb" and (?:a+a?|a*|\+.(?#c)\+)[ab]*. against "a", both of which find no match where the OS engine matches - neither one needs ignoresCase, unlike the repro in the issue.

Independent of #872: that one changes when guaranteesForwardProgress is consulted, this one what it computes. Every assertion added here uses an unbounded quantifier, so they hold either way, but the two do touch the same two test functions.

`_guaranteesForwardProgressImpl` walks the flattened node list with a
cursor, and several of its cases answer without visiting every child:
`contains` stops at the first child of a concatenation that progresses,
`allSatisfy` at the first alternative that doesn't, and a lookahead or a
zero-lower-bound quantification doesn't look at its child at all. When a
case stopped early, the cursor was left in the middle of the subtree, and
the caller then read a node from inside it as the next sibling.

In `(?:.c|)*` the first alternative is the concatenation `.c`. Deciding it
only takes `.`, so `c` was read as the second, empty alternative. That made
every alternative look like it progresses, so no progress check was emitted
and matching the empty alternative looped forever - or grew until an
allocation failed.

Find the end of the node's subtree up front and restore the cursor to it on
the way out, so the invariant holds however much of the subtree a case needs
to look at.

Fixes swiftlang#897, fixes swiftlang#894.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Infinite loop: forward-progress analysis loses its place (contains/allSatisfy short-circuit), e.g. (?:.c|)*x

1 participant