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:
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.
- 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.
First — thank you for the extremely quick turnaround on #4451. Reported and resolved in
4.51.10within a day, and the vendored-nest_asynciofix is exactly the right shape: it keepsapply()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 withstdin/stdout/stderrall set tosubprocess.PIPE, keeps onlybrowser.pid, and lets thePopenobject 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:
ResourceWarning: unclosed fileat unpredictable times. Because thePopenis unreachable, the handles are closed only when the GC gets to them — which under-W error(or pytest'sfilterwarnings = ["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.Environment
The code
seleniumbase/undetected/__init__.py:293-302:browseris a local. Once__init__returns, thePopenand its three handles are unreachable from anything.Nothing reads them —
grep -c '\.stdout\|\.stderr\|\.stdin\|communicate' seleniumbase/undetected/__init__.pyreturns 0.And
quit()(:587) closes the browser process but not the pipes:Worth noting
Popen.__del__does not close pipes — it only warns if the child is still running. Onlycommunicate()or thePopencontext manager's__exit__close them ("on exit, standard file descriptors are closed, and the process is waited for").Reproduction
Actual output on 4.51.10 / Python 3.14.6 / Linux:
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 theBuffered*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__:points at
undetected/__init__.py:293.Suggested fix
Since the streams are never read, the smallest change is to not create them:
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 inquit()alongside the existingos.kill/os.waitpid— the same thing Selenium's ownService._terminate_processdoes for chromedriver.Happy to open a PR for either shape if that's useful.