Skip to content

Fix IndexError in read_game on an unmatched ) after an illegal move - #1204

Open
eeshsaxena wants to merge 2 commits into
niklasf:masterfrom
eeshsaxena:fix/pgn-variation-stack-underflow
Open

Fix IndexError in read_game on an unmatched ) after an illegal move#1204
eeshsaxena wants to merge 2 commits into
niklasf:masterfrom
eeshsaxena:fix/pgn-variation-stack-underflow

Conversation

@eeshsaxena

Copy link
Copy Markdown

Came across this feeding some scraped PGNs through read_game: a short game like

1. e4 Nf3 ) e5 *

raises IndexError: list index out of range from GameBuilder.visit_move instead of being parsed with the bad move recorded in game.errors.

What happens: Nf3 is illegal for Black, so the parser calls handle_error and sets skip_variation_depth = 1 to skip to the end of the (assumed) variation. The following ) closes that skip and calls end_variation(), but since the error was on the mainline there was never a matching begin_variation(). end_variation() pops variation_stack unconditionally, so it removes the root game node and empties the stack; the next move (e5) then does self.variation_stack[-1] and raises IndexError.

read_game already guards the normal ) path with len(board_stack) > 1, and begin_variation() asserts the root is never pushed as a variation, so I made end_variation() symmetric: it keeps the root on the stack. With that, the example parses to the e4 e5 mainline with one recorded error, and real variations are unaffected.

Extended test_variation_stack with this case (it sits right next to the existing superfluous-bracket cases). It raises IndexError on master and passes with the change; the full PGN test suite still passes. Found it by fuzzing read_game with mutated PGNs.

read_game recovers from an illegal move by entering skip mode, and a later
unmatched ')' closes that skip by calling end_variation(). When the error
happened on the mainline there was no matching begin_variation(), so the
unconditional variation_stack.pop() removed the root game node, leaving the
stack empty and making the next visit_move() raise IndexError. Keep the root
on the stack, mirroring the assert in begin_variation().
@eeshsaxena

Copy link
Copy Markdown
Author

Hi! Gentle nudge on this one whenever you have some bandwidth. It's a small, self-contained fix (Fix IndexError in read_game on an unmatched ) after an illegal move), and it's currently mergeable with no conflicts. No urgency at all, and I'm happy to make any changes you'd like. Thanks for maintaining python-chess!

@niklasf

niklasf commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Hi. Thanks for reporting and analysing the issue. I think the fix belongs into the read_game() function, so that all the visitors can benefit.

Instead of guarding GameBuilder.end_variation against an empty stack, keep the
parser from calling end_variation() for a ")" that never opened a variation.
An illegal move sets skip_variation_depth via error recovery, not through
begin_variation(); track that distinction with skip_variation_from_begin so the
closing ")" only ends a variation that a SKIP begin_variation() opened or a
real variation whose tail the error skipped. A top-level error's trailing ")"
is now ignored, keeping begin/end_variation balanced for every visitor.

Reverts the GameBuilder.end_variation guard and adds a bare-visitor test that
begin/end_variation stay balanced on the reported input.
@eeshsaxena

Copy link
Copy Markdown
Author

Thanks, that makes sense. Moved the fix into read_game() and reverted the GameBuilder.end_variation change.

The root cause is that an illegal move triggers error recovery which sets skip_variation_depth = 1 without ever calling begin_variation(), so the following ) closed a variation that was never opened. The parser now tracks whether the skipped level came from a real begin_variation() (returning SKIP) versus error recovery, and only calls end_variation() when the ) actually closes an opened or real variation. A top-level unmatched ) is ignored, which keeps begin_variation/end_variation balanced for every visitor, not just GameBuilder.

Added a test with a bare BaseVisitor asserting the calls stay balanced on 1. e4 Nf3 ) e5 *, alongside the existing mainline check. mypy --strict chess and the full PgnTestCase suite pass locally.

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.

2 participants