Skip to content

Commit ebec683

Browse files
Fix stack overflow in Deferred parsers with infinite recursion (#270)
* Initial plan * Add cycle detection for infinite recursion in Deferred parsers Co-authored-by: sebastienros <[email protected]> * Fix typo in ParseContext comment Co-authored-by: sebastienros <[email protected]> * Add DisableLoopDetection option to ParseContext Co-authored-by: sebastienros <[email protected]> * Use private record struct for HashSet key optimization Co-authored-by: sebastienros <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: sebastienros <[email protected]> Co-authored-by: Sébastien Ros <[email protected]>
1 parent b4c8073 commit ebec683

File tree

3 files changed

+133
-0
lines changed

3 files changed

+133
-0
lines changed

src/Parlot/Fluent/Deferred.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,31 @@ public override bool Parse(ParseContext context, ref ParseResult<T> result)
5252
throw new InvalidOperationException("Parser has not been initialized");
5353
}
5454

55+
// Check for infinite recursion at the same position (unless disabled)
56+
if (!context.DisableLoopDetection && context.IsParserActiveAtPosition(this))
57+
{
58+
// Cycle detected at this position - fail gracefully instead of stack overflow
59+
return false;
60+
}
61+
62+
// Remember the position where we entered this parser
63+
var entryPosition = context.Scanner.Cursor.Position.Offset;
64+
65+
// Mark this parser as active at the current position (unless loop detection is disabled)
66+
var trackPosition = !context.DisableLoopDetection && context.PushParserAtPosition(this);
67+
5568
context.EnterParser(this);
5669

5770
var outcome = Parser.Parse(context, ref result);
5871

5972
context.ExitParser(this);
73+
74+
// Mark this parser as inactive at the entry position (only if we tracked it)
75+
if (trackPosition)
76+
{
77+
context.PopParserAtPosition(this, entryPosition);
78+
}
79+
6080
return outcome;
6181
}
6282

src/Parlot/Fluent/ParseContext.cs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Collections.Generic;
23
using System.Runtime.CompilerServices;
34
using System.Threading;
45

@@ -15,6 +16,16 @@ public class ParseContext
1516
/// </summary>
1617
public int CompilationThreshold { get; set; } = DefaultCompilationThreshold;
1718

19+
/// <summary>
20+
/// Whether to disable loop detection for recursive parsers. Default is <c>false</c>.
21+
/// </summary>
22+
/// <remarks>
23+
/// When <c>false</c>, loop detection is enabled and will prevent infinite recursion at the same position.
24+
/// When <c>true</c>, loop detection is disabled. This may be needed when the ParseContext itself is mutated
25+
/// during loops and can change the end result of parsing at the same location.
26+
/// </remarks>
27+
public bool DisableLoopDetection { get; set; }
28+
1829
/// <summary>
1930
/// Whether new lines are treated as normal chars or white spaces. Default is <c>false</c>.
2031
/// </summary>
@@ -29,6 +40,11 @@ public class ParseContext
2940
/// </summary>
3041
public readonly Scanner Scanner;
3142

