Skip to content

fix(ws): stop discarding parser and handler lookup failures silently - #17635

Merged
kamilmysliwiec merged 3 commits into
nestjs:masterfrom
bernard-code-lab:fix/ws-adapter-silent-failures
Aug 31, 2026
Merged

fix(ws): stop discarding parser and handler lookup failures silently#17635
kamilmysliwiec merged 3 commits into
nestjs:masterfrom
bernard-code-lab:fix/ws-adapter-silent-failures

Conversation

@bernard-code-lab

Copy link
Copy Markdown
Contributor

PR Checklist

PR Type

  • Bugfix
  • Feature
  • Code style update (formatting, local variables)
  • Refactoring (no functional changes, no api changes)
  • Build related changes
  • CI related changes
  • Other... Please describe:

What is the current behavior?

Issue Number: #17624

WsAdapter.bindMessageHandler wraps its whole body in a single try with an empty catch, so three unrelated failures are indistinguishable from a dropped packet — no reply, no log, no exception filter:

  1. a custom messageParser that throws — the application's own bug, invisible
  2. a malformed frame — the default parser throws SyntaxError
  3. an event with no registered handler — handlersMap.get(...)! returns undefined and the destructure on the next line throws a TypeError

The WsProxy / WsExceptionsHandler path fixed in #16366 only covers exceptions raised inside a registered handler, which is downstream of all three above.

For what it's worth, the empty catch doesn't look like a deliberate error policy: it dates back to 53687ba (2018), and the unknown-event TypeError predates the Map — it was already there when the lookup was handlers.find(...). 8c7718e later swapped find for get for performance and kept the shape as it was.

What is the new behavior?

The single try becomes three explicit blocks, one per failure origin.

Handler lookup. get(...)! + destructure is replaced by a plain guard on the result. An unrecognised event now returns EMPTY because the code says so, not because a TypeError happens to be caught. This keeps the single map lookup from 8c7718e — I deliberately did not use has() + get(), which would double the lookups in the hot path.

Parser failures. A custom parser that throws is now reported through this.logger.error. The default parser stays silent: it throws on client-controlled input, and logging that would turn a public socket into a log flood target — the concern raised in the issue. Telling the two apart is a reference comparison against the extracted defaultMessageParser, which also covers a subclass assigning the protected field directly.

A custom parser that wants a frame dropped quietly can still return nothing — WsMessageParser is typed => { event, data } | void and the void case was already handled. So "throw" can mean "something is wrong" without that being the only way to reject a frame.

Unknown events stay silent, as suggested in the issue — dropping unrecognised traffic is a reasonable default for a public socket, and this keeps the change behaviour-preserving there.

One thing worth flagging that isn't in the issue: removing the outer try outright is not safe. A synchronous throw from callback currently becomes EMPTY; without a try it propagates through the mergeMap in bindMessageHandlers and tears down the client's source$, so that client silently stops receiving every subsequent message, not just the failing one. That catch is kept, just separated from the parse one, and there is a test pinning it.

Verified end-to-end against a real ws client — after an unknown event, a malformed frame, a throwing parser and a throwing handler in sequence, the connection still answers the next valid message.

Does this PR introduce a breaking change?

  • Yes
  • No

Externally observable behaviour is unchanged in every case except one: a throwing custom parser is now logged. Unknown events, malformed frames on the default parser, and throwing handlers all behave exactly as before.

Other information

Tests live in packages/platform-ws/test/ws-adapter.spec.ts, extending the file added with the dispose fix. Seven cases: throwing custom parser (constructor and setMessageParser), malformed frame on the default parser, unknown event, void parser, happy path, and the throwing-handler case that pins the teardown risk above.

One open question I did not want to decide unilaterally: should a throwing callback also be logged? I left it at the current behaviour (EMPTY, no log) to keep the diff behaviour-preserving, but it is reachable from client input, so the logging policy there felt like a maintainer call. Happy to add it in a follow-up commit if you want it.

bernard-code-lab and others added 3 commits August 30, 2026 19:36
The three failures that the ws adapter currently swallows had no unit
coverage: a throwing custom message parser, a malformed frame, and an event
with no registered handler.

Adds a case for each, plus the void-parser and happy paths, and one that pins
the current behaviour of a throwing handler so the connection teardown risk
stays visible.

Refs nestjs#17624

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moving it out of the field initialiser gives the adapter a stable reference to
compare against, so it can tell whether the parser in use is still the built-in
one. Comparing the reference rather than tracking a flag covers every way the
parser can be replaced: the constructor option, the setter, and a subclass
assigning the protected field directly.

Pure refactor, no behaviour change.

Refs nestjs#17624

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The message handler binding wrapped its whole body in a single try with an
empty catch, so three unrelated failures ended up indistinguishable from a
dropped packet: a custom message parser that throws, a malformed frame, and an
event with no registered handler.

The handler lookup used a non-null assertion followed by a destructure, so an
unrecognised event reached that catch as a TypeError. It is now an explicit
guard that keeps the single map lookup introduced in 8c7718e.

A custom parser that throws is now reported, since nothing else in the process
would reveal it. The default parser stays silent because it throws on
client-controlled input, and reporting that would turn a public socket into a
log flood target.

The catch around the handler invocation is kept on purpose: without it a
synchronous throw propagates through the mergeMap that binds the client and
tears down that client's source stream, silencing every later message from it.

Closes nestjs#17624

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kamilmysliwiec
kamilmysliwiec merged commit 39fbdda into nestjs:master Aug 31, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants