[js] Fix MaxListenersExceededWarning in BiDi send#17423
[js] Fix MaxListenersExceededWarning in BiDi send#17423devanngg wants to merge 7 commits intoSeleniumHQ:trunkfrom
Conversation
Replace per-send 'message' listener with a single shared response dispatcher that routes by request id. Concurrent BiDi traffic, such as network interception during a navigation, previously attached a new listener for every in-flight send() and tripped Node's MaxListenersExceededWarning at the 11th concurrent request. Pending requests are now tracked in a Map keyed by id; the dispatcher clears the per-request timeout, removes the entry, and resolves the caller. close() rejects any outstanding pendings so callers don't hang on shutdown. Adds a regression test that asserts no warning is emitted after 50 concurrent sends and that the message-listener count on the underlying WebSocket remains constant. Fixes SeleniumHQ#17380
Review Summary by QodoFix MaxListenersExceededWarning in BiDi send with shared dispatcher
WalkthroughsDescription• Replaces per-send 'message' listener with single shared dispatcher • Tracks pending requests in Map keyed by request id for routing • Prevents MaxListenersExceededWarning under concurrent BiDi traffic • Rejects outstanding requests on connection close to prevent hangs File Changes1. javascript/selenium-webdriver/bidi/index.js
|
Code Review by Qodo
1.
|
Address review feedback on the MaxListenersExceededWarning fix: - Emit an 'error' event (or process.emitWarning when no listener) on JSON parse failures so callers see deterministic protocol errors instead of opaque send() timeouts. Document that messages without a numeric id are valid BiDi events and intentionally ignored by the response dispatcher. - Hook the WebSocket 'close' and 'error' events. If the peer disconnects mid-request the in-flight send() promises now reject promptly via a shared _failPending() helper instead of waiting for RESPONSE_TIMEOUT (30s). A _closed flag keeps the helper idempotent so close() and the underlying 'close' event do not double-reject. Adds regression tests for both behaviors.
|
Persistent review updated to latest commit 7e77a5b |
Address review feedback: after the underlying socket fails or closes, _failPending() sets connected=false but waitForConnection() previously only resolved on a future 'open' event, so a subsequent send() could hang indefinitely instead of surfacing the closed state. - send() now rejects immediately when _closed is true. - waitForConnection() rejects on 'close'/'error' if it is waiting for 'open', and short-circuits to a rejection when _closed is already set, so it can no longer leak a pending promise on a dead socket. Adds a regression test asserting send() rejects with a clear error once the peer has dropped the connection.
|
Persistent review updated to latest commit 1bc5eb1 |
|
Persistent review updated to latest commit c058c72 |
Address review feedback: close() called removeAllListeners('close')
on the underlying socket before it actually closed, which could strip
the rejection listener that waitForConnection() registered. A close()
issued while another caller was awaiting connection could therefore
leave that wait pending forever.
Replace the per-call socket listener juggling in waitForConnection()
with a centrally-tracked Set of {resolve, reject} entries:
- The constructor's 'open' handler resolves all parked waiters.
- _failPending() rejects them, so an unexpected disconnect or an
explicit close() reliably unblocks any in-flight waitForConnection().
This removes the dependency on socket listener ordering inside
close(), which is what made the bug possible.
Adds a regression test using a plain-TCP server that accepts but
never completes the WebSocket upgrade, calls close() while the
client is still CONNECTING, and asserts the wait rejects.
|
Persistent review updated to latest commit 3a72849 |
|
Persistent review updated to latest commit 98e4e83 |
Address review feedback and proactively close adjacent seams in the connection state machine: - 'open' handler now early-returns when _closed is set and proactively closes the now-orphan socket. Previously a handshake that completed after close() left the instance with _closed=true AND connected=true. - 'message' handler early-returns when _closed is set. Frames arriving after close() are no longer processed (parse errors emitted, lookups attempted) on a closed connection. - send() additionally checks WebSocket.readyState !== OPEN after waitForConnection() resolves, so sends fail with a clear error rather than throwing from inside ws.send() if the raw socket has transitioned to CLOSING/CLOSED. Adds a regression test that calls close() before the WebSocket has opened (against an echo server, where the handshake will succeed moments later) and asserts isConnected stays false.
|
Persistent review updated to latest commit dfda96f |
User description
Calling
network.continueRequest()from abeforeRequestSenthandlerduring a single page navigation triggers
MaxListenersExceededWarningfrom Node, because the BiDisend()implementation attached a fresh
'message'listener on the underlyingWebSocket for every in-flight request. Once 11 requests were in
flight (typical with even a small number of intercepted subresources),
Node's default EventEmitter limit was tripped:
(node:47356) MaxListenersExceededWarning: Possible EventEmitter
memory leak detected. 11 message listeners added to [WebSocket].
MaxListeners is 10.
at .../selenium-webdriver/bidi/index.js:117:32
PR Type
Bug fix.
Description
send()listener with a single shared responsedispatcher attached once in the constructor.
Mapkeyed by request id; thedispatcher looks up the pending entry, clears its timeout, and
resolves the caller.
close()now rejects any outstanding pendings with a clear errorinstead of leaving callers hanging when the connection goes away
mid-request.
Tests
test/bidi/index_test.jsruns an in-processwsecho server and asserts:MaxListenersExceededWarningis observed after 50 concurrentsend()calls.'message'listener count on the underlying WebSocket staysconstant whether 0 or 25 requests are in flight.
bazel test //javascript/selenium-webdriver:small-tests
//javascript/selenium-webdriver:small-tests PASSED in 13.0s
Cross-binding
Java (
org.openqa.selenium.bidi.Connection) and Python(
websocket_connection) already use single demuxing handlers, so JSwas the outlier; no parity follow-up needed.
Motivation and Context
Fixes #17380. Network interception is now safe to use during
navigations without spurious warnings (or, in larger pages, the
listener count growing unbounded under repeated retries).
AI assistance disclosure
Parts of this change were drafted with Claude (Anthropic). The
author reviewed and verified all code, ran the regression tests
locally, and is responsible for the final result. AI was used for:
test/bidi/index_test.js,Types of changes
functionality to change)
Checklist
refactor, public API unchanged)