Skip to content

[js] Fix MaxListenersExceededWarning in BiDi send#17423

Open
devanngg wants to merge 7 commits intoSeleniumHQ:trunkfrom
devanngg:fix/bidi-max-listeners-warning
Open

[js] Fix MaxListenersExceededWarning in BiDi send#17423
devanngg wants to merge 7 commits intoSeleniumHQ:trunkfrom
devanngg:fix/bidi-max-listeners-warning

Conversation

@devanngg
Copy link
Copy Markdown

@devanngg devanngg commented May 7, 2026

User description

Calling network.continueRequest() from a beforeRequestSent handler
during a single page navigation triggers
MaxListenersExceededWarning from Node, because the BiDi send()
implementation attached a fresh 'message' listener on the underlying
WebSocket 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

  • Replaces the per-send() listener with a single shared response
    dispatcher attached once in the constructor.
  • Tracks pending requests in a Map keyed by request id; the
    dispatcher looks up the pending entry, clears its timeout, and
    resolves the caller.
  • close() now rejects any outstanding pendings with a clear error
    instead of leaving callers hanging when the connection goes away
    mid-request.

Tests

  • New small mocha test test/bidi/index_test.js runs an in-process
    ws echo server and asserts:
    1. No MaxListenersExceededWarning is observed after 50 concurrent
      send() calls.
    2. The 'message' listener count on the underlying WebSocket stays
      constant whether 0 or 25 requests are in flight.
  • Verified locally:

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 JS
was 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:

  • drafting the regression test in test/bidi/index_test.js,

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing
    functionality to change)

Checklist

  • I have read the contributing document.
  • My change requires a change to the documentation. (no — internal
    refactor, public API unchanged)
  • I have added tests to cover my changes.
  • All new and existing tests passed.

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
@CLAassistant
Copy link
Copy Markdown

CLAassistant commented May 7, 2026

CLA assistant check
All committers have signed the CLA.

@qodo-code-review
Copy link
Copy Markdown
Contributor

Review Summary by Qodo

Fix MaxListenersExceededWarning in BiDi send with shared dispatcher

🐞 Bug fix

Grey Divider

Walkthroughs

Description
• 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

Grey Divider

File Changes

1. javascript/selenium-webdriver/bidi/index.js 🐞 Bug fix +31/-16

Replace per-send listeners with shared response dispatcher

• Adds _pending Map to track in-flight requests by id
• Implements single shared 'message' listener in constructor that dispatches responses by request id
• Removes per-send listener attachment that caused warning accumulation
• Enhances close() to reject pending requests with clear error message

javascript/selenium-webdriver/bidi/index.js


2. javascript/selenium-webdriver/test/bidi/index_test.js 🧪 Tests +94/-0

Add regression tests for concurrent BiDi sends

• New regression test file with in-process WebSocket echo server
• Asserts no MaxListenersExceededWarning after 50 concurrent sends
• Verifies message listener count remains constant during in-flight requests
• Tests both before, during, and after concurrent request phases

javascript/selenium-webdriver/test/bidi/index_test.js


3. javascript/selenium-webdriver/BUILD.bazel ⚙️ Configuration changes +2/-0

Update build configuration for new BiDi tests

• Adds new test file test/bidi/index_test.js to SMALL_TESTS list
• Adds ws dependency to mocha_test data for WebSocket server support

javascript/selenium-webdriver/BUILD.bazel


Grey Divider

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown
Contributor

qodo-code-review Bot commented May 7, 2026

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (2)

Grey Divider


Action required

1. Connected true after close ✓ Resolved 🐞 Bug ☼ Reliability
Description
The WebSocket 'open' handler unconditionally sets connected=true even if close()/_failPending()
already marked the connection closed, which can leave the instance in an inconsistent state
(_closed===true but isConnected===true). This can happen when close() is called during CONNECTING
and the handshake completes anyway before the close event fires.
Code

javascript/selenium-webdriver/bidi/index.js[R40-46]

    this._ws.on('open', () => {
      this.connected = true
+      for (const { resolve } of this._connectWaiters) {
+        resolve()
+      }
+      this._connectWaiters.clear()
+    })
Evidence
Index tracks closure via _closed in _failPending(), and close() calls _failPending() immediately;
however, the 'open' handler does not check _closed before setting connected=true, so an 'open' event
arriving after close initiation can re-flip connected to true despite the object being logically
closed.

javascript/selenium-webdriver/bidi/index.js[39-46]
javascript/selenium-webdriver/bidi/index.js[96-113]
javascript/selenium-webdriver/bidi/index.js[275-277]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The WebSocket `open` handler sets `this.connected = true` even if `_closed` was already set by `close()`/`_failPending()`, allowing `isConnected` to become true after the connection is logically closed.

### Issue Context
`close()` calls `_failPending()` immediately (setting `_closed=true`), but does not remove/guard the constructor `open` listener. If `open` fires after `close()` is initiated, the state becomes inconsistent.

### Fix Focus Areas
- javascript/selenium-webdriver/bidi/index.js[39-46]
- javascript/selenium-webdriver/bidi/index.js[96-113]
- javascript/selenium-webdriver/bidi/index.js[275-295]

