← Back to microsoft/playwright
microsoft / playwright · Issue No. 42659
1.62.0
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.
"""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"""<!doctype html><html><head>
<script src="https://js.hcaptcha.com/1/api.js" async defer></script></head><body>
<input style="position:absolute;left:200px;top:200px;width:200px;height:40px">
<div class="h-captcha" data-sitekey="10000000-ffff-ffff-ffff-000000000001" data-size="invisible"></div>
<script>window.big = new Uint8Array(20 * 1024 * 1024);</script></body></html>"""
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()
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.
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(<url>, id=N)/active/window(<url>)/... 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.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.
Workaround in use: drive the form with keyboard input only (Locator.focus() + typing + Locator.press("Enter")), which keeps memory flat over 40 navigations.
- 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
Relay reads this issue against the repository's contribution signals: the files it is likely to touch, how the maintainers triage work this size, and what the first contribution would exercise.
The full analysis for this issue is still being assembled. Until then, the description above and the thread on GitHub are the most reliable context.