Skip to content

Feat/enquire link keepalive - #12

Merged
semyonf merged 2 commits into
masterfrom
feat/enquire-link-keepalive
Oct 6, 2026
Merged

semyonf merged 2 commits into
masterfrom
feat/enquire-link-keepalive

Conversation

@semyonf

@semyonf semyonf commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

Problem

  • An incoming enquire_link was only emitted as an event; every user had to answer it by hand.
  • auto_enquire_link_period sent enquire_link but never checked the response, so a dead link stayed "open" until TCP gave up on it, which can take many minutes. Passed to createServer(), the option was silently ignored.
  • session.connect() could not reconnect: it reused the closed socket, which never emits 'readable' again, so a reconnected session sent PDUs and never received one.

Changes

enquire_link (minor)

New options, accepted by both smpp.connect() and smpp.createServer():

Option Default
auto_enquire_link_response false Answer every incoming enquire_link before the 'pdu'/'enquire_link' events
auto_enquire_link_period unset As before; now also works on server sessions
enquire_link_timeout unset Wait this long for each keepalive's response; on timeout emit enquire_link_timeout with the unanswered PDU
close_on_enquire_link_timeout true Destroy the session after enquire_link_timeout
  • Runtime control: session.startEnquireLink(period, [timeout]), session.stopEnquireLink() and session.autoEnquireLinkResponse.
  • Defaults keep the previous behaviour.
  • Keepalive enquire_link are not subject to response_timeout. They only wait for enquire_link_timeout, and never show up in 'response_timeout' or 'response_aborted'.
  • Nothing accumulates against a silent peer.
  • close() stops sending, but keeps awaiting an enquire_link already in flight, so a half-open peer is still dropped.
  • destroy() stops the keepalive synchronously.

session.connect() (patch)

  • Every call opens a new socket.
  • All per-connection state is reset: sequence numbers, any partly received PDU, read counters and closed.
  • connectTimeout is re-armed for every attempt.
  • Events of the previous connection are no longer emitted, so there is no stray 'close' after reconnecting from 'error'.
  • A connection that is still open or still connecting is destroyed first.
  • Requests still waiting for a response fail with ESESSIONCLOSED when response_timeout is set.
  • Reconnecting from an enquire_link_timeout listener works: the new connection is not destroyed.
  • A server session throws a clear error.
  • Options rejected by net.connect()/tls.connect() leave the session as it was.

Testing

  • 44 new tests, 171 passing in total.
  • Also run with the server's 'connection' deferred, to simulate macOS ordering.
  • Four rounds of adversarial review, with mutation testing.

An incoming enquire_link was only emitted as an event, leaving every
user to answer it by hand, and auto_enquire_link_period sent
enquire_link without ever looking at the enquire_link_resp, so a dead
link stayed open until TCP gave up on it, which can take many minutes.
auto_enquire_link_period passed to createServer() was silently ignored.

New options, accepted by both connect() and createServer():
- auto_enquire_link_response answers incoming enquire_link, before the
  'pdu' and 'enquire_link' events are emitted.
- enquire_link_timeout arms a timer for every keepalive enquire_link.
  When it fires, the session emits enquire_link_timeout with the
  unanswered PDU and is destroyed, unless close_on_enquire_link_timeout
  is false.
- auto_enquire_link_period now also works on server sessions.

The keepalive enquire_link only wait for enquire_link_timeout:
response_timeout is for the requests the user sends, so they are
tracked without its timer and never show up in 'response_timeout' or
'response_aborted' events. Otherwise a response_timeout shorter than
enquire_link_timeout would settle them first and switch the link check
off.

startEnquireLink(period, [timeout]) / stopEnquireLink() and the
autoEnquireLinkResponse property control the same at runtime.
Everything defaults to the previous behaviour: answering automatically
by default would double the response for everyone who already answers
in their own handler.

Lifecycle details:
- A keepalive registers a response callback only when there is a
  timeout to clear, and the callback is removed on timeout, on stop and
  when the write fails, so nothing accumulates against a silent peer.
- Any response with the right sequence number counts as an answer,
  including generic_nack, which some SMSCs send for an enquire_link
  before the bind.
- close() stops sending but keeps awaiting an enquire_link already in
  flight: a peer that stopped answering doesn't send its FIN either,
  and that timeout is what drops the half-open session. destroy() stops
  the keepalive synchronously. Replacing the schedule keeps the
  enquire_link already sent awaited.
- The schedule only starts on a writable socket, so it doesn't start
  when close() was called from a 'connect' listener, and a negative or
  non-numeric timeout or period is ignored instead of firing at once.
connect() reconnected by calling connect() again on the previous socket.
A net.Socket that was closed can connect again, but never emits
'readable' again, so a reconnected session sent PDUs and never read a
single one: responses and incoming requests were silently lost. The
connection was also only half reset: a partly received PDU kept its
command length and corrupted the first PDU of the new connection, the
bytesRead baseline stayed at the old socket's count, closed stayed true,
and the connect timeout was not re-armed.

connect() now opens a new socket (net or tls, as in the constructor)
and resets all per-connection state. Socket listeners only act while
their socket is the session's socket: reconnecting from an 'error'
listener used to be followed by the failed connection's 'close', which
made the session look closed again. They stay attached so that a late
error of a replaced socket is swallowed instead of crashing the process.
A previous connection that is still open or still connecting is
destroyed instead of failing with EISCONN / EALREADY, and a server
session throws a clear error instead of Node's ERR_MISSING_ARGS. The
enquire_link schedule and all listeners carry over to the new
connection.

The new socket is created before anything else is touched, so options
that net.connect() or tls.connect() reject leave the session as it was.
Requests waiting for a response fail with ESESSIONCLOSED when
response_timeout is set, as on close, and the socket to replace is only
picked after their failure callbacks ran, since those may reconnect on
their own. A connection replaced from such a callback, which runs while
its 'close' is handled, no longer emits that 'close'; and a connection
opened from an enquire_link_timeout listener is no longer destroyed by
close_on_enquire_link_timeout right after. A write that fails on the
replaced socket can't drop the callback of a new request reusing its
sequence number, since pending entries are only removed by identity.
@semyonf
semyonf merged commit 180e698 into master Oct 6, 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.

1 participant