Skip to content

Fix unbounded recursion in querySelector retry guard#71

Merged
nathanfallet merged 1 commit into
mainfrom
fix/queryselector-retry-recursion
May 22, 2026
Merged

Fix unbounded recursion in querySelector retry guard#71
nathanfallet merged 1 commit into
mainfrom
fix/queryselector-retry-recursion

Conversation

@nathanfallet
Copy link
Copy Markdown
Member

Summary

querySelector / querySelectorAll retry once when CDP reports "could not find node" (the node being searched within has gone stale, e.g. after a re-render), then give up. The "already retried" flag was stored in a method-local lastMap, recreated on every call. Since the retry is done by the method calling itself, the recursive call started with a fresh empty map — so the guard if (last == true) never tripped and the method recursed without bound on a persistent error → StackOverflowError / hang.

Fix

Thread the retry state through a private …Internal helper as a retried: Boolean parameter, so it survives the recursive call. Logic is otherwise unchanged: refresh the node, retry exactly once, then return null / empty list.

This matches the reference client's intent — zendriver (zendriver/core/tab.py:441-452) stores the flag on the _node object it threads through the recursion. A call-scoped parameter is equivalent and avoids mutating shared node state. zendriver is not affected by this bug; it was introduced in the Kotlin port.

Verification (red → green)

Added QuerySelectorRetryTest, which drives the real querySelector/querySelectorAll through a stubbed callCommand that always reports "could not find node" (no browser):

  • RED (before fix): recursion never terminates — the test caps invocations and fails loudly (IllegalStateException) instead of letting it grow into a StackOverflowError.
  • GREEN (after fix): exactly one attempt + one retry, then null / empty list.

Full :core:jvmTest (real headless Chrome) passes.

Test plan

  • ./gradlew :core:jvmTest --tests "*.QuerySelectorRetryTest" — red on unfixed code, green after fix
  • ./gradlew :core:jvmTest (real Chrome) — all pass

querySelector/querySelectorAll retry once when CDP reports "could not find
node" (the searched-within node went stale), then give up. The "already
retried" flag was stored in a method-local `lastMap` that is recreated on
every call. Because the retry is performed by the method calling itself, the
recursive call started with a fresh empty map, so the guard never tripped and
the method recursed without bound on a persistent error -> StackOverflowError
/ hang.

Thread the retry state through a private *Internal helper as a `retried`
parameter so it survives the recursive call. This matches zendriver's intent
(zendriver/core/tab.py stores the flag on the node it threads through the
recursion); a call-scoped parameter is equivalent without mutating shared
node state.

Verified red->green with QuerySelectorRetryTest, which drives the real methods
through a stubbed callCommand that always reports "could not find node": before
the fix the recursion never terminates (capped in the test to fail loudly);
after, it attempts exactly once + one retry then returns null/empty. Full
:core:jvmTest (real Chrome) passes.
@codecov
Copy link
Copy Markdown

codecov Bot commented May 22, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@nathanfallet nathanfallet merged commit 73c5991 into main May 22, 2026
5 checks passed
@nathanfallet nathanfallet deleted the fix/queryselector-retry-recursion branch May 22, 2026 19:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant