Skip to content

Browser launch leaks three stdio pipes per session (unread PIPEs, dropped Popen) — plus a latent pipe-buffer deadlock #4454

Description

@crippledgeek

First — thank you for the extremely quick turnaround on #4451. Reported and resolved in 4.51.10 within a day, and the vendored-nest_asyncio fix is exactly the right shape: it keeps apply() working rather than removing it, so nothing downstream had to change. Much appreciated.

Here is another one from the same area, found while chasing a nondeterministic test suite.

Summary

Chrome.__init__ launches the browser with stdin/stdout/stderr all set to subprocess.PIPE, keeps only browser.pid, and lets the Popen object go out of scope. Nothing ever reads those pipes and nothing ever closes them, so three file descriptors per browser leak until the garbage collector finalises them.

Two consequences:

  1. ResourceWarning: unclosed file at unpredictable times. Because the Popen is unreachable, the handles are closed only when the GC gets to them — which under -W error (or pytest's filterwarnings = ["error"]) fails whichever test happens to be running at that moment. In our suite this produced 12 / 11 / 11 / 0 / 0 failures across five runs of an unchanged tree, with zero assertion failures.
  2. A latent deadlock. A pipe with no reader blocks the writer once the buffer fills (~64 KB on Linux). If Chrome ever writes that much to stderr, it blocks forever.

Environment

  • SeleniumBase 4.51.10
  • Python 3.14.6 (CPython, uv-managed)
  • Linux

The code

seleniumbase/undetected/__init__.py:293-302:

            else:
                gui_lock = FileLock(constants.MultiBrowser.PYAUTOGUILOCK)
                with gui_lock:
                    shared_utils.make_writable(
                        constants.MultiBrowser.PYAUTOGUILOCK
                    )
                    browser = subprocess.Popen(          # <- line 293
                        [options.binary_location, *options.arguments],
                        stdin=subprocess.PIPE,
                        stdout=subprocess.PIPE,
                        stderr=subprocess.PIPE,
                        close_fds=IS_POSIX,
                        creationflags=creationflags,
                    )
                    self.browser_pid = browser.pid       # <- only the pid is kept
            self._process_pid = browser.pid

browser is a local. Once __init__ returns, the Popen and its three handles are unreachable from anything.

Nothing reads them — grep -c '\.stdout\|\.stderr\|\.stdin\|communicate' seleniumbase/undetected/__init__.py returns 0.

And quit() (:587) closes the browser process but not the pipes:

            os.kill(self.browser_pid, 15)

Worth noting Popen.__del__ does not close pipes — it only warns if the child is still running. Only communicate() or the Popen context manager's __exit__ close them ("on exit, standard file descriptors are closed, and the process is waited for").

Reproduction

import gc, warnings
from seleniumbase import SB

warnings.simplefilter("always", ResourceWarning)

with SB(uc=True, test=True, headless=True) as sb:
    sb.activate_cdp_mode("about:blank")

gc.collect()   # only to make the timing deterministic for this demo

Actual output on 4.51.10 / Python 3.14.6 / Linux:

subprocess.py:1139: ResourceWarning: subprocess 1402474 is still running
<sys>:0: ResourceWarning: unclosed file <_io.BufferedReader name=14>
<sys>:0: ResourceWarning: unclosed file <_io.BufferedWriter name=11>
<sys>:0: ResourceWarning: unclosed file <_io.BufferedReader name=12>
subprocess.py:1139: ResourceWarning: subprocess 1402910 is still running
<sys>:0: ResourceWarning: unclosed file <_io.BufferedReader name=18>
<sys>:0: ResourceWarning: unclosed file <_io.BufferedWriter name=15>
<sys>:0: ResourceWarning: unclosed file <_io.BufferedReader name=16>

Six handles per launch, in two groups of three — one group is this Popen, the other is chromedriver's (selenium/webdriver/common/service.py, reported separately to Selenium). Depending on which object the collector finalises first you may see the raw <_io.FileIO name=NN mode='rb'> instead of the Buffered* wrapper; both name the same descriptor.

Attributing them needs PYTHONTRACEMALLOC — an ordinary traceback names the finaliser, not the allocation, which is why this initially looks like it comes from __getattribute__:

PYTHONTRACEMALLOC=30 python repro.py

points at undetected/__init__.py:293.

Suggested fix

Since the streams are never read, the smallest change is to not create them:

                    browser = subprocess.Popen(
                        [options.binary_location, *options.arguments],
                        stdin=subprocess.DEVNULL,
                        stdout=subprocess.DEVNULL,
                        stderr=subprocess.DEVNULL,
                        close_fds=IS_POSIX,
                        creationflags=creationflags,
                    )

That fixes the leak and the deadlock together, and needs no teardown changes.

If the pipes are wanted for a future use, the alternative is to keep the object (self._browser = browser) and close them in quit() alongside the existing os.kill / os.waitpid — the same thing Selenium's own Service._terminate_process does for chromedriver.

Happy to open a PR for either shape if that's useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions