microsoft / microsoft/playwright

[Bug]: Firefox keeps every navigated document alive after page.mouse.move on a page embedding hCaptcha

Open
#42,659 2 comments 0 reactions 1 assignee Claimed by @dgozman View on GitHub
v1.64
Dominant language
TypeScript
Stars
96.3k
Forks
6.5k
Avg merge
1d 6h
Merged PRs (30d)
180

Description

### Version

1.62.0

### Steps to reproduce

Run the script below (Linux; it uses Firefox's `SIGRTMIN` memory-report dump to count live documents). It serves a local page that embeds hCaptcha's public test widget (`10000000-ffff-ffff-ffff-000000000001`, invisible size), navigates the same tab to it 8 times, and optionally calls `page.mouse.move` once per navigation.

```python
"""Firefox under Playwright keeps every navigated document alive after page.mouse.move
on a page that embeds hCaptcha (public test sitekey). Run: python repro_pointer_retention.py
Prints the count of live top-level documents after 8 same-tab navigations, with and
without one mouse.move per navigation. Linux only (uses SIGRTMIN memory reports)."""
import glob, gzip, json, os, re, signal, threading, time
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from playwright.sync_api import sync_playwright

PAGE = b"""


window.big = new Uint8Array(20 * 1024 * 1024);"""

class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if not self.path.startswith("/page"):
self.send_response(404); self.end_headers(); return
self.send_response(200); self.send_header("Content-Type", "text/html")
self.send_header("Cache-Control", "no-store"); self.end_headers(); self.wfile.write(PAGE)
def log_message(self, *args): pass

def firefox_parent_pid():
for pid in os.listdir("/proc"):
if pid.isdigit():
try:
if (Path("/proc") / pid / "comm").read_text().strip() == "firefox" and int((Path("/proc") / pid / "stat").read_text().split()[3]) != 1:
return int(pid)
except OSError: pass
raise RuntimeError("firefox parent not found")

def memory_report(parent):
before = set(glob.glob("/tmp/unified-memory-report-*"))
os.kill(parent, signal.SIGRTMIN + 1) # force GC + CC first
time.sleep(10)
os.kill(parent, signal.SIGRTMIN)
for _ in range(40):
time.sleep(1)
new = set(glob.glob("/tmp/unified-memory-report-*")) - before
if new: break
time.sleep(3)
reports = json.load(gzip.open(sorted(new)[0]))["reports"]
top = sum(1 for r in reports if r["units"] == 0 and re.search(r"/window\(http:\\\\127\.0\.0\.1[^)]*\)/dom/element-nodes$", r["path"]))
frames = sum(1 for r in reports if r["units"] == 0 and re.search(r"/window\(https:\\\\newassets\.hcaptcha\.com[^)]*\)/dom/element-nodes$", r["path"]))
return top, frames

server = HTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
url = f"http://127.0.0.1:{server.server_address[1]}/page"
with sync_playwright() as pw:
for move in (True, False):
browser = pw.firefox.launch(headless=True)
page = browser.new_page()
for i in range(8):
page.goto(url, wait_until="domcontentloaded"); page.wait_for_timeout(2500)
if move: page.mouse.move(300 + i * 5, 220)
page.wait_for_timeout(1500)
top, frames = memory_report(firefox_parent_pid())
print(f"mouse.move per navigation={move}: live top-level documents={top}, live hCaptcha frame documents={frames}")
browser.close()
```

### Expected behavior

After 8 same-tab navigations, one top-level document is alive. Old documents are collected after navigation regardless of whether the mouse moved over them.

### Actual behavior

With one `page.mouse.move` per navigation, every previous document stays alive, together with its hCaptcha frame documents, and a forced GC + cycle collection frees none of them:

```
mouse.move per navigation=True: live top-level documents=8, live hCaptcha frame documents=9
mouse.move per navigation=False: live top-level documents=1, live hCaptcha frame documents=2
```

In a long-running scraper that navigates one tab repeatedly and clicks on each page, this grows the content process by roughly 25 MB per navigation until the kernel kills it. The retained documents show up in the memory report as `explicit/window-objects/top(, id=N)/active/window()/...` entries repeated once per navigation (same outer window id), plus `top(none)/detached/window(https://newassets.hcaptcha.com/...)` entries, and `ghost-windows` stays 0, so something still references them.

What does and does not trigger it, measured on the same page:

- `page.goto` only, `page.evaluate`, locators, `wait_for_function`, `Locator.press("Enter")`, `Locator.focus()`: no retention.
- `page.mouse.down()` / `page.mouse.up()` without a move: no retention.
- `page.mouse.move`, `page.mouse.click`, `page.click`, `Locator.click` (all of which move the pointer): retention.
- `Locator.dispatch_event("click")`: no retention.
- A plain local page without the hCaptcha widget: no retention with the same `mouse.move` calls, so the widget's pointer handling seems to be part of the cycle.

Reproduced with stock Playwright Firefox 153.0 (build 1538) and with Camoufox 152.0.4-beta.30 (a Playwright Firefox fork), with the same counts.

### Additional context

Workaround in use: drive the form with keyboard input only (`Locator.focus()` + typing + `Locator.press("Enter")`), which keeps memory flat over 40 navigations.

### Environment

```
- Operating System: Linux 7.2.2 (CachyOS, Fedora 44 base)
- CPU: x86_64
- Browser: Firefox (playwright firefox v1538, Firefox 153.0)
- Python Version: 3.13.15
- Other info: playwright 1.62.0 Python; headless=True
```

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.