Skip to content

Add listener error handling, outgoing event, and session addresses - #15

Merged
semyonf merged 2 commits into
masterfrom
claude/focused-volta-6iieut
Oct 14, 2026
Merged

semyonf merged 2 commits into
masterfrom
claude/focused-volta-6iieut

Conversation

@semyonf

@semyonf semyonf commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

Summary

This PR adds robust error handling for listener exceptions, introduces an 'outgoing' event for tracking PDUs sent to the socket, exposes session address information, and adds automatic unbind response handling.

Key Changes

  • Listener Exception Handling: Exceptions thrown by event listeners, callbacks, and the debugListener option no longer unwind the session's code. Instead, they are caught and emitted as 'listener_error' events with a report containing the error, event name, and associated PDU. This prevents the session from silently stopping to read after an exception. A PDU that cannot be parsed now destroys the connection after 'error', instead of leaving a deaf session behind.

  • 'outgoing' Event: Added a new event emitted synchronously from session.send() for every PDU handed to the socket (requests, responses, automatic enquire_link, etc.). This provides a reliable way to track outgoing PDUs before write callbacks are invoked, addressing timing issues with the existing 'send' event.

  • Session Address Properties: Added session.localAddress and session.localPort properties that are set when the connection is established and retained after the socket closes, complementing the existing remoteAddress and remotePort.

  • Auto Unbind Response: Added auto_unbind_response option (default false) to both connect() and createServer(). When enabled, incoming unbind PDUs are automatically answered with unbind_resp (status ESME_ROK), the keepalive is stopped, the connection is closed, and the session emits the standard 'pdu' and 'unbind' events after the response is queued.

  • Error Handling Infrastructure: Implemented _guard() method to wrap all listener invocations and callbacks, catching exceptions and emitting them as 'listener_error' events instead of propagating them. Added rethrowAsync() utility to re-throw uncaught exceptions asynchronously when no listener handles them.

Implementation Details

  • The _guard() method wraps listener calls and callbacks, catching exceptions and emitting 'listener_error' with a ListenerErrorReport containing the error, event name, and PDU context.
  • The 'outgoing' event is emitted right after socket.write() is called, still synchronously within send(), so it fires before any write callback and before the response can be read, and a listener that closes or sends from it cannot get between the event and the write.
  • Address information is captured via _rememberAddresses() which reads from rootSocket() to handle TLS and proxy-protocol sessions correctly.
  • The automatic unbind_resp is sent before 'pdu' and 'unbind' events are emitted, and the socket is destroyed as soon as the response is flushed (destroySoon()), so 'close' follows even when the peer never closes its side.
  • Exceptions in 'debug' and 'metrics' listeners are reported but do not prevent the session from continuing to operate.

https://claude.ai/code/session_01QEFXDb9Pc8brL9ZCHbBJyM

…stener exceptions

'outgoing' is emitted synchronously from send() for every pdu written to
the socket, once the sequence_number is assigned and before the write, so
for a request it always precedes the 'pdu' event of its response. 'send'
comes from the asynchronous write callback and could follow a response
that arrived in the same chunk. It covers requests, responses and the
automatic enquire_link, enquire_link_resp and unbind_resp, and is typed
in SessionEventMap.

session.localAddress/localPort are set next to remoteAddress/remotePort
(connect on a client session, construction on a server session, from
rootSocket() on TLS) and kept after close, when the socket has forgotten
them. The client connect handler fell back to remoteAddress for
remotePort; it now falls back to the previous remotePort.

auto_unbind_response (default false, as handlers that already answer
unbind would double the response) answers an incoming unbind with
unbind_resp ESME_ROK and ends the connection, before emitting 'pdu' and
'unbind', like auto_enquire_link_response. session.autoUnbindResponse
switches it at runtime.

A listener throwing out of the read loop left _busy set, after which the
session silently stopped reading the socket; a throwing debug/metrics
listener was reported as a stream 'error' with the same effect, and a
throwing 'send' listener skipped the sendCallback. _busy is now reset in
a finally, only the parsing stays in the stream-error try, and every
listener and callback called while handling a pdu goes through _guard():
the exception is emitted as 'listener_error' { error, event, pdu }, or
rethrown asynchronously when nothing listens, so it still surfaces as an
uncaught exception without breaking the session.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QEFXDb9Pc8brL9ZCHbBJyM
…lose after unbind

Follow-up to the previous commit, after an adversarial review of it.

Listener exceptions: the guard covered only the pdu path, so a debug or
metrics listener throwing on socket.data.in or socket.data.out still
escaped the socket handler. A throw out of a 'readable' listener leaves
Node's Readable without a 'readable' to come, which is a second way to
go deaf that resetting _busy does not cure. A throwing failureCallback
in _abortPending skipped the other pending requests and 'close', and a
throwing 'response_timeout' listener skipped the failureCallback. Now
debug() and emitMetric() guard themselves, and every event the session
emits and every callback it calls goes through _guard(). 'error' with no
listener is rethrown on the next tick instead of throwing synchronously
out of the socket handler. ListenerError is renamed ListenerErrorReport
and its pdu is null for events that are not about one.

Parse errors: with _busy reset in a finally, the stale _command_length
made the session read the rest of a broken stream as garbage pdus. A pdu
that cannot be parsed now destroys the connection after 'error', as the
README always described, unless the 'error' listener reconnected.

send(): 'outgoing' is emitted right after socket.write() instead of
before it, so a listener that closes, reconnects or sends from there
cannot get between the event and the write, and the event order is the
wire order. The pdu.command.out debug/metrics are back before
toBuffer(), which encodes the pdu in place, so logs show the pdu as
built. _addPending runs after toBuffer(), so a pdu that cannot be
encoded no longer leaves a pending request and its timer behind.

auto_unbind_response: close() alone is a half-close, so a peer that
never closed its side after unbinding kept the session open, and a
keepalive in flight ended it with a spurious enquire_link_timeout. The
keepalive is stopped and the socket destroyed once the unbind_resp is
flushed, so 'close' always follows.

Tests: a deterministic same-chunk test (raw peer answers three requests
in one write, write callbacks held back), the sequence_number captured
inside the listener, synchronous emission for responses asserted from a
later listener, listener_error reports checked for event name and pdu,
tests for a throwing listener_error listener, failureCallback, connect
and response_timeout listener, socket.data.in/out debug listeners, the
rethrow asserting that the rest of the pdu was handled first, stream
errors, a half-open peer after unbind, close() from an 'outgoing'
listener, an unencodable pdu, and the runtime switch in both directions.
The timed negative tests assert from inside the 'unbind' listener, the
keepalive test no longer asserts exact counts, and the uncaughtException
takeover is restored from afterEach.

Docs: the README no longer claims a second unbind_resp reaches the peer,
describes proxy-protocol addresses correctly, and says what the next-tick
rethrow changes. The remote-port changeset is folded into the addresses
one and the listener-exceptions changeset is a minor bump.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QEFXDb9Pc8brL9ZCHbBJyM
@semyonf
semyonf merged commit bf9cefd into master Oct 14, 2026
22 checks passed
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.

2 participants