43+
/// <summary>
44+
/// Tracks parser-position pairs to detect infinite recursion at the same position.
45+
/// </summary>
46+
private readonly HashSet<ParserPosition> _activeParserPositions = new();
47+
3248
/// <summary>
3349
/// The cancellation token used to stop the parsing operation.
3450
/// </summary>
@@ -115,4 +131,42 @@ public void ExitParser<T>(Parser<T> parser)
115131
{
116132
OnExitParser?.Invoke(parser, this);
117133
}
134+
135+
/// <summary>
136+
/// Checks if a parser is already active at the current position.
137+
/// </summary>
138+
/// <param name="parser">The parser to check.</param>
139+
/// <returns>True if the parser is already active at the current position, false otherwise.</returns>
140+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
141+
public bool IsParserActiveAtPosition(object parser)
142+
{
143+
return _activeParserPositions.Contains(new ParserPosition(parser, Scanner.Cursor.Position.Offset));
144+
}
145+
146+
/// <summary>
147+
/// Marks a parser as active at the current position.
148+
/// </summary>
149+
/// <param name="parser">The parser to mark as active.</param>
150+
/// <returns>True if the parser was added (not previously active at this position), false if it was already active at this position.</returns>
151+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
152+
public bool PushParserAtPosition(object parser)
153+
{
154+
return _activeParserPositions.Add(new ParserPosition(parser, Scanner.Cursor.Position.Offset));
155+
}
156+
157+
/// <summary>
158+
/// Marks a parser as inactive at the current position.
159+
/// </summary>
160+
/// <param name="parser">The parser to mark as inactive.</param>
161+
/// <param name="position">The position offset where the parser was entered.</param>
162+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
163+
public void PopParserAtPosition(object parser, int position)
164+
{
165+
_activeParserPositions.Remove(new ParserPosition(parser, position));
166+
}
167+
168+
/// <summary>
169+
/// Represents a parser instance at a specific position for cycle detection.
170+
/// </summary>
171+
private readonly record struct ParserPosition(object Parser, int Position);
118172
}

test/Parlot.Tests/FluentTests.cs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1529,4 +1529,63 @@ public void WithWhiteSpaceParserShouldWorkWithMultipleCharWhiteSpace()
15291529
Assert.Equal("hello", result.Item1.ToString());
15301530
Assert.Equal("world", result.Item2.ToString());
15311531
}
1532+
1533+
[Fact]
1534+
public void DeferredShouldDetectInfiniteRecursion()
1535+
{
1536+
// Test case 1: Direct self-reference
1537+
var loop = Deferred<string>();
1538+
loop.Parser = loop;
1539+
1540+
// Should fail gracefully instead of causing stack overflow
1541+
Assert.False(loop.TryParse("hello parlot", out var result1));
1542+
Assert.Null(result1);
1543+
}
1544+
1545+
[Fact]
1546+
public void RecursiveShouldDetectInfiniteRecursion()
1547+
{
1548+
// Test case 2: Recursive self-reference
1549+
var loop = Recursive<string>(c => c);
1550+
1551+
// Should fail gracefully instead of causing stack overflow
1552+
Assert.False(loop.TryParse("hello parlot", out var result2));
1553+
Assert.Null(result2);
1554+
}
1555+
1556+
[Fact]
1557+
public void DeferredShouldAllowValidRecursion()
1558+
{
1559+
// Valid recursive parser - should still work
1560+
// This represents a simple recursive grammar like: list ::= '[' (item (',' item)*)? ']'
1561+
var list = Deferred<string>();
1562+
var item = Literals.Text("item");
1563+
var comma = Literals.Char(',');
1564+
var openBracket = Literals.Char('[');
1565+
var closeBracket = Literals.Char(']');
1566+
1567+
// A list can contain items or nested lists
1568+
var element = item.Or(list);
1569+
var elements = ZeroOrMany(element.And(ZeroOrOne(comma)));
1570+
list.Parser = Between(openBracket, elements, closeBracket).Then(x => "list");
1571+
1572+
// This should work fine - it's recursive but makes progress
1573+
Assert.True(list.TryParse("[]", out var result));
1574+
Assert.Equal("list", result);
1575+
}
1576+
1577+
[Fact]
1578+
public void DisableLoopDetectionShouldAllowInfiniteRecursion()
1579+
{
1580+
// When DisableLoopDetection is true, the parser should not detect infinite loops
1581+
var loop = Deferred<string>();
1582+
loop.Parser = loop;
1583+
1584+
// Test with loop detection enabled (default)
1585+
var contextWithDetection = new ParseContext(new Scanner("test")) { DisableLoopDetection = false };
1586+
Assert.False(loop.TryParse(contextWithDetection, out var _, out var _));
1587+
1588+
// We can't safely test the DisableLoopDetection = true case to completion without stack overflow,
1589+
// but the implementation is verified by the fact that the flag is properly checked in the code
1590+
}
15321591
}

0 commit comments

Comments
 (0)