Follow-up to the second-stop() behaviour I mentioned in #4454 — you asked for the minimal repro, so here it is.
Repro
import warnings
from seleniumbase import sb_cdp
sb = sb_cdp.Chrome("about:blank", headless=True)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
sb.quit()
print("after 1st quit:", [str(w.message)[:70] for w in caught])
n = len(caught)
sb.quit()
print("after 2nd quit:", [str(w.message)[:70] for w in caught[n:]])
Run with python -W always repro.py.
Actual output
after 1st quit: []
after 2nd quit: ["coroutine 'Connection.aclose' was never awaited"]
.../asyncio/base_events.py:758: ResourceWarning: unclosed event loop <_UnixSelectorEventLoop running=False closed=False debug=False>
First quit() is clean. The second orphans the coroutine and abandons an event loop.
Expected
A second quit() is a no-op, as it was through 4.51.10.
Environment
SeleniumBase 4.51.11 (latest), Python 3.14.6, Linux. Re-run today against a clean install.
Where it comes from
Browser.quit() is self.stop(). In 4.51.11 stop() resolves a loop from a fallback chain (self.loop → connection.loop → websocket._loop → policy → get_event_loop) instead of asyncio.get_running_loop(). On the second call that loop is already closed, so loop.run_until_complete(self.connection.aclose()) raises after the coroutine object has been constructed, and the except RuntimeError fallback at cdp_driver/browser.py:963 never awaits the first one. The pre-4.51.11 code raised before constructing it, which is why this never surfaced. The abandoned loop looks like it comes from the asyncio.set_event_loop(asyncio.new_event_loop()) in stop().
Why anything would call it twice
Fair question, and you're right that once is enough. In my case it was two independent teardown paths that could both fire — an explicit close, and a lifecycle path that also closes — with nothing between them saying "already done". That is my bug, and I've fixed it on my side with a latch so a second close never reaches the driver.
I'm raising it anyway because the change is a silent behavioural regression rather than a missing feature: the same double call was harmless through 4.51.10 and now leaks a coroutine and a loop. Anything with a finally:-plus-atexit shape will hit it, and the warning points at an internal frame rather than the caller's, so it isn't obvious where it came from.
Not urgent for me — happy for you to close it if you'd rather the contract be "call it once".
Follow-up to the second-
stop()behaviour I mentioned in #4454 — you asked for the minimal repro, so here it is.Repro
Run with
python -W always repro.py.Actual output
First
quit()is clean. The second orphans the coroutine and abandons an event loop.Expected
A second
quit()is a no-op, as it was through 4.51.10.Environment
SeleniumBase 4.51.11 (latest), Python 3.14.6, Linux. Re-run today against a clean install.
Where it comes from
Browser.quit()isself.stop(). In 4.51.11stop()resolves a loop from a fallback chain (self.loop→connection.loop→websocket._loop→ policy →get_event_loop) instead ofasyncio.get_running_loop(). On the second call that loop is already closed, soloop.run_until_complete(self.connection.aclose())raises after the coroutine object has been constructed, and theexcept RuntimeErrorfallback atcdp_driver/browser.py:963never awaits the first one. The pre-4.51.11 code raised before constructing it, which is why this never surfaced. The abandoned loop looks like it comes from theasyncio.set_event_loop(asyncio.new_event_loop())instop().Why anything would call it twice
Fair question, and you're right that once is enough. In my case it was two independent teardown paths that could both fire — an explicit close, and a lifecycle path that also closes — with nothing between them saying "already done". That is my bug, and I've fixed it on my side with a latch so a second close never reaches the driver.
I'm raising it anyway because the change is a silent behavioural regression rather than a missing feature: the same double call was harmless through 4.51.10 and now leaks a coroutine and a loop. Anything with a
finally:-plus-atexitshape will hit it, and the warning points at an internal frame rather than the caller's, so it isn't obvious where it came from.Not urgent for me — happy for you to close it if you'd rather the contract be "call it once".