Skip to content

fix(listeners): isolate listener exceptions from message dispatch loop - #1953

Open
alaahong wants to merge 1 commit into
microsoft:mainfrom
alaahong:fix-1952
Open

fix(listeners): isolate listener exceptions from message dispatch loop#1953
alaahong wants to merge 1 commit into
microsoft:mainfrom
alaahong:fix-1952

Conversation

@alaahong

Copy link
Copy Markdown

Summary

  • Isolate listener exceptions in ListenerCollection.notify() so a listener throwing on the message-dispatch thread can no longer propagate up through Connection.dispatch and break unrelated API calls (e.g. download.path(), page.close()) that happen to pump messages while the listener runs.
  • Mirrors the JS client, where async listener rejections surface as unhandled rejections on stderr without interrupting the caller: listener exceptions are now printed to stderr and dispatching continues to the remaining listeners.

Root Cause

Fixes #1952

ListenerCollection.notify() called each listener without any exception isolation. When a listener threw (for example, a TargetClosedError from a waitForLoadState call made after the page was closed), the exception propagated synchronously up through:
ListenerCollection.notify → BrowserContextImpl.handleEvent → Connection.dispatch → Connection.processOneMessage → ChannelOwner.runUntil ← outer API call's message loop → ArtifactImpl.pathAfterFinished → DownloadImpl.path

This broke the outer download.path() call even though the listener failure was unrelated to it. The issue was introduced in 1.59.0 and reproduced with download.createReadStream() / download.path() after page.waitForDownload().

Fix

Wrap each listener invocation in ListenerCollection.notify() with try/catch:

for (Consumer<?> listener: new ArrayList<>(list)) { try { ((Consumer<t>) listener).accept(param); } catch (RuntimeException e) { System.err.println("[playwright] Listener for " + eventType + " threw: " + e); LoggingSupport.logApiIfEnabled("Listener threw exception: " + e.getMessage()); } }</t>

Why this aligns with the JS client

The JS client's EventEmitter._callHandler relies on async isolation — listeners return Promises, and promise.catch(e => { throw e; }) turns failures into unhandled rejections that Node prints to stderr by default, without interrupting the caller's Promise chain.

The Java client is synchronous: listeners are Consumer<T> and page.waitForLoadState() throws TargetClosedError synchronously. There is no natural async isolation, so the try/catch here explicitly mirrors the JS promise.catch behavior:

Behavior JS Client Java Client (after fix)
Listener invocation Reflect.apply(handler, this, args) ((Consumer<T>) listener).accept(param)
Exception capture promise.catch(e => { throw e; }) try { ... } catch (RuntimeException e) { ... }
Outer API call affected No (independent Promise chain) No (exception not propagated to runUntil)
Default observability Node prints unhandled rejection to stderr System.err.println always prints to stderr
DEBUG-mode extra log None LoggingSupport.logApiIfEnabled adds a structured pw:api log line
Remaining listeners still called Yes (for loop continues) Yes (for loop continues)
Optional silent mode removeAllListeners(type, { behavior: 'ignoreErrors' }) sets _rejectionHandler = () => {} Not exposed (always prints to stderr)

Tests

  • TestListenerCollection (new, 4 tests): pure unit tests verifying a throwing listener does not break the dispatch loop, remaining listeners are still called, and the exception is printed to stderr for observability.
  • TestDownload#shouldNotBreakMessageDispatchWhenListenerThrows (new): integration test that registers a throwing onClose listener and asserts page.close() (which pumps messages and triggers the listener) does not throw.

Verified by temporarily reverting only the ListenerCollection.java fix and re-running the integration test — it failed with RuntimeException: listener threw during message dispatch (reproducing #1952), then passed again after re-applying the fix.

A listener throwing on the message dispatch thread could propagate up
through Connection.dispatch and break unrelated API calls that happened
to pump messages while the listener ran (e.g. download.path() aborted by
a TargetClosedError thrown from a waitForLoadState listener). This
mirrors the JS client where async listener rejections surface as
unhandled rejections on stderr without interrupting the caller.

Fixes: microsoft#1952
@alaahong

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: TargetClosedError when calling path() on Download after page.waitForDownload()

1 participant