microsoft / microsoft/playwright
[Bug]: Firefox keeps every navigated document alive after page.mouse.move on a page embedding hCaptcha
- 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
Assessment
This issue has not been assessed yet.