#18076·selenium

[ Bug]: [java] [bidi] RemoteValue decoding re-serializes every nested subtree, so a large console argument costs gigabytes and seconds per `log.entryAdded` and can OOM the client

Author: pwspotCreated Sep 25, 2026Updated Sep 30, 2026
LabelsC-javaI-defect

What happened?

Context. We moved console/log capture from CDP (Runtime.consoleAPICalled, Log.entryAdded) to WebDriver BiDi (LogInspector) following #13990, which deprecates CDP in favour of BiDi and asks users to report what is not yet possible over BiDi. Over CDP a logged object arrives as a small preview with an objectId; over BiDi the whole object graph is serialized into the log.entryAdded event, and decoding it is where this issue lives. The switch is what exposed it, and one part of the report — the lack of any client-side bound on log-event serialization — is the kind of gap #13990 asks about.

RemoteValue.fromJson reads the value field as a raw Object tree and hands it to deserializeValue, which converts every child of a MAP/OBJECT back into a RemoteValue by serializing the child to JSON text and parsing it again:

java
// RemoteValue.deserializeValue, MAP / OBJECT case
for (List<Object> list : result) {
  Object key = list.get(0);
  if (!(key instanceof String)) {
    try (StringReader reader = new StringReader(JSON.toJson(key));
        JsonInput keyInput = JSON.newInput(reader)) {
      key = keyInput.read(RemoteValue.class);
    }
  }
  try (StringReader reader = new StringReader(JSON.toJson(list.get(1)));
      JsonInput valueInput = JSON.newInput(reader)) {
    RemoteValue value1 = valueInput.read(RemoteValue.class);   // -> fromJson -> deserializeValue, recursively
    map.put(key, value1);
  }
}

JSON.toJson(list.get(1)) serializes the child's entire remaining subtree, and the recursive read(RemoteValue.class) does the same one level down, so every node is re-serialized once per level above it. The decode cost is the number of nodes multiplied by a per-node cost that grows with depth.

Measured on Selenium 4.48.0 (Temurin 25.0.3, macOS arm64) by decoding real log.entryAdded payloads captured from chromedriver 152 through Log.entryAdded().getMapper(). "Allocated" is total temporary allocation for one decode (ThreadMXBean.getThreadAllocatedBytes), not memory held:

console.log argument wire size nodes allocated per decode per node time
Array.from({length: 100000}, (_, i) => i) 3.1 MB 100k 3.45 GB ~35 KB 5.2 s
array of 10,000 records with 2 fields 1.1 MB 30k 4.68 GB 152 KB —
array of 10,000 records with 8 fields 3.2 MB 90k 10.2 GB ~113 KB 13 s
object tree, depth 8, 3 keys per level 349 KB 9,841 7.16 GB 711 KB ~10 s
single chain, depth 14, one key per level 0.5 KB 15 15 MB 976 KB —

The depth dependence in isolation (a chain of nested objects, one key each):

depth JSON chars allocated
2 100 110 KB
4 168 494 KB
6 236 1.3 MB
8 304 2.8 MB
10 372 5.0 MB

3.7x more input, 45x more work.

This becomes an OOM because Connection.Listener.onText hands each incoming event to an unbounded cached thread pool (Executors.newCachedThreadPool, threads named BiDi Connection) with no backpressure. A page that logs a large object on every state change emits an entry every few hundred milliseconds; each decode takes seconds and holds ~300 MB of intermediate ArrayList/String/LinkedHashMap state live while it runs, so decodes pile up until the heap is exhausted. With -Xmx1536m the reproduction below dies in about 40 s with ~30 decodes in flight.

There is no client-side way to bound this for log events: SerializationOptions.maxObjectDepth is only wired into script.evaluate, script.callFunction and browsingContext.locateNodes, and session.subscribe carries no serialization options in the spec. The only workaround we found is to register our own Event for log.entryAdded whose mapper prunes the raw Map (capping entries per level and depth) before delegating to Log.entryAdded().getMapper().

The same code is present on trunk as of 2026-09-23: RemoteValue.deserializeValue still round-trips each child through StringReader(JSON.toJson(...)), so there is no newer release that avoids this.

How can we reproduce the issue?