### Suggested change
In the `open` handler, early-return if `this._closed` is true (and optionally immediately `this._ws.close()`/`terminate()`), so `connected` cannot be re-enabled after closure.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. close() can hang waiters ✓ Resolved 🐞 Bug ☼ Reliability
Description
Index.waitForConnection() depends on a WebSocket 'close'/'error' listener to reject when the socket
dies before opening, but Index.close() removes all 'close' listeners before initiating ws.close(),
which can remove waitForConnection()’s onFailure handler. If close() is called while a send() is
awaiting waitForConnection(), that wait can remain pending indefinitely.
Code

javascript/selenium-webdriver/bidi/index.js[R273-277]

+    this._failPending(new Error('BiDi connection closed before response was received'))
+
    const closeWebSocket = (callback) => {
      // don't close if it's already closed
      if (this._ws.readyState === 3) {
Evidence
waitForConnection() installs a one-time 'close' listener to reject if the socket closes before
opening, and send() awaits waitForConnection() when not connected; close() removes all 'close'
listeners before closing the socket, which can remove that rejection path and leave the wait promise
unresolved.

javascript/selenium-webdriver/bidi/index.js[133-155]
javascript/selenium-webdriver/bidi/index.js[163-183]
javascript/selenium-webdriver/bidi/index.js[272-291]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`waitForConnection()` uses `this._ws.once('close'|'error', ...)` to reject when the socket fails before opening. But `close()` calls `this._ws.removeAllListeners('close')` before triggering `this._ws.close()`, which can remove `waitForConnection()`’s failure handler if a caller closes while another caller is awaiting connection.

### Issue Context
This can lead to an indefinitely pending `waitForConnection()` (and therefore `send()`) promise during connection setup/cancellation scenarios.

### Fix Focus Areas
- javascript/selenium-webdriver/bidi/index.js[133-155]
- javascript/selenium-webdriver/bidi/index.js[272-291]

### Suggested approach
- Avoid `removeAllListeners('close')` before the socket actually closes. Instead:
 - Remove only the internal close handler(s) you own (store them as named/bound functions so they can be removed explicitly), **or**
 - Defer `removeAllListeners()` until inside the `once('close', ...)` callback (after other listeners like `waitForConnection()` have had a chance to run).
- Optionally, make `_failPending()` also reject/resolve a tracked “connect promise” if you introduce one, so `close()` can reliably unblock `waitForConnection()` without relying on WS listener ordering.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. send() can hang forever ✓ Resolved 🐞 Bug ☼ Reliability
Description
After a socket 'close'/'error', _failPending() sets connected=false, but send() will then await
waitForConnection(), which only resolves on a future 'open' event; on a closed/failed WebSocket this
can leave new send() calls pending indefinitely. This can stall higher-level BiDi helpers that call
bidi.send() without checking connection state.
Code

javascript/selenium-webdriver/bidi/index.js[R91-97]

+  _failPending(error) {
+    if (this._closed) {
+      return
+    }
+    this._closed = true
+    this.connected = false
+    for (const { reject, timeoutId } of this._pending.values()) {
Evidence
_failPending() is invoked on WebSocket 'close'/'error' and marks the connection closed
(connected=false). send() gates on connected and calls waitForConnection(), but waitForConnection()
has no path to reject/resolve when the socket is already closed/failed, so post-failure sends can
hang indefinitely; multiple BiDi modules call bidi.send() directly and would inherit the hang.

javascript/selenium-webdriver/bidi/index.js[75-82]
javascript/selenium-webdriver/bidi/index.js[91-101]
javascript/selenium-webdriver/bidi/index.js[133-142]
javascript/selenium-webdriver/bidi/index.js[150-153]
javascript/selenium-webdriver/bidi/browsingContext.js[155-160]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
After `_failPending()` runs, `connected` becomes `false`, and future `send()` calls can await `waitForConnection()` forever because there will be no future `'open'` event on a closed/failed socket.

### Issue Context
`_failPending()` is triggered on the underlying WebSocket `'close'`/`'error'` events and marks the connection closed. Many BiDi helper classes call `bidi.send()` directly; if the connection drops and a command is attempted afterward, the call can hang indefinitely.

### Fix Focus Areas
- javascript/selenium-webdriver/bidi/index.js[91-101]

### Suggested fix
Add a fast-fail guard in `send()` (or `waitForConnection()`):
- If `this._closed` is true, immediately reject/throw a clear error.
- Also consider checking `this._ws.readyState` and rejecting when `CLOSING`/`CLOSED`.
- Optionally, have `waitForConnection()` reject on `'close'`/`'error'` if it is waiting for `'open'`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

4. Closed socket skips cleanup 🐞 Bug ⚙ Maintainability
Description
If close() is called when the underlying WebSocket is already CLOSED (readyState===3), it fulfills
without removing listeners, leaving the shared 'message' dispatcher and other listeners attached.
This can retain references longer than necessary after an unexpected disconnect followed by user
cleanup.
Code

javascript/selenium-webdriver/bidi/index.js[R278-280]

    const closeWebSocket = (callback) => {
      // don't close if it's already closed
      if (this._ws.readyState === 3) {
Evidence
The constructor now always attaches persistent 'message'/'close'/'error' listeners. In close(), the
early return path for readyState===3 does not call removeAllListeners(), so those listeners remain
attached even though the user explicitly called close() to clean up.

javascript/selenium-webdriver/bidi/index.js[39-87]
javascript/selenium-webdriver/bidi/index.js[278-295]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`close()` does not remove WebSocket listeners when `readyState === 3` (already CLOSED), so the instance may retain listener closures longer than needed.

### Issue Context
Because a shared dispatcher is attached in the constructor, listener cleanup matters even when the socket is already closed.

### Fix Focus Areas
- javascript/selenium-webdriver/bidi/index.js[278-291]
- javascript/selenium-webdriver/bidi/index.js[39-87]

### Suggested change
In the `if (this._ws.readyState === 3)` branch, call `this._ws.removeAllListeners()` (and any other required cleanup) before invoking `callback()`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. waitForConnection() now rejects ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
waitForConnection() previously only resolved (or could hang) but now rejects when the socket is
closed or closes before opening, which can break callers that do not handle promise rejection. This
changes the public API contract/behavior for a user-facing interface.
Code

javascript/selenium-webdriver/bidi/index.js[R134-155]

+    return new Promise((resolve, reject) => {
+      if (this._closed) {
+        reject(new Error('BiDi connection is closed'))
+        return
+      }
      if (this.connected) {
        resolve()
-      } else {
-        this._ws.once('open', () => {
-          resolve()
-        })
+        return
+      }
+      const onOpen = () => {
+        this._ws.off('close', onFailure)
+        this._ws.off('error', onFailure)
+        resolve()
      }
+      const onFailure = () => {
+        this._ws.off('open', onOpen)
+        reject(new Error('BiDi connection closed before opening'))
+      }
+      this._ws.once('open', onOpen)
+      this._ws.once('close', onFailure)
+      this._ws.once('error', onFailure)
    })
Evidence
PR Compliance ID 1 requires preserving user-facing API behavior. The updated waitForConnection()
now calls reject(...) on _closed or on close/error before open, altering the previous
behavior (resolve-only) into a rejecting promise path.

AGENTS.md
javascript/selenium-webdriver/bidi/index.js[134-155]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`waitForConnection()` now rejects on closed/failed connections, which is a user-visible behavioral change that may break downstream code that previously awaited it without handling rejections.

## Issue Context
This class is exported from `selenium-webdriver/bidi`, so changes to promise rejection behavior can be compatibility-impacting.

## Fix Focus Areas
- javascript/selenium-webdriver/bidi/index.js[134-155]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. send() fails fast when closed 📘 Rule violation ⚙ Maintainability
Description
send() now rejects immediately with BiDi connection is closed when _closed is set, instead of
waiting (potentially until timeout), which is a behavioral change that can break callers relying on
the previous timing/behavior. This alters the public API contract for a user-facing method.
Code

javascript/selenium-webdriver/bidi/index.js[R164-166]

+    if (this._closed) {
+      throw new Error('BiDi connection is closed')
+    }
Evidence
PR Compliance ID 1 requires avoiding breaking behavioral changes in user-facing APIs. The new
early-throw path changes send() behavior for closed connections from potentially
pending/timeout-based failure to immediate rejection.

AGENTS.md
javascript/selenium-webdriver/bidi/index.js[164-166]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`send()` now immediately rejects when `_closed` is set, changing user-visible behavior compared to prior versions.

## Issue Context
If maintaining strict backward compatibility is required, consider gating this behavior (e.g., behind an option) or preserving previous semantics while still preventing hangs in a backward-compatible way.

## Fix Focus Areas
- javascript/selenium-webdriver/bidi/index.js[164-166]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (3)
7. Fixed 50ms sleep in test 📘 Rule violation ☼ Reliability
Description
The new test waits for an error event using a hardcoded setTimeout(..., 50), which can be flaky
on slow/loaded CI environments and cause intermittent failures. Tests for new behavior should be
deterministic and robust across supported environments.
Code

javascript/selenium-webdriver/test/bidi/index_test.js[105]

+    await new Promise((resolve) => setTimeout(resolve, 50))
Evidence
PR Compliance ID 13 requires unit tests to be gated/robust across supported environments to avoid
cross-environment CI failures. The new test relies on a fixed 50ms delay instead of awaiting a
specific signal (e.g., once('error') with a bounded timeout), which can intermittently fail when
scheduling is delayed.

javascript/selenium-webdriver/test/bidi/index_test.js[95-109]
Best Practice: Learned patterns

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`index_test.js` uses a fixed 50ms sleep to wait for an `error` event, which can be flaky in slower CI environments.

## Issue Context
This test is intended to validate new behavior (emitting an error on non-JSON messages) and should be deterministic across supported environments.

## Fix Focus Areas
- javascript/selenium-webdriver/test/bidi/index_test.js[95-109]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. Invalid BiDi messages ignored ✓ Resolved 📘 Rule violation ≡ Correctness
Description
The new shared message dispatcher silently drops JSON parse failures and messages without a
numeric id, which can turn protocol/input issues into misleading request timeouts and makes
diagnosis non-actionable. Protocol-derived inputs should be validated with deterministic, actionable
errors rather than being accepted/dropped silently.
Code

javascript/selenium-webdriver/bidi/index.js[R45-54]

+    this._ws.on('message', (data) => {
+      let payload
+      try {
+        payload = JSON.parse(data.toString())
+      } catch {
+        return
+      }
+      if (payload == null || typeof payload.id !== 'number') {
+        return
+      }
Evidence
PR Compliance ID 8 requires early validation of protocol inputs with clear, actionable exceptions;
the added dispatcher currently returns early on malformed/unsupported messages (parse failure or
missing/non-numeric id) without surfacing an error, which can lead to opaque behavior like
timeouts.

javascript/selenium-webdriver/bidi/index.js[45-54]
Best Practice: Learned patterns

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The shared WebSocket `message` handler swallows protocol/input problems (JSON parse failures or unexpected payload shapes) by returning early, which can cause callers to see unrelated timeouts and makes failures hard to diagnose.

## Issue Context
Messages received over the BiDi WebSocket are protocol-derived external inputs. When they are malformed/unexpected, we should surface a deterministic, actionable error (e.g., emit an `error` event and/or reject impacted pending requests) rather than silently dropping the message.

## Fix Focus Areas
- javascript/selenium-webdriver/bidi/index.js[45-54]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. Unexpected close stalls sends ✓ Resolved 🐞 Bug ☼ Reliability
Description
Index only rejects pending send() calls on timeout or explicit close(); it does not handle
underlying WebSocket 'close'/'error' events. If the peer disconnects mid-request, callers can hang
until RESPONSE_TIMEOUT (30s) instead of failing immediately.
Code

javascript/selenium-webdriver/bidi/index.js[R41-62]

+    // Single shared response dispatcher. Avoids attaching a new 'message'
+    // listener for every in-flight send(), which previously caused
+    // MaxListenersExceededWarning under concurrent BiDi traffic
+    // (e.g. network interception during a page navigation).
+    this._ws.on('message', (data) => {
+      let payload
+      try {
+        payload = JSON.parse(data.toString())
+      } catch {
+        return
+      }
+      if (payload == null || typeof payload.id !== 'number') {
+        return
+      }
+      const entry = this._pending.get(payload.id)
+      if (entry === undefined) {
+        return
+      }
+      clearTimeout(entry.timeoutId)
+      this._pending.delete(payload.id)
+      entry.resolve(payload)
+    })
Evidence
The constructor wires 'open' and a shared 'message' dispatcher but no 'close'/'error' handlers, so
nothing clears/rejects _pending when the socket drops unexpectedly. send() registers only a
timeout-based rejection, and close() rejects pendings only if user code calls close().

javascript/selenium-webdriver/bidi/index.js[33-63]
javascript/selenium-webdriver/bidi/index.js[111-127]
javascript/selenium-webdriver/bidi/index.js[217-223]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`Index` tracks in-flight requests in `_pending`, but it does not subscribe to the underlying WebSocket `'close'` / `'error'` events. If the connection drops without an explicit `Index.close()` call, pending `send()` promises will only fail after `RESPONSE_TIMEOUT`, delaying failure propagation.

### Issue Context
- `close()` now correctly rejects `_pending` when invoked, but unexpected disconnects still leave callers waiting up to 30 seconds.
- Since there is now a single dispatcher and centralized `_pending`, the most reliable place to fail outstanding requests is in a single socket-level close/error handler.

### Fix Focus Areas
- javascript/selenium-webdriver/bidi/index.js[33-63]
- javascript/selenium-webdriver/bidi/index.js[217-241]

### Suggested approach
- Add `_ws.on('close', ...)` and `_ws.on('error', ...)` handlers in the constructor.
- In those handlers:
 - set `this.connected = false`
 - iterate `_pending.values()` and `clearTimeout(timeoutId)` then `reject(new Error(...))`
 - `_pending.clear()`
- Ensure handlers are idempotent (e.g., guard with a boolean like `this._closed = true`) so `close()` and `'close'` event don’t double-reject.
- (Optional) extend tests to simulate server-side disconnect and assert pending sends reject promptly (without waiting for the timeout).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Advisory comments

10. Generic error on connect ✓ Resolved 🐞 Bug ◔ Observability
Description
waitForConnection() rejects with "BiDi connection closed before opening" even when the WebSocket
emits an 'error' event before 'open', discarding the underlying error message. This makes connection
failures harder to diagnose for callers awaiting waitForConnection().
Code

javascript/selenium-webdriver/bidi/index.js[R148-154]

+      const onFailure = () => {
+        this._ws.off('open', onOpen)
+        reject(new Error('BiDi connection closed before opening'))
+      }
+      this._ws.once('open', onOpen)
+      this._ws.once('close', onFailure)
+      this._ws.once('error', onFailure)
Evidence
The same onFailure handler is registered for both 'close' and 'error', and it always rejects with
the same generic message (and does not incorporate the 'error' event’s Error object). As a result, a
pre-open 'error' produces a misleading/less-informative rejection.

javascript/selenium-webdriver/bidi/index.js[143-154]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`waitForConnection()` uses a single `onFailure` handler for both `'close'` and `'error'` before the socket opens, but it always rejects with `BiDi connection closed before opening`, losing the real connection error detail.

### Issue Context
This only affects the pre-open wait path (callers awaiting `waitForConnection()` / `send()` while not connected). Including the underlying error message improves diagnosability.

### Fix Focus Areas
- javascript/selenium-webdriver/bidi/index.js[143-154]

### Suggested change
Split the handlers:
- `onClose` -> reject with the current close message
- `onError(err)` -> reject with something like `BiDi connection error before opening: ${err.message}`
Also keep the listener cleanup symmetric (remove the other listeners on success/failure).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Previous review results

Review updated until commit dfda96f

Results up to commit 10c75f4


🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)


Remediation recommended
1. Unexpected close stalls sends ✓ Resolved 🐞 Bug ☼ Reliability
Description
Index only rejects pending send() calls on timeout or explicit close(); it does not handle
underlying WebSocket 'close'/'error' events. If the peer disconnects mid-request, callers can hang
until RESPONSE_TIMEOUT (30s) instead of failing immediately.
Code

javascript/selenium-webdriver/bidi/index.js[R41-62]

+    // Single shared response dispatcher. Avoids attaching a new 'message'
+    // listener for every in-flight send(), which previously caused
+    // MaxListenersExceededWarning under concurrent BiDi traffic
+    // (e.g. network interception during a page navigation).
+    this._ws.on('message', (data) => {
+      let payload
+      try {
+        payload = JSON.parse(data.toString())
+      } catch {
+        return
+      }
+      if (payload == null || typeof payload.id !== 'number') {
+        return
+      }
+      const entry = this._pending.get(payload.id)
+      if (entry === undefined) {
+        return
+      }
+      clearTimeout(entry.timeoutId)
+      this._pending.delete(payload.id)
+      entry.resolve(payload)
+    })
Evidence
The constructor wires 'open' and a shared 'message' dispatcher but no 'close'/'error' handlers, so
nothing clears/rejects _pending when the socket drops unexpectedly. send() registers only a
timeout-based rejection, and close() rejects pendings only if user code calls close().

javascript/selenium-webdriver/bidi/index.js[33-63]
javascript/selenium-webdriver/bidi/index.js[111-127]
javascript/selenium-webdriver/bidi/index.js[217-223]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`Index` tracks in-flight requests in `_pending`, but it does not subscribe to the underlying WebSocket `'close'` / `'error'` events. If the connection drops without an explicit `Index.close()` call, pending `send()` promises will only fail after `RESPONSE_TIMEOUT`, delaying failure propagation.

### Issue Context
- `close()` now correctly rejects `_pending` when invoked, but unexpected disconnects still leave callers waiting up to 30 seconds.
- Since there is now a single dispatcher and centralized `_pending`, the most reliable place to fail outstanding requests is in a single socket-level close/error handler.

### Fix Focus Areas
- javascript/selenium-webdriver/bidi/index.js[33-63]
- javascript/selenium-webdriver/bidi/index.js[217-241]

### Suggested approach
- Add `_ws.on('close', ...)` and `_ws.on('error', ...)` handlers in the constructor.
- In those handlers:
 - set `this.connected = false`
 - iterate `_pending.values()` and `clearTimeout(timeoutId)` then `reject(new Error(...))`
 - `_pending.clear()`
- Ensure handlers are idempotent (e.g., guard with a boolean like `this._closed = true`) so `close()` and `'close'` event don’t double-reject.
- (Optional) extend tests to simulate server-side disconnect and assert pending sends reject promptly (without waiting for the timeout).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Invalid BiDi messages ignored ✓ Resolved 📘 Rule violation ≡ Correctness
Description
The new shared message dispatcher silently drops JSON parse failures and messages without a
numeric id, which can turn protocol/input issues into misleading request timeouts and makes
diagnosis non-actionable. Protocol-derived inputs should be validated with deterministic, actionable
errors rather than being accepted/dropped silently.
Code

javascript/selenium-webdriver/bidi/index.js[R45-54]

+    this._ws.on('message', (data) => {
+      let payload
+      try {
+        payload = JSON.parse(data.toString())
+      } catch {
+        return
+      }
+      if (payload == null || typeof payload.id !== 'number') {
+        return
+      }
Evidence
PR Compliance ID 8 requires early validation of protocol inputs with clear, actionable exceptions;
the added dispatcher currently returns early on malformed/unsupported messages (parse failure or
missing/non-numeric id) without surfacing an error, which can lead to opaque behavior like
timeouts.

javascript/selenium-webdriver/bidi/index.js[45-54]
Best Practice: Learned patterns

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The shared WebSocket `message` handler swallows protocol/input problems (JSON parse failures or unexpected payload shapes) by returning early, which can cause callers to see unrelated timeouts and makes failures hard to diagnose.

## Issue Context
Messages received over the BiDi WebSocket are protocol-derived external inputs. When they are malformed/unexpected, we should surface a deterministic, actionable error (e.g., emit an `error` event and/or reject impacted pending requests) rather than silently dropping the message.

## Fix Focus Areas
- javascript/selenium-webdriver/bidi/index.js[45-54]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Results up to commit 7e77a5b


🐞 Bugs (0) 📘 Rule violations (1) 📎 Requirement gaps (0)


Action required
1. send() can hang forever ✓ Resolved 🐞 Bug ☼ Reliability
Description
After a socket 'close'/'error', _failPending() sets connected=false, but send() will then await
waitForConnection(), which only resolves on a future 'open' event; on a closed/failed WebSocket this
can leave new send() calls pending indefinitely. This can stall higher-level BiDi helpers that call
bidi.send() without checking connection state.
Code

javascript/selenium-webdriver/bidi/index.js[R91-97]

+  _failPending(error) {
+    if (this._closed) {
+      return
+    }
+    this._closed = true
+    this.connected = false
+    for (const { reject, timeoutId } of this._pending.values()) {
Evidence
_failPending() is invoked on WebSocket 'close'/'error' and marks the connection closed
(connected=false). send() gates on connected and calls waitForConnection(), but waitForConnection()
has no path to reject/resolve when the socket is already closed/failed, so post-failure sends can
hang indefinitely; multiple BiDi modules call bidi.send() directly and would inherit the hang.

javascript/selenium-webdriver/bidi/index.js[75-82]
javascript/selenium-webdriver/bidi/index.js[91-101]
javascript/selenium-webdriver/bidi/index.js[133-142]
javascript/selenium-webdriver/bidi/index.js[150-153]
javascript/selenium-webdriver/bidi/browsingContext.js[155-160]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
After `_failPending()` runs, `connected` becomes `false`, and future `send()` calls can await `waitForConnection()` forever because there will be no future `'open'` event on a closed/failed socket.

### Issue Context
`_failPending()` is triggered on the underlying WebSocket `'close'`/`'error'` events and marks the connection closed. Many BiDi helper classes call `bidi.send()` directly; if the connection drops and a command is attempted afterward, the call can hang indefinitely.

### Fix Focus Areas
- javascript/selenium-webdriver/bidi/index.js[91-101]

### Suggested fix
Add a fast-fail guard in `send()` (or `waitForConnection()`):
- If `this._closed` is true, immediately reject/throw a clear error.
- Also consider checking `this._ws.readyState` and rejecting when `CLOSING`/`CLOSED`.
- Optionally, have `waitForConnection()` reject on `'close'`/`'error'` if it is waiting for `'open'`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended
2. Fixed 50ms sleep in test 📘 Rule violation ☼ Reliability
Description
The new test waits for an error event using a hardcoded setTimeout(..., 50), which can be flaky
on slow/loaded CI environments and cause intermittent failures. Tests for new behavior should be
deterministic and robust across supported environments.
Code

javascript/selenium-webdriver/test/bidi/index_test.js[105]

+    await new Promise((resolve) => setTimeout(resolve, 50))
Evidence
PR Compliance ID 13 requires unit tests to be gated/robust across supported environments to avoid
cross-environment CI failures. The new test relies on a fixed 50ms delay instead of awaiting a
specific signal (e.g., once('error') with a bounded timeout), which can intermittently fail when
scheduling is delayed.

javascript/selenium-webdriver/test/bidi/index_test.js[95-109]
Best Practice: Learned patterns

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`index_test.js` uses a fixed 50ms sleep to wait for an `error` event, which can be flaky in slower CI environments.

## Issue Context
This test is intended to validate new behavior (emitting an error on non-JSON messages) and should be deterministic across supported environments.

## Fix Focus Areas
- javascript/selenium-webdriver/test/bidi/index_test.js[95-109]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Results up to commit 1bc5eb1


🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)


Advisory comments
1. Generic error on connect ✓ Resolved 🐞 Bug ◔ Observability
Description
waitForConnection() rejects with "BiDi connection closed before opening" even when the WebSocket
emits an 'error' event before 'open', discarding the underlying error message. This makes connection
failures harder to diagnose for callers awaiting waitForConnection().
Code

javascript/selenium-webdriver/bidi/index.js[R148-154]

+      const onFailure = () => {
+        this._ws.off('open', onOpen)
+        reject(new Error('BiDi connection closed before opening'))
+      }
+      this._ws.once('open', onOpen)
+      this._ws.once('close', onFailure)
+      this._ws.once('error', onFailure)
Evidence
The same onFailure handler is registered for both 'close' and 'error', and it always rejects with
the same generic message (and does not incorporate the 'error' event’s Error object). As a result, a
pre-open 'error' produces a misleading/less-informative rejection.

javascript/selenium-webdriver/bidi/index.js[143-154]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`waitForConnection()` uses a single `onFailure` handler for both `'close'` and `'error'` before the socket opens, but it always rejects with `BiDi connection closed before opening`, losing the real connection error detail.

### Issue Context
This only affects the pre-open wait path (callers awaiting `waitForConnection()` / `send()` while not connected). Including the underlying error message improves diagnosability.

### Fix Focus Areas
- javascript/selenium-webdriver/bidi/index.js[143-154]

### Suggested change
Split the handlers:
- `onClose` -> reject with the current close message
- `onError(err)` -> reject with something like `BiDi connection error before opening: ${err.message}`
Also keep the listener cleanup symmetric (remove the other listeners on success/failure).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Results up to commit c058c72


🐞 Bugs (0) 📘 Rule violations (1) 📎 Requirement gaps (0)


Action required
1. close() can hang waiters ✓ Resolved 🐞 Bug ☼ Reliability
Description
Index.waitForConnection() depends on a WebSocket 'close'/'error' listener to reject when the socket
dies before opening, but Index.close() removes all 'close' listeners before initiating ws.close(),
which can remove waitForConnection()’s onFailure handler. If close() is called while a send() is
awaiting waitForConnection(), that wait can remain pending indefinitely.
Code

javascript/selenium-webdriver/bidi/index.js[R273-277]

+    this._failPending(new Error('BiDi connection closed before response was received'))
+
    const closeWebSocket = (callback) => {
      // don't close if it's already closed
      if (this._ws.readyState === 3) {
Evidence
waitForConnection() installs a one-time 'close' listener to reject if the socket closes before
opening, and send() awaits waitForConnection() when not connected; close() removes all 'close'
listeners before closing the socket, which can remove that rejection path and leave the wait promise
unresolved.

javascript/selenium-webdriver/bidi/index.js[133-155]
javascript/selenium-webdriver/bidi/index.js[163-183]
javascript/selenium-webdriver/bidi/index.js[272-291]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`waitForConnection()` uses `this._ws.once('close'|'error', ...)` to reject when the socket fails before opening. But `close()` calls `this._ws.removeAllListeners('close')` before triggering `this._ws.close()`, which can remove `waitForConnection()`’s failure handler if a caller closes while another caller is awaiting connection.

### Issue Context
This can lead to an indefinitely pending `waitForConnection()` (and therefore `send()`) promise during connection setup/cancellation scenarios.

### Fix Focus Areas
- javascript/selenium-webdriver/bidi/index.js[133-155]
- javascript/selenium-webdriver/bidi/index.js[272-291]

### Suggested approach
- Avoid `removeAllListeners('close')` before the socket actually closes. Instead:
 - Remove only the internal close handler(s) you own (store them as named/bound functions so they can be removed explicitly), **or**
 - Defer `removeAllListeners()` until inside the `once('close', ...)` callback (after other listeners like `waitForConnection()` have had a chance to run).
- Optionally, make `_failPending()` also reject/resolve a tracked “connect promise” if you introduce one, so `close()` can reliably unblock `waitForConnection()` without relying on WS listener ordering.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended
2. send() fails fast when closed 📘 Rule violation ⚙ Maintainability
Description
send() now rejects immediately with BiDi connection is closed when _closed is set, instead of
waiting (potentially until timeout), which is a behavioral change that can break callers relying on
the previous timing/behavior. This alters the public API contract for a user-facing method.
Code

javascript/selenium-webdriver/bidi/index.js[R164-166]

+    if (this._closed) {
+      throw new Error('BiDi connection is closed')
+    }
Evidence
PR Compliance ID 1 requires avoiding breaking behavioral changes in user-facing APIs. The new
early-throw path changes send() behavior for closed connections from potentially
pending/timeout-based failure to immediate rejection.

AGENTS.md
javascript/selenium-webdriver/bidi/index.js[164-166]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`send()` now immediately rejects when `_closed` is set, changing user-visible behavior compared to prior versions.

## Issue Context
If maintaining strict backward compatibility is required, consider gating this behavior (e.g., behind an option) or preserving previous semantics while still preventing hangs in a backward-compatible way.

## Fix Focus Areas
- javascript/selenium-webdriver/bidi/index.js[164-166]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. waitForConnection() now rejects ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
waitForConnection() previously only resolved (or could hang) but now rejects when the socket is
closed or closes before opening, which can break callers that do not handle promise rejection. This
changes the public API contract/behavior for a user-facing interface.
Code

javascript/selenium-webdriver/bidi/index.js[R134-155]

+    return new Promise((resolve, reject) => {
+      if (this._closed) {
+        reject(new Error('BiDi connection is closed'))
+        return
+      }
      if (this.connected) {
        resolve()
-      } else {
-        this._ws.once('open', () => {
-          resolve()
-        })
+        return
+      }
+      const onOpen = () => {
+        this._ws.off('close', onFailure)
+        this._ws.off('error', onFailure)
+        resolve()
      }
+      const onFailure = () => {
+        this._ws.off('open', onOpen)
+        reject(new Error('BiDi connection closed before opening'))
+      }
+      this._ws.once('open', onOpen)
+      this._ws.once('close', onFailure)
+      this._ws.once('error', onFailure)
    })
Evidence
PR Compliance ID 1 requires preserving user-facing API behavior. The updated waitForConnection()
now calls reject(...) on _closed or on close/error before open, altering the previous
behavior (resolve-only) into a rejecting promise path.

AGENTS.md
javascript/selenium-webdriver/bidi/index.js[134-155]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`waitForConnection()` now rejects on closed/failed connections, which is a user-visible behavioral change that may break downstream code that previously awaited it without handling rejections.

## Issue Context
This class is exported from `selenium-webdriver/bidi`, so changes to promise rejection behavior can be compatibility-impacting.

## Fix Focus Areas
- javascript/selenium-webdriver/bidi/index.js[134-155]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Results up to commit 98e4e83


🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0)


Action required
1. Connected true after close ✓ Resolved 🐞 Bug ☼ Reliability
Description
The WebSocket 'open' handler unconditionally sets connected=true even if close()/_failPending()
already marked the connection closed, which can leave the instance in an inconsistent state
(_closed===true but isConnected===true). This can happen when close() is called during CONNECTING
and the handshake completes anyway before the close event fires.
Code

javascript/selenium-webdriver/bidi/index.js[R40-46]

    this._ws.on('open', () => {
      this.connected = true
+      for (const { resolve } of this._connectWaiters) {
+        resolve()
+      }
+      this._connectWaiters.clear()
+    })
Evidence
Index tracks closure via _closed in _failPending(), and close() calls _failPending() immediately;
however, the 'open' handler does not check _closed before setting connected=true, so an 'open' event
arriving after close initiation can re-flip connected to true despite the object being logically
closed.

javascript/selenium-webdriver/bidi/index.js[39-46]
javascript/selenium-webdriver/bidi/index.js[96-113]
javascript/selenium-webdriver/bidi/index.js[275-277]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The WebSocket `open` handler sets `this.connected = true` even if `_closed` was already set by `close()`/`_failPending()`, allowing `isConnected` to become true after the connection is logically closed.

### Issue Context
`close()` calls `_failPending()` immediately (setting `_closed=true`), but does not remove/guard the constructor `open` listener. If `open` fires after `close()` is initiated, the state becomes inconsistent.

### Fix Focus Areas
- javascript/selenium-webdriver/bidi/index.js[39-46]
- javascript/selenium-webdriver/bidi/index.js[96-113]
- javascript/selenium-webdriver/bidi/index.js[275-295]

### Suggested change
In the `open` handler, early-return if `this._closed` is true (and optionally immediately `this._ws.close()`/`terminate()`), so `connected` cannot be re-enabled after closure.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended
2. Closed socket skips cleanup 🐞 Bug ⚙ Maintainability
Description
If close() is called when the underlying WebSocket is already CLOSED (readyState===3), it fulfills
without removing listeners, leaving the shared 'message' dispatcher and other listeners attached.
This can retain references longer than necessary after an unexpected disconnect followed by user
cleanup.
Code

javascript/selenium-webdriver/bidi/index.js[R278-280]

    const closeWebSocket = (callback) => {
      // don't close if it's already closed
      if (this._ws.readyState === 3) {
Evidence
The constructor now always attaches persistent 'message'/'close'/'error' listeners. In close(), the
early return path for readyState===3 does not call removeAllListeners(), so those listeners remain
attached even though the user explicitly called close() to clean up.

javascript/selenium-webdriver/bidi/index.js[39-87]
javascript/selenium-webdriver/bidi/index.js[278-295]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`close()` does not remove WebSocket listeners when `readyState === 3` (already CLOSED), so the instance may retain listener closures longer than needed.

### Issue Context
Because a shared dispatcher is attached in the constructor, listener cleanup matters even when the socket is already closed.

### Fix Focus Areas
- javascript/selenium-webdriver/bidi/index.js[278-291]
- javascript/selenium-webdriver/bidi/index.js[39-87]

### Suggested change
In the `if (this._ws.readyState === 3)` branch, call `this._ws.removeAllListeners()` (and any other required cleanup) before invoking `callback()`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
</det...

@selenium-ci selenium-ci added C-nodejs JavaScript Bindings B-build Includes scripting, bazel and CI integrations B-devtools Includes everything BiDi or Chrome DevTools related labels May 7, 2026
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.
@qodo-code-review
Copy link
Copy Markdown
Contributor

qodo-code-review Bot commented May 8, 2026

Persistent review updated to latest commit 7e77a5b

Comment thread javascript/selenium-webdriver/bidi/index.js
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.
@qodo-code-review
Copy link
Copy Markdown
Contributor

qodo-code-review Bot commented May 8, 2026

Persistent review updated to latest commit 1bc5eb1

@qodo-code-review
Copy link
Copy Markdown
Contributor

qodo-code-review Bot commented May 8, 2026

Persistent review updated to latest commit c058c72

Comment thread javascript/selenium-webdriver/bidi/index.js
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.
@qodo-code-review
Copy link
Copy Markdown
Contributor

qodo-code-review Bot commented May 9, 2026

Persistent review updated to latest commit 3a72849

@qodo-code-review
Copy link
Copy Markdown
Contributor

qodo-code-review Bot commented May 9, 2026

Persistent review updated to latest commit 98e4e83

Comment thread javascript/selenium-webdriver/bidi/index.js
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.
@qodo-code-review
Copy link
Copy Markdown
Contributor

qodo-code-review Bot commented May 9, 2026

Persistent review updated to latest commit dfda96f

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

B-build Includes scripting, bazel and CI integrations B-devtools Includes everything BiDi or Chrome DevTools related C-nodejs JavaScript Bindings

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[🐛 Bug]: [nodejs][bidi] MaxListenersExceededWarning when calling network.continueRequest() in beforeRequestSent

3 participants