java
ChromeOptions options = new ChromeOptions();
options.setBinary("/path/to/chrome-headless-shell");   // Chrome for Testing headless shell 152, as tested
options.enableBiDi();
ChromeDriver driver = new ChromeDriver(options);

new LogInspector(driver).onConsoleEntry(entry -> System.out.println("entry received"));

driver.get("about:blank");
((JavascriptExecutor) driver).executeScript(
    "const a = Array.from({ length: 100000 }, (_, i) => i);"
        + "let n = 0;"
        + "const t = setInterval(() => { console.log(a); if (++n >= 40) clearInterval(t); }, 250);");

Thread.sleep(120_000);
driver.quit();

Run with -Xmx1536m. Only the headless shell was run; full Chrome with --headless=new was not tried. It should behave the same, since the cost is in the Java client's decode rather than in the browser, but that is an inference. Expected: 40 lines of entry received within a few seconds. Actual: the heap fills within ~20 s, OutOfMemoryError: Java heap space is thrown on BiDi Connection threads from ~40 s, and few or none of the entries reach the listener. With a large heap instead, each entry takes ~5 s to decode and allocates ~3.4 GB.

Single-decode micro-benchmark (no browser): build the wire form of a nested object as a String and time new Json().newInput(new StringReader(json)).read(RemoteValue.class) — see the depth table above.

Relevant log output

Stack of a BiDi Connection thread at the time of the OOM (from a heap dump):

at org.openqa.selenium.json.JsonOutput.write(...)
at org.openqa.selenium.json.Json.toJson(Json.java:128)
at org.openqa.selenium.bidi.script.RemoteValue.deserializeValue(RemoteValue.java:216)
at org.openqa.selenium.bidi.script.RemoteValue.fromJson(RemoteValue.java:151)
at org.openqa.selenium.json.StaticInitializerCoercer.lambda$apply$0(StaticInitializerCoercer.java:59)
at org.openqa.selenium.json.JsonTypeCoercer.coerce(JsonTypeCoercer.java:154)
at org.openqa.selenium.json.JsonInput.read(JsonInput.java:517)
at org.openqa.selenium.bidi.script.RemoteValue.deserializeValue(RemoteValue.java:218)
at org.openqa.selenium.bidi.script.RemoteValue.fromJson(RemoteValue.java:151)
...
at org.openqa.selenium.bidi.log.ConsoleLogEntry.fromJson(ConsoleLogEntry.java:98)
at org.openqa.selenium.bidi.log.Log.lambda$entryAdded$0(Log.java:51)
at org.openqa.selenium.bidi.Connection.lambda$handleEventResponse$2(Connection.java:378)
at org.openqa.selenium.bidi.Connection.handleEventResponse(Connection.java:373)
at org.openqa.selenium.bidi.Connection.handle(Connection.java:312)
at org.openqa.selenium.bidi.Connection$Listener.lambda$onText$0(Connection.java:281)
Exception in thread "BiDi Connection" java.lang.OutOfMemoryError: Java heap space

Suggested direction

Decode children directly from the already-parsed Object tree instead of round-tripping each one through JSON text — e.g. a RemoteValue.fromObject(Map<String, Object>) used by deserializeValue for MAP/OBJECT/ARRAY/SET children — so each node is visited once. Independently of that, a way to bound decoded depth/breadth for log events on the client (an option on LogInspector, or on the Event) would let users protect themselves, since the protocol offers nothing for log.entryAdded.

Operating System

macOS 26.6 (arm64); also reproduced on Linux (ECS)

Selenium version

Java 4.48.0; the same code is present on trunk as of 2026-09-23

What are the browser(s) and version(s) where you see this issue?

Chrome for Testing headless shell 152.0.7977.64 (via options.setBinary); full Chrome not tested

What are the browser driver(s) and version(s) where you see this issue?

ChromeDriver 152.0.7977.64

Are you using Selenium Grid?

No

Are you willing to submit a PR?

Not committing to one at the moment. The change looks contained — decode container children (ARRAY / SET / NODE_LIST / MAP / OBJECT) from the already-parsed Map/List tree via a fromMap path instead of round-tripping each through JSON text, leaving the leaf types (RegExpValue, WindowProxyProperties, NodeProperties) as they are, plus a unit test on nested payloads — and if that shape is right and the hand-written RemoteValue is where you would want it (rather than the generated layer from #17777), we may pick it up later.