Files
lilleman 576b713af4
Test / lint (pull_request) Successful in 23s
Test / test (18) (pull_request) Successful in 30s
Test / test (20) (pull_request) Successful in 31s
Test / test (22) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 30s
Test / test (26) (pull_request) Successful in 30s
Mirror / push (push) Successful in 5s
Plan the comprehension rewrite and file its background
2026-09-30 12:04:56 +02:00

12258 lines
482 KiB
Diff
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
diff --git a/AGENTS.md b/AGENTS.md
index 882bb24..0a77378 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -38,36 +38,37 @@ These are not preferences. Breaking one is a defect.
```
src/
index.ts Public surface. Named exports only, no default export.
- client.ts client() -> { err, session }
+ client.ts client() -> { err, client }; SmppClient: one bound Session at a time, the sends waiting for one, the receipt merges
server.ts server() -> { err, server }, server owns the listener + close()
- session.ts Session: the socket's life, dispatch, events, and the collaborators below
- sms.ts The live handle emitted as the 'sms' event (sendResp/sendDlr)
+ session.ts Session: one socket's life — bound once, ended once by the socket's close, the drain, dispatch and events
+ sms.ts Sms: the handle onSms gets; sendResp() is the one place a message is answered, sendDlr() reports on it
concat.ts How a PDU says it is a segment: its UDH, or the sar_* TLVs
+ defaults.ts Every default the session layer runs on, in one object
dlr.ts Delivery receipts: text and TLV parsing, receipt status codes
- dlr-merger.ts DlrMerger: per-segment receipts counted into one MessageDlr
+ dlr-merger.ts DlrMerger: per-segment receipts counted into one MessageDlr, owned by the client
error-from.ts An untyped value as error material: errorFrom() an Error, namedValue() a name
- expiring-groups.ts ExpiringGroups: the capped, weighed, expiring store DlrMerger, HeldMessages and Reassembler share
- held-messages.ts HeldMessages: a message from its `sms` event to its answer, capped and expiring, one MessageHold each
+ expiring-groups.ts ExpiringGroups: the capped, weighed, expiring store that evicts and expires on its own and reports each drop
idle-waiters.ts IdleWaiters: waiting for a count to fall to zero, and what is left of a budget
- incoming-requests.ts Every request the peer sends: messages, receipts, links, unknown commands
- link-life.ts LinkLife: whether the link lives, and where a request waits for the next one
+ incoming-requests.ts Every request the peer sends, behind a SessionPort: messages, receipts, links, unknown commands
+ link-lost-error.ts LinkLostError: the link went before the request was written, so the next one may carry it
link-timers.ts LinkTimers: the enquire_link heartbeat and the idle timeout
log.ts SmppLog, the logger contract, and silentLog — the default
message.ts Encoding detection, splitting, bit counting, SMPP date formatting
message-body.ts Where an inbound body is: short_message, or the message_payload TLV
- outgoing-requests.ts OutgoingRequests: the window, the pending map and the retry
+ outgoing-requests.ts OutgoingRequests: request() through the window and the pending map, end() when the socket goes
pdu.ts pduToObj / objToPdu / pduReturn — synchronous, result-returning
pdu-framer.ts PduFramer: a byte stream cut into complete PDUs
pdu-refusal.ts A PDU the codec would not read, and the answer SMPP names for it
- pdu-transport.ts PduTransport: the socket a session reads complete PDUs off
+ pdu-transport.ts PduTransport: one socket read as complete PDUs
pending-requests.ts PendingRequests: sequence numbers, correlation, timeout, abort
reassembly.ts Reassembler: capped, expiring multipart groups
- reconnect-loop.ts ReconnectLoop: backoff, retry timer, stopped-ness
+ reconnect-loop.ts ReconnectLoop: opens a bound Session per link on a backoff, hands out the current one, holds requests for the next
result.ts Result<T> — the shape every fallible call returns
retained-pdu.ts A PDU copied off the wire so holding it pins nothing else, and what holding it costs
+ running-handlers.ts RunningHandlers: the onSms handlers in flight, their bound, and what a drain waits for
send-sms.ts submitSms composition and the submitSmParams builder
send-window.ts SendWindow: the maxOutstanding semaphore
- session-options.ts SessionOptions, ReconnectOptions, bind direction and the session defaults
+ session-options.ts SessionOptions, OnSms, OnRequest, bind direction and the option checks
sms-id.ts Message ids: the peer's notation, the <base>-<n> a segment gets, which response carries one
udh.ts User data header: its length, the concatenation fields of a long SMS and their reference
unanswered-error.ts UnansweredError: it went out and no answer came back
@@ -83,9 +84,10 @@ src/
```
Imports point one way: `defs` knows nothing above it but `result.ts`, `pdu` uses `defs`, `session`
-uses `pdu`, and `client`/`server` use `session`. The ways back up are the `Session` handed to
-`createSms()`, `HeldMessages` and `IncomingRequests`, which call back into it, and to `OnRequest`
-and `onConnected` in `session-options.ts`, all imported as a type only.
+uses `pdu`, and `client`/`server` use `session`. The ways back up are the `Session` a `Sms` carries
+and `onRequest` is handed, and `reconnect-loop`'s `Session`, all imported as a type only. What
+`IncomingRequests` may do to its session is the `SessionPort` the session builds for it, and what a
+`Sms` may do is `SmsDeps`: named functions, never the session itself.
**Parameter order is wire order.** The key order inside `cmds.*.params` is the order the fields are
written to and read from the buffer. Never sort those alphabetically — the alphabetical-ordering
@@ -175,6 +177,8 @@ decision under [The wire](docs/decisions.md#the-wire).
collaborator the type system already keeps in step: `recordingDeps()` in `messaging-mode.test.ts`,
`message-class.test.ts` and `unsendable.test.ts` is one `SendSmsDeps.send` that answers nothing,
and a field added to that type fails to compile in every copy at once.
+- A test drives a bare `Session` through its socket: `sock.emit('data', bytes)` is a PDU arriving and
+ `sock.destroy()` the link going, so nothing reaches into the session's collaborators.
- A socket a test opens and never reads must be `resume()`d, and a `data` listener counts. An unread
socket never processes the peer's FIN, so `server.close()` hangs forever — that is a test bug, not
a library one.
@@ -277,8 +281,9 @@ this is not a changelog.
### [The session's life](docs/decisions.md#the-sessions-life)
- A close arriving after our own `unbind` is a clean unbind, not an error.
-- `close` means the session is over, and a drop the loop will retry is `disconnected`.
-- An answer belongs to the link the message arrived on; a receipt does not.
+- A session is one socket's life, and the client is the composition that outlives it.
+- `close` means the client is over, and a drop the loop will retry is `disconnected`.
+- A message belongs to the session it arrived on, its answer and its receipt alike.
- `reconnect` takes `{ minDelay, maxDelay }` to retune and `false` to turn off
- Coming up is not proof a link works, so only one that outlasted `maxDelay` resets the backoff.
- `reconnect: { fromStart: true }` puts the first connect and bind through that same loop, and
@@ -292,25 +297,25 @@ this is not a changelog.
application's own signal rather than the peer's answer.
- `server()` composes the application's `onRequest` after its own bind handling, and offers it every
request that handling did not answer.
-- The drain waits on the messages the application holds, and `sendResp()` is what says it is done
- with one.
-- The drain's wait on the application ignores `shutdownTimeout: 0`.
-- What the application holds unanswered is capped on constants, and a message past the cap is
- refused.
+- An inbound message is an `onSms` handler's, and the handler settling is what answers it.
+- A session with no `onSms` refuses every message with the retry status.
+- The drain waits on the `onSms` handlers still running, then on the requests on the wire, and a
+ handler running five minutes is no longer counted.
+- What the running handlers hold is capped on constants, and a message past the cap is refused.
- A store at its bound answers `ESME_RTHROTTLED` to a submission and `ESME_RX_T_APPN` to a delivery,
a `data_sm` by whichever it stands in for.
-- A reconnect keeps the delivery-receipt merges; everything else the link held is dropped.
-- The bind state is the session's, `bound()` alone writes it, and it holds through a reconnect's gap.
+- The client owns the delivery-receipt merges; everything else the link held ends with its session.
+- The bind state is the session's, and `bound()` alone writes it.
- A message id base is merged at most once.
- A send that never reached the socket waits for the next link; one that did is counted, not resent.
- A send queued for a send-window slot is bounded by the caller's `signal`, and by nothing else.
-- One owner decides whether a link can carry a request, and a bind is what makes it one.
+- The reconnect loop alone decides whether a link can carry a request, and a bound session is one.
### [Internals and tests](docs/decisions.md#internals-and-tests)
- Locality work comes before other work until a scoring run reads 7.0.
- A listener that rejects is routed by Node's `captureRejections`, not by hand-dispatching.
-- The four-line abort dance is copied across `LinkLife`, `IdleWaiters`, `PendingRequests` and
+- The four-line abort dance is copied across `ReconnectLoop`, `IdleWaiters`, `PendingRequests` and
`SendWindow` rather than extracted.
- `SmppLog` is a five-method contract this library declares, not a dependency.
- The TLS tests build their own self-signed certificate in DER
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7cb5ff7..7a550a7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,29 @@
## 0.6.0 (unreleased)
+- **`client()` resolves `{ err, client }`, an `SmppClient`, and a `Session` is one socket's life.**
+ The client keeps one bound session at a time (`client.session`, `undefined` while the link is
+ down, a new object after every reconnect) and carries the `dlr`, `messageDlr`, `disconnected`,
+ `reconnected`, `close` and `sessionError` events, `sendSms()`, `send()`, `unbind()` and
+ `close()`. A `Session` no longer reconnects, so `new Session()` takes no `reconnect` option and
+ emits no `disconnected`, `reconnected` or `messageDlr`; `ReconnectOptions` is gone. Its `linkEnd`
+ is a constructor option, and `session.closed` says whether its socket closed.
+- **Inbound messages go to an `onSms` option, and the `sms` event is gone.** Give it to `client()`,
+ `server()` or `Session`; `sms.session` says which session a server's message came in on. The
+ message is answered when the handler settles: `ESME_ROK` under `sms.smsId` on a bare return, the
+ `{ smsId }` or `{ status }` returned, or the retry status (`ESME_RTHROTTLED` on a submission,
+ `ESME_RX_T_APPN` on a delivery) on a throw or rejection, which also reaches `sessionError`. With
+ no `onSms` every message is refused with that retry status. `sendResp()` still answers earlier,
+ writes once, and a later `smsId` or refusing `status` is an error. `sendDlr()` before the answer
+ is refused.
+- `close()` and `unbind()` wait for `onSms` handlers still running, then for requests on the wire.
+ `shutdownTimeout: 0` waits forever for both; a handler running five minutes is no longer waited
+ for. `sendResp()` and `sendDlr()` go out during the drain. The bound of 1000 messages, or 64 MiB
+ of them, now counts handlers still running.
+- A message belongs to the session it arrived on: `sendResp()` and `sendDlr()` on one whose socket
+ closed return `err`. A receipt used to go out on the next link.
+- A drop the application closes over from `sessionError` reports `close` alone, where it used to
+ report `disconnected` first.
- `client()` now bounds each connect attempt at 10 seconds, the TLS handshake included, and reports
one that expires as an ordinary connect failure, so `reconnect` retries it on its usual backoff.
A connect previously waited the operating system out, around 130 s on Linux against a host that
diff --git a/DESIGN.md b/DESIGN.md
new file mode 100644
index 0000000..081181a
--- /dev/null
+++ b/DESIGN.md
@@ -0,0 +1,78 @@
+# Draft F: a session is one socket's life
+
+## The public contract, as an implementer sees it
+
+- `client(options)` → `{ err, client: SmppClient }`. The client is the stable handle: `sendSms()`,
+ `send()`, `unbind()`, `close()`; events `dlr`, `messageDlr`, `disconnected`, `reconnected`,
+ `close`, `sessionError`; `client.session` is the bound `Session` or `undefined` while down.
+- `server(options)` → `{ err, server: SmppServer }`, a `session` event per connection, unchanged.
+- `Session` is one socket: bound once (`bound()`), ended once when the socket closes (`close`
+ event, `closed` flag), never revived. `new Session({ sock, onSms?, onRequest?, linkEnd? })`.
+- Receiving is one option, `onSms: (sms) => Promise<SendRespOptions | void> | …`, on client, server
+ and session. The message is answered when the handler settles: `ESME_ROK` under `sms.smsId` on
+ a bare return, the `{ smsId }`/`{ status }` returned, or the retry status on a throw. No handler
+ means every message is refused with the retry status. `sms.sendResp()` answers earlier and is
+ what `sendDlr()` requires. `answeredOnArrival` is unchanged.
+- README's "Client and session" states the shutdown in three lines and the sends-and-link rules in
+ six bullets; the glossary defines the twelve SMPP terms the README uses.
+
+## How a drop, a reconnect and a shutdown read
+
+- **Drop.** The socket closes → `Session.end()` runs once: timers cleared, pending requests settled
+ `UnansweredError`, queued sends settled `LinkLostError`, running handlers and half-arrived groups
+ dropped, `close` emitted. Nothing else ends a session; an idle timeout, an unframeable stream, the
+ peer's unbind and `close()` all just destroy the socket.
+- **Reconnect.** `ReconnectLoop` sees the session's `close`, tells the client (`disconnected`),
+ waits out the backoff, calls `openBoundSession()` (socket, `Session`, bind), and adopts the result
+ (`reconnected`). A client `send()` is `await links.bound()` then `session.send()`, retried only on
+ `LinkLostError` — the one error that says nothing reached the socket (goal 2).
+- **Shutdown.** `Session.close()`: `stopping = true`; wait for running handlers, then the window,
+ up to `shutdownTimeout`; destroy the socket; await its `close`. `SmppClient.close()`: stop the
+ loop, close the current session, emit `close`. Nothing re-enters anything.
+
+## Internal structure and ownership
+
+| Owner | State |
+| --- | --- |
+| `SmppClient` | `closed`; `ConcatReference`; `DlrMerger` (merges outlive sessions); the loop |
+| `ReconnectLoop` | current `Session`, backoff delay/timer, `upAt`, `stopped`, waiters for a link |
+| `Session` | `bind`, `stopping`, `over`, the `ended` promise; builds a `SessionPort` for `IncomingRequests` |
+| `OutgoingRequests` | `SendWindow` + `PendingRequests`; one entry point, `request()`; `end()` |
+| `IncomingRequests` | dispatch; `Reassembler`; `RunningHandlers` (count, octets, 5-min expiry) |
+| `Sms` (`sms.ts`) | `answered` + `smsId` — the one place a message is answered |
+| `ExpiringGroups` | evicts on `set()`/`weigh()` and expires on every access, reporting via `onDrop` |
+| `defaults.ts` | every default, once |
+
+## What was deleted
+
+`LinkLife` (phase enum, `stopped`, seven predicates, generations, held requests), `HeldMessages`
+and `MessageHold` (the `WeakMap`, listener counts, `setImmediate`, `listenerRejected`), the `sms`
+event, `Session.attach/teardown/comeBackUp/stop/emitClose/linkLost`, `PduTransport.attach()`,
+`OutgoingRequests.requestPastDrain/requestOnCurrentLink/canCarry/linkLost` and its retry loop,
+`ReconnectOptions`, `session.linkEnd` as a mutable field, `DlrMerger.close` (now `spend`), the
+`shutdownTimeout: 0` fallback rule, per-owner cap/sweep code in the three `ExpiringGroups` owners,
+and the `ascii` name on the GSM 03.38 codec object (`gsm0338`; the public option stays `ASCII`).
+
+## Tests
+
+`docker compose run --rm node npm test`: lint, typecheck and 7xx tests green (numbers in the
+final report). Changed tests, by file:
+
+- `test/session-extras.test.ts` (rewritten): every `sms` listener is an `onSms`; the client result
+ is `client`; `LinkLife` unit tests → `ReconnectLoop.bound()` tests; `held message bounds` →
+ `running handler bounds` driven through a bare `Session`'s socket; `sendResp()` gains "answers
+ once" and "refuses sendDlr() until answered"; "refuses to answer a message whose link went" now
+ asserts `Socket is closed` for both answer and receipt; "drops a message whose link went while
+ onRequest was still running" drives the socket; "reports a drop once as disconnected when a
+ listener closes…" → "…as close alone"; "falls back to responseTimeout…" deleted (rule gone);
+ "still ends when the message half has spent the whole budget" holds a request via a client handler
+ that never returns; "waits for the listener still working when another one rejected" deleted (one
+ handler); "does not report a reconnect on a session closed while coming back up" and "answers a
+ peer's unbind before the session ends" rewritten without `ReconnectOptions`/`IncomingRequests`
+ internals; new `the onSms contract` describe (five tests) and `SendWindow.close()` test.
+- `test/session.test.ts`: mechanical port; `ReconnectLoop` unit tests target the new `open()`
+ API; "ignores events from the socket it left behind" → old session `closed`, new one live;
+ throwing/rejecting listener tests now assert the `ESME_RTHROTTLED` refusal.
+- `test/readme.test.ts`: mirrors the new README examples.
+- `tls`, `declared-alphabet`, `message-class`, `session-error`, `interop`, `messaging-mode`,
+ `operator-receipts`, `dummy-smsc`: mechanical port (result field, `onSms`, `MessageDlr` import).
diff --git a/MIGRATION-NOTES.md b/MIGRATION-NOTES.md
new file mode 100644
index 0000000..accc719
--- /dev/null
+++ b/MIGRATION-NOTES.md
@@ -0,0 +1,25 @@
+# Migrating from 0.5.0
+
+Every breaking change for a 0.5.0 user, each with its replacement.
+
+| 0.5.0 | Now |
+| --- | --- |
+| `const { err, session } = await client()` | `const { err, client: smpp } = await client()`: an `SmppClient`. `smpp.session` is the bound `Session`, `undefined` while the link is down. |
+| `session.on('sms', async sms => { await sms.sendResp(); })` on a client | `client({ onSms: async sms => { … } })`: returning answers `ESME_ROK`. |
+| `smpp.on('session', s => s.on('sms', …))` on a server | `server({ onSms: sms => { … } })`; `sms.session` is the session it came in on. |
+| A message with no `sms` listener stayed unanswered | With no `onSms` it is refused with `ESME_RTHROTTLED` (`ESME_RX_T_APPN` on a delivery). |
+| `sms.sendResp({ smsId })` / `sendResp({ status })` inside the listener | Return `{ smsId }` / `{ status }` from `onSms`, or keep calling `sendResp()` before returning. |
+| A listener that threw left the message unanswered | A handler that throws or rejects refuses the message with the retry status and reports on `sessionError`. |
+| `await sms.sendDlr()` before answering | `await sms.sendResp()` first (or report after the handler returned); `sendDlr()` on an unanswered message returns `err`. |
+| `session.on('dlr' \| 'messageDlr' \| 'disconnected' \| 'reconnected' \| 'close' \| 'sessionError', …)` on a client | The same events on the `SmppClient`. |
+| `session.on('data' \| 'incomingPdu' \| 'incomingPduObj', …)`, `session.sock`, `boundAs`, `peerInterfaceVersion`, `acceptsOptionalParams()`, `bindAllows()` on a client | On `smpp.session` (assert it is defined). It is a new object after every reconnect. |
+| `session.boundAs` held through a reconnect's gap | `smpp.session` is `undefined` in the gap; the client refuses `sendSms()` on a receiver bind from its own `bindType`. |
+| `new Session({ sock, reconnect: { connect, onConnected } })` | `new Session({ sock, onSms })`; a session ends with its socket. Open a new socket and a new `Session` to reconnect. `ReconnectOptions` is gone. |
+| `session.linkEnd = 'smsc'` on a hand-wired SMSC | `new Session({ linkEnd: 'smsc', sock })`. |
+| `sms.sendDlr()` on a message whose link dropped went out on the new link | It returns `err`; a message belongs to its session. |
+| `sendResp()` set `sms.smsId` even when the write failed; a second `sendResp()` wrote again | `sms.smsId` changes only on a successful answer; after one, a bare `sendResp()` is a no-op and an `smsId` or refusing `status` is an error. |
+| `shutdownTimeout: 0` waited `responseTimeout` for unanswered messages | `0` waits for the handlers until they return, or five minutes. |
+| `close()` reported `… N message(s) unanswered` for messages not answered | The same text now counts `onSms` handlers still running. |
+| `close()` from a `sessionError` listener during a drop reported `disconnected`, then `close` | It reports `close` alone. |
+| `session.on('sms')` with several listeners | One `onSms` per session; fan out inside it. |
+| `import type { ReconnectOptions, MessageDlr, SessionOptions } from '@larvit/smpp'` | `MessageDlr`, `SessionOptions`, `OnSms`, `ClientEvents`, `ReconnectTuning` are exported; `ReconnectOptions` is not. |
diff --git a/MIGRATION.md b/MIGRATION.md
index 0296ede..b155d00 100644
--- a/MIGRATION.md
+++ b/MIGRATION.md
@@ -37,9 +37,12 @@ shape is the same, connect, send, listen for delivery reports, with callbacks re
Write `alert_on_message_delivery` and `broadcast_area_identifier`, the names they read back
under, for `alert_on_msg_delivery` and `failed_broadcast_area_identifier`, which are gone from
`tlvs` too.
-- **`session.loggedIn` and the `loggedIn` event are gone.** `client()` resolves once bound,
- `session.boundAs !== undefined` says a bind happened, and `disconnected`/`reconnected` say whether
- the link is up now. A session you construct yourself records a bind with `session.bound()`.
+- **`session.loggedIn` and the `loggedIn` event are gone.** `client()` resolves once bound, as
+ `{ client }`: an `SmppClient` whose `session` is the bound `Session` while the link is up, and
+ whose `disconnected`/`reconnected` events say whether it is. A session you construct yourself
+ records a bind with `session.bound()`.
+- **Inbound messages go to an `onSms` handler option**, on `client()`, `server()` and `Session`,
+ and are answered when it returns; the `sms` event is gone. `sms.sendResp()` answers earlier.
- **The `error` event is `sessionError`**, and `serverError` on the server handle.
- **`log`** takes any object with `debug`, `error`, `info`, `verbose` and `warn` methods instead of
a `larvitutils` one, and is silent by default: [README](README.md#logging).
diff --git a/README.md b/README.md
index 9ff2b7d..999ca69 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,8 @@ SMPP 3.4 client and server for Node.js with the session layer built in: keepaliv
window, long messages and delivery receipts. TypeScript, ESM, no dependencies.
- **Keepalive.** `enquire_link` every 20 s on a quiet link; a peer that stops answering is dropped.
-- **Reconnect.** A dropped client link re-binds on its own, backing off from 1 s to 30 s.
+- **Reconnect.** A dropped client link is reopened and re-bound on its own, backing off from 1 s to
+ 30 s; the client handle you hold stays the same.
- **Send window.** 10 requests in flight; further sends queue instead of overrunning the SMSC.
- **Long messages.** Split on send, reassembled on receive, in both the UDH and `sar_*` spellings.
- **Delivery receipts.** Read from TLVs or from receipt text, matched to the ids you were given.
@@ -19,8 +20,9 @@ window, long messages and delivery receipts. TypeScript, ESM, no dependencies.
[Install](#install) · [Send an SMS](#send-an-sms) · [Delivery reports](#delivery-reports) ·
[Receive SMS](#receive-sms) · [Run an SMPP server](#run-an-smpp-server) · [Errors](#errors) ·
[Client options](#client-options) · [Server options](#server-options) ·
-[Send options](#send-options) · [Session](#session) · [Receiving in depth](#receiving-in-depth) ·
-[Server in depth](#server-in-depth) · [Logging](#logging) ·
+[Send options](#send-options) · [Client and session](#client-and-session) ·
+[Receiving in depth](#receiving-in-depth) · [Server in depth](#server-in-depth) ·
+[Glossary](#glossary) · [Logging](#logging) ·
[PDUs and the low-level API](#pdus-and-the-low-level-api) ·
[Migrating from 0.4.0](#migrating-from-larvitsmpp-040) · [Goals](#goals) ·
[Audience](#audience) · [Development](#development)
@@ -38,21 +40,23 @@ Node 18 or later. ESM only, types included.
```javascript
import { client } from '@larvit/smpp';
-const { err, session } = await client();
+const { err, client: smpp } = await client();
if (err) throw err;
-await session.sendSms({
+await smpp.sendSms({
from: '46701113311',
message: 'Hello world',
to: '46709771337',
});
-await session.unbind();
+await smpp.unbind();
```
Without options this binds to `localhost:2775` as a transceiver with the default credentials. A real
SMSC needs `host`, `port`, `username` and `password`: [Client options](#client-options). A message
longer than one SMS is split and sent as one concatenated message: [Send options](#send-options).
+The handle is an `SmppClient`: it keeps one bound session at a time and opens another when the link
+drops: [Client and session](#client-and-session).
## Delivery reports
@@ -64,7 +68,7 @@ import { client } from '@larvit/smpp';
const log = new Log('debug');
-const { err, session } = await client({
+const { err, client: smpp } = await client({
host: 'smpp.somewhere.com',
log,
password: 'bar',
@@ -73,11 +77,11 @@ const { err, session } = await client({
});
if (err) throw err;
-session.on('dlr', dlr => {
+smpp.on('dlr', dlr => {
// dlr.smsId, dlr.statusMsg, dlr.statusId
});
-const { err: sendErr, smsIds } = await session.sendSms({
+const { err: sendErr, smsIds } = await smpp.sendSms({
dlr: true,
from: '46701113311',
message: '«baff»',
@@ -92,34 +96,32 @@ receipts into one, and SMSCs that write ids in two notations: [Delivery receipts
## Receive SMS
-A `receiver` or `transceiver` client gets mobile-originated messages as `sms` events:
+A `receiver` or `transceiver` client hands each mobile-originated message to its `onSms` handler:
```javascript
-session.on('sms', async sms => {
- // sms.from, sms.to, sms.message
- await sms.sendResp();
+const { err, client: smpp } = await client({
+ onSms: async sms => {
+ // sms.from, sms.to, sms.message
+ },
});
```
-Call `sendResp()` for every message, multipart included: until you do, it counts toward the bound
-past which the peer's messages are refused. Delivery receipts reach you as `dlr` events, not here. A
-multipart message arrives reassembled and already answered segment by segment, so `sendResp()` there
-puts nothing on the wire and releases it: [Receiving in depth](#receiving-in-depth).
+Returning answers the message `ESME_ROK`; throwing refuses it, and the SMSC sends it again. Return
+`{ smsId }` to name the id, or `{ status: 'ESME_RMSGQFUL' }` to refuse it yourself. Delivery
+receipts reach `dlr`, not here. A multipart message arrives reassembled, already answered segment by
+segment: [Receiving in depth](#receiving-in-depth).
## Run an SMPP server
```javascript
import { server } from '@larvit/smpp';
-const { err, server: smpp } = await server();
-if (err) throw err;
-
-smpp.on('session', session => {
- session.on('sms', async sms => {
- // sms.from, sms.to, sms.message, sms.dlr
- await sms.sendResp();
- });
+const { err, server: smpp } = await server({
+ onSms: async sms => {
+ // sms.from, sms.to, sms.message, sms.dlr; returning answers ESME_ROK
+ },
});
+if (err) throw err;
```
With authentication and delivery reports:
@@ -134,63 +136,61 @@ const { err, server: smpp } = await server({
return { userData: { userId: 123 } };
},
-});
-if (err) throw err;
-
-smpp.on('session', session => {
- session.on('sms', async sms => {
- if (sms.answeredOnArrival) {
- await sms.sendResp(); // multipart: already answered per segment; this only releases the shutdown drain
- } else {
- // no args: ESME_ROK + generated id; or sendResp({ smsId, status: 'ESME_RMSGQFUL' })
- await sms.sendResp();
- }
+ onSms: async sms => {
+ // Answer now rather than on return, since a receipt can only follow the answer.
+ await sms.sendResp(); // ESME_ROK + generated id; or sendResp({ smsId }) / sendResp({ status: 'ESME_RMSGQFUL' })
if (sms.dlr) {
await sms.sendDlr(); // same as sms.sendDlr('DELIVERED')
}
- });
+ },
});
+if (err) throw err;
console.log(smpp.port); // the port actually bound, useful when 0 was requested
await smpp.close(); // stop listening, then drain and close every live session
```
-- `sendResp()` answers `ESME_ROK` with a generated UUID v7 as the message id.
- `sendResp({ smsId, status })` names the id or refuses the message.
+- One `onSms` serves every session the server accepts; `sms.session` is the one a message came in
+ on, and `session.userData` is what `authenticate` attached.
+- Returning from `onSms` answers `ESME_ROK` with a generated UUID v7 as the message id, or with the
+ `{ smsId }` or `{ status }` returned. `sendResp()` answers earlier, for a handler that goes on
+ working after the answer.
- `sms.dlr` is true where the sender asked for a receipt. `sendDlr()` reports `DELIVERED`,
- `sendDlr('UNDELIVERABLE')` any other state: [Server in depth](#server-in-depth).
-- A message that arrived in several segments was answered as they arrived, so `sendResp()` there
- takes no `smsId` or refusing `status`. `sms.answeredOnArrival` says which case you are in.
+ `sendDlr('UNDELIVERABLE')` any other state, and is refused until the message is answered:
+ [Server in depth](#server-in-depth).
+- A message that arrived in several segments was answered as they arrived, so neither the return
+ nor `sendResp()` may name an `smsId` or a refusing `status` for it. `sms.answeredOnArrival` says
+ which case you are in.
## Errors
Nothing throws. Every fallible call returns a result with an optional `err`:
```javascript
-const { err, session } = await client({ host: 'smpp.somewhere.com' });
+const { err, client: smpp } = await client({ host: 'smpp.somewhere.com' });
if (err) return;
-const { err: sendErr, smsIds } = await session.sendSms({ from, message, to });
+const { err: sendErr, smsIds } = await smpp.sendSms({ from, message, to });
```
-Failures on a live session arrive as `sessionError` events, on a server handle as `serverError`.
-Neither is named `error`, because Node throws on an unhandled `error` event.
+Failures on a live client or session arrive as `sessionError` events, on a server handle as
+`serverError`. Neither is named `error`, because Node throws on an unhandled `error` event.
`sessionError` carries three kinds of failure:
| Kind | Type | |
| --- | --- | --- |
| A PDU the peer sent that the codec could not read. The link stays up; only that PDU is lost. | `PduRefusedError` | Count it. |
-| A concatenated message given up on before it was whole. Its segments were answered, so the peer will not resend, and no `sms` fired for it. | `Error` | Count it as lost traffic. |
-| The session or socket failing, or a hook or listener that threw or rejected. | `Error` | Alert. |
+| A concatenated message given up on before it was whole, or one `onSms` failed on after its segments were answered. The peer will not resend it. | `Error` | Count it as lost traffic. |
+| The session or socket failing, or a hook, handler or listener that threw or rejected. | `Error` | Alert. |
The last two are told apart by message text only, so this alerts on both:
```javascript
import { PduRefusedError } from '@larvit/smpp';
-session.on('sessionError', err => {
+smpp.on('sessionError', err => {
if (err instanceof PduRefusedError) {
log.warn('the peer sent a PDU that could not be read', {
cmdName: err.header.cmdName ?? err.header.cmdId,
@@ -209,7 +209,7 @@ session.on('sessionError', err => {
`seqNr`. `cmdName` is undefined for a command id this library does not know. `PduHeader` is its type.
- A refused request is answered with the status SMPP names for it. A refused response is answered
with nothing, and settles the request it named as `unanswered`.
-- A refused inbound `deliver_sm` is lost traffic: a message or receipt that never arrives as `sms`
+- A refused inbound `deliver_sm` is lost traffic: a message or receipt that never reaches `onSms`
or `dlr`. A refused response is reported twice, as the `err` of the `sendSms()` or `send()`
waiting on it and here.
- A `PduRefusedError` is always a PDU that arrived. What this library refuses to build or send (an
@@ -231,20 +231,22 @@ All optional. Timeouts and delays are milliseconds.
| `enquireLinkInterval` | `20000` | Interval between `enquire_link` on a quiet link. |
| `idleTimeout` | `2 × enquireLinkInterval` | Give up on a link the peer has stopped answering, and re-bind unless `reconnect` is `false`. |
| `responseTimeout` | `30000` | How long to wait for a response, and how long a send with no link waits for the next one. `0` waits forever. |
-| `shutdownTimeout` | `5000` | How long `close()` and `unbind()` wait for requests already sent and messages not yet answered. `0` waits forever for the requests, which end when the peer answers or `responseTimeout` expires, so both at `0` never ends. The messages then fall back to `responseTimeout`, or to its default where that is `0` too. |
+| `shutdownTimeout` | `5000` | How long `close()` and `unbind()` wait for `onSms` handlers still running and requests already sent. `0` waits forever: a request ends when the peer answers or `responseTimeout` expires, a handler when it returns or five minutes pass. |
| `maxOutstanding` | `10` | Requests on the wire at once; further sends queue. |
+| `onSms` | none | `(sms) => Promise<SendRespOptions \| void> \| SendRespOptions \| void`: every mobile-originated message. Without one the SMSC's deliveries are refused: [Receive SMS](#receive-sms). |
| `smsIdFormat` | — | The notation the SMSC writes message ids in, per place: `{ receipt: 'decimal', submitResp: 'hex' }`. Only where the two disagree: [Delivery receipts](#delivery-receipts). |
-| `reconnect` | on | `{ minDelay, maxDelay }` retunes the backoff; `false` turns it off, so a drop ends the session; `{ fromStart: true }` retries the first connect too. |
+| `reconnect` | on | `{ minDelay, maxDelay }` retunes the backoff; `false` turns it off, so a drop ends the client; `{ fromStart: true }` retries the first connect too. |
| `log` | silent | Any object with `debug`, `error`, `info`, `verbose` and `warn` methods: [Logging](#logging). |
-| `signal` | — | An `AbortSignal` that cancels connecting and tears the session down. |
+| `signal` | — | An `AbortSignal` that cancels connecting and closes the client. |
**Reconnect.** After a drop, an idle timeout, or a stream the library cannot frame, the client
-reopens the socket and re-binds, doubling the delay from `minDelay` (1 s) to `maxDelay` (30 s), and
-starts over at `minDelay` once a link has lasted `maxDelay`.
+opens a new socket, binds a new session on it, and announces it with `reconnected`, doubling the
+delay from `minDelay` (1 s) to `maxDelay` (30 s), and starting over at `minDelay` once a link has
+lasted `maxDelay`.
`reconnect: { fromStart: true }` puts the first connect and bind through the same loop, a bind the
SMSC refuses included, so a client started while its SMSC is down keeps retrying. `client()` then
-resolves once bound, and only an aborted `signal` ends the wait. That signal also closes the session
+resolves once bound, and only an aborted `signal` ends the wait. That signal also closes the client
once bound, so write a deadline as an `AbortController` you stop arming when `client()` returns,
not as `AbortSignal.timeout(ms)`.
@@ -257,6 +259,7 @@ All optional. Timeouts are milliseconds.
| `host`, `port` | all interfaces, `2775` | Where to listen. `port: 0` takes any free port; `smpp.port` says which. |
| `authenticate` | accept everything | `({ password, session, systemId, systemType }) => false \| { userData }`, sync or async. |
| `onRequest` | none | `(session, pduObj) => true \| false`, sync or async. First refusal on every request a bound peer sends: [Server in depth](#server-in-depth). |
+| `onSms` | none | Every message a bound peer submits, on any session: [Run an SMPP server](#run-an-smpp-server). Without one, submissions are refused with `ESME_RTHROTTLED`. |
| `systemId` | `''` | The SMSC identity returned in the bind response. |
| `interfaceVersion` | `0x34` | The SMPP version advertised in the bind response. Optional parameters are sent to a peer from `0x34` up, whatever this is set to. |
| `tls` | `false` | A `tls.TlsOptions` object with your certificate and key. A bare `true` is refused. |
@@ -269,7 +272,7 @@ All optional. Timeouts are milliseconds.
## Send options
```javascript
-await session.sendSms({
+await smpp.sendSms({
dlr: true, // ask for a delivery report
destinationAddrNpi: 0, // override the numbering plan of the recipient
destinationAddrTon: 1,
@@ -309,14 +312,14 @@ refuse a non-ASCII sender of its own accord, which reaches you as a refusal such
- An alphabet you name has to carry every character, or the send is refused before anything goes
out, naming the character, its code point and its index. Detection never refuses.
- `LATIN1` carries every octet, so `buffer.toString('latin1')` reaches the SMSC byte for byte, under
- `data_coding` 0x03, which declares Latin-1 text. To declare 8-bit binary, hand `session.send()` a
+ `data_coding` 0x03, which declares Latin-1 text. To declare 8-bit binary, hand `send()` a
`Buffer` body and the `data_coding` you want: [PDUs and the low-level API](#pdus-and-the-low-level-api).
- `consts.ENCODING` is the low-level `data_coding` table, not this option's list.
**Long messages.**
```javascript
-const { err, pduObjs, smsIds, unanswered } = await session.sendSms({ from, message, to });
+const { err, pduObjs, smsIds, unanswered } = await smpp.sendSms({ from, message, to });
```
- One id per segment. `smsIds` is positional with `pduObjs`, and an entry is `undefined` where the
@@ -351,54 +354,67 @@ Name a later instant as a `Date`, which goes out absolute.
an alphabet or a time you named, a string body under a `data_coding` you named. What you formed
yourself, a `Buffer` body or a stamp you formatted, passes through as written, except that a text
field is still checked: [PDUs and the low-level API](#pdus-and-the-low-level-api). The same rule
-holds for `session.send()`.
+holds for `send()`.
+
+## Client and session
-## Session
+A **session** is one socket's life: bound once, ended once when the socket closes, never revived.
+`server()` hands you one per connection. A **client** is the stable handle `client()` returns: it
+holds one bound session at a time, opens another when the link drops, and carries what outlives a
+session — the sends waiting for a link and the receipt merges. `smpp.session` is the current one,
+`undefined` while the link is down, and a different object after every reconnect.
-### Events
+### Client events
| Event | Fires when |
| --- | --- |
-| `sms` | An SMS arrives, reassembled if it was multipart. Carries `sendResp()`, `sendDlr()` and `smsId`. |
| `dlr` | A delivery report arrives, one per segment, with its PDU as the second argument: [Delivery receipts](#delivery-receipts). |
| `messageDlr` | Every segment of a long message sent with `dlr: true` has a final report: [Delivery receipts](#delivery-receipts). |
-| `close` | The session is over and nothing will bring the link back. Fires once, whether you closed it or the link failed for good. |
-| `disconnected` | The link dropped and the reconnect loop will retry. Do not open a replacement client: this session comes back on its own, and `reconnected` says when. Fires again for each attempt that reconnects and then fails, so it is not one-to-one with `reconnected`. |
-| `reconnected` | The client re-bound after a drop. |
-| `sessionError` | Something failed on a live session, a PDU the codec refused included: [Errors](#errors). |
+| `disconnected` | The session ended and the client will open another. Do not open a replacement client: `reconnected` says when it is back. |
+| `reconnected` | A new session is bound after a drop. |
+| `close` | The client is over: you closed it, `reconnect` is `false` and the link dropped, or the `signal` aborted. Fires once. |
+| `sessionError` | Something failed on the current session, a PDU the codec refused included: [Errors](#errors). |
+
+### Session events
+
+| Event | Fires when |
+| --- | --- |
+| `dlr` | As on the client, for this session's receipts. |
+| `close` | The socket closed. Fires once. |
+| `sessionError` | As on the client. |
| `data` | Raw bytes arrived on the socket. |
| `incomingPdu` | A complete PDU arrived, as a buffer. |
| `incomingPduObj` | The same PDU, parsed into an object. |
### Methods
-`sendSms()`, `send()`, `sendReturn()`, `unbind()` and `close()`.
+Client and session alike: `sendSms()`, `send()`, `unbind()` and `close()`. Session alone:
+`sendReturn()`, `bound()`, `bindAllows()`, `acceptsOptionalParams()`, `boundAs`,
+`peerInterfaceVersion`, `sock`, `closed`, `linkEnd`, `userData`.
**Shutdown.** `close()` and `unbind()` both:
-1. Refuse further sends. `sendDlr()` is the one send let past, when issued straight after
- `sendResp()`; await anything in between and it races the shutdown like any other send.
-2. Wait up to `shutdownTimeout` for the requests already sent, and for every `sms` the application
- has not answered. That wait ends when `sendResp()` puts the response on the wire (or, for a
- message answered on arrival, when it is called at all), or when every listener that took the
- message has failed. Answering through `sendReturn()` instead leaves the wait running.
-3. Tear down what is left, resolving to an `err` that says what was lost.
+1. Refuse further `sendSms()` and `send()` calls. `sendResp()` and `sendDlr()` still go out, since
+ answering and reporting are what the drain waits for.
+2. Wait up to `shutdownTimeout` for every `onSms` handler still running, then for the requests
+ already sent.
+3. Destroy the socket, resolving to an `err` that says what was lost.
-A message left unanswered for five minutes is no longer waited for.
-`close({ signal })` cuts the wait short. `unbind()` takes no signal, and waits a further
-`responseTimeout` for its own response.
+A handler running five minutes is no longer waited for. `close({ signal })` cuts the wait short.
+`unbind()` takes no signal, and waits a further `responseTimeout` for its own response. On the
+client both also stop the reconnect loop; a client whose link is down closes at once.
**Sends and the link.**
-- A send issued while the link is down waits for the reconnect and goes out once the new link is
- bound, up to `responseTimeout`, after which it gives up having sent nothing.
+- A client send issued while the link is down waits for the next session and goes out once it is
+ bound, up to `responseTimeout`, after which it gives up having sent nothing. One that was still
+ queued behind a full window when the link dropped goes out on the next session too.
- A request already on the wire when the link drops, when the peer fails to answer in time, or when
you abort it, fails and counts in `unanswered`: the SMSC may have taken it and lost only the
response.
-- With `reconnect: false` a drop ends the session, and every send after it is refused.
-- `sms.sendResp()` on a message whose link dropped writes nothing and returns `err`, since a response
- carries the sequence number of the link it arrived on. `sms.sendDlr()` still goes out on the new
- link.
+- With `reconnect: false` a drop ends the client, and every send after it is refused.
+- A message belongs to the session it arrived on. Once that socket closed, `sms.sendResp()` and
+ `sms.sendDlr()` return `err`.
- `responseTimeout` bounds the wait for a link and the wait for an answer separately, and the wait
for a `maxOutstanding` slot is unbounded, so it is not a deadline. For a deadline pass
`{ signal: AbortSignal.timeout(ms) }`: it cuts all three waits short, and a send it stops before
@@ -410,29 +426,28 @@ A message left unanswered for five minutes is no longer waited for.
**Raw commands.** `send()` reaches all 33 SMPP commands, not just the four the session handles itself:
```javascript
-const { err, pduObj } = await session.send({
+const { err, pduObj } = await smpp.send({
cmdName: 'query_sm',
params: { message_id: smsId },
});
```
-**The peer.**
+**The peer**, on a session:
- `acceptsOptionalParams()`: whether the peer declared SMPP 3.4 or later, the version from which
optional parameters may be sent to it. The library's own senders check it before attaching a TLV;
a `send()` you build is passed through as written, so check it yourself.
- `peerInterfaceVersion`: the version the peer declared, `0x00` if none, `undefined` before any bind.
- `bindAllows(cmdName)` and `boundAs`: what the bind direction carries: [Bind direction](#bind-direction).
-- `boundAs` and `peerInterfaceVersion` are read-only, and hold through a reconnect's gap until the
- link binds again.
- `bound(bindType, declaredVersion)`: how a session you construct yourself records a bind, whichever
- end accepted it, on every link it binds. `bindType` is `receiver`, `transceiver` or `transmitter`;
- `declaredVersion` is 0-255, or `undefined` where the peer declared none. Anything else returns `err`
- and records nothing.
-- An ESME wired by hand sends its own `bind_<bindType>` through `session.send()`, after it is
- constructed and again in `reconnect.onConnected`, and records each accepted one with
+ end accepted it. `bindType` is `receiver`, `transceiver` or `transmitter`; `declaredVersion` is
+ 0-255, or `undefined` where the peer declared none. Anything else returns `err` and records
+ nothing.
+- An ESME wired by hand (`new Session({ sock, onSms })`) sends its own `bind_<bindType>` through
+ `session.send()` and records the accepted one with
`session.bound(bindType, pduObj.tlvs.sc_interface_version?.tagValue)`, where `bindType` is the one
- it sent and `pduObj` the `bind_resp` that `send()` resolved with.
+ it sent and `pduObj` the `bind_resp` that `send()` resolved with. A session reconnects nothing:
+ open a new socket and a new `Session` when it closes.
## Receiving in depth
@@ -442,18 +457,26 @@ const { err, pduObj } = await session.send({
each is two messages.
- **Answered on arrival.** Each segment was answered as it landed, before you see the message:
[Server in depth](#server-in-depth).
-- **Unanswered messages.** While a session holds 1000 messages you have not called `sendResp()` on,
- or 64 MiB of them counted the way `maxOctets` counts segments, every new message and segment is
- refused so the peer retries it: `ESME_RTHROTTLED` on a submission, `ESME_RX_T_APPN` on a delivery.
- No `sms` fires. Reaching the bound logs one `warn`, and the first message accepted once both are
- down to half one `info`. A message left five minutes is dropped from the count with a `warn`; a
- later `sendResp()` still answers it. None of the three is an option.
+- **The handler.** `onSms` runs once per message and the message is answered when it settles:
+ `ESME_ROK` under `sms.smsId` on a bare return, the `{ smsId }` or `{ status }` returned, or the
+ retry status (`ESME_RTHROTTLED` on a submission, `ESME_RX_T_APPN` on a delivery) on a throw or a
+ rejection, which also reaches `sessionError`. `sendResp()` answers earlier and is what `sendDlr()`
+ waits for; after an answer, a returned `{ smsId }` or refusing `{ status }` is an error on
+ `sessionError` and a bare return is nothing. A message answered on arrival that the handler fails
+ on is lost traffic, reported on `sessionError`.
+- **No handler.** With no `onSms`, every message is refused with that retry status at once, so the
+ peer keeps it.
+- **Handlers still running.** While 1000 handlers have not returned, or 64 MiB of their messages
+ counted the way `maxOctets` counts segments, every new message and segment is refused with the
+ retry status. Reaching the bound logs one `warn`, and the first message accepted once both are
+ down to half one `info`. A handler running five minutes is no longer counted, with a `warn`. None
+ of the three is an option.
- **Where the body is.** A body in the `message_payload` TLV, SMPP's way of carrying up to 64 KB and
the only place a `data_sm` has, reads exactly like one in `short_message`, concatenated messages
and receipts included. A PDU filling both is read from `short_message`.
-- **`data_sm`.** A client reads an inbound `data_sm` as a delivery: a message arrives as `sms`, a
- receipt as `dlr`. A `server()` session reads it as a submission and always emits `sms`. Either way
- it is answered `data_sm_resp`.
+- **`data_sm`.** A client reads an inbound `data_sm` as a delivery: a message goes to `onSms`, a
+ receipt to `dlr`. A `server()` session reads it as a submission and always hands it to `onSms`.
+ Either way it is answered `data_sm_resp`.
- **Flash.** `sms.flash` is true where `data_coding` carries GSM 03.38 message class 0, in every
coding group that carries one: `0x10`, `0x18`, `0x50` and `0xF0` alike. Classes 1 to 3 name where
the handset stores the message and are not flash.
@@ -483,7 +506,7 @@ decimal `id:` in the receipt, or one of them zero-padded, and the comparison the
Name each notation and both are read into plain decimal:
```javascript
-const { err, session } = await client({ smsIdFormat: { receipt: 'decimal', submitResp: 'hex' } });
+const { err, client: smpp } = await client({ smsIdFormat: { receipt: 'decimal', submitResp: 'hex' } });
```
`receipt` is the notation of the body's `id:`; `submitResp` that of the `message_id` in
@@ -504,12 +527,12 @@ even where the SMSC took some of its segments; their receipts still arrive as `d
**Multipart is answered on arrival.** Each segment is answered as it lands, because a relaying SMSC
will not send the next until the last is answered. The answer is `ESME_ROK`, unless the segment
numbers itself into no message this session can join, which refuses it, or the reassembly buffer or
-the unanswered messages are at their bound, which asks the SMSC to keep it and try again.
+the handlers still running are at their bound, which asks the SMSC to keep it and try again.
`sms.answeredOnArrival` says whether the message you hold was answered that way; a segment count
cannot, since a peer may number a message one part of one.
-- The id was fixed with the first segment, so `sendResp()` there puts nothing on the wire and
- releases the message, and returns `err` for an `smsId` or a refusing `status`.
+- The id was fixed with the first segment, so `sendResp()` there puts nothing on the wire, and both
+ it and the handler's return refuse an `smsId` or a refusing `status`.
- `sms.smsId` is the base. `sendDlr()` names `<smsId>-1`, `<smsId>-2` and so on: the ids the
`submit_sm` responses carried.
- A `deliver_sm` is answered with no id at all, since SMPP marks that field unused, so an inbound
@@ -517,7 +540,7 @@ cannot, since a peer may number a message one part of one.
**Refusing a request** for a reason in the request rather than the message (a full queue, an unknown
recipient, an unauthorised sender) has to land before a segment is answered. `onRequest` runs on
-every request a bound peer sends, before reassembly and before the `sms` event:
+every request a bound peer sends, before reassembly and before `onSms`:
```javascript
import { isCommand, server } from '@larvit/smpp';
@@ -555,21 +578,42 @@ if (err) throw err;
**`sendDlr()`** takes `SCHEDULED`, `ENROUTE`, `DELIVERED`, `EXPIRED`, `DELETED`, `UNDELIVERABLE`,
`ACCEPTED`, `UNKNOWN`, `REJECTED` or `SKIPPED`. The first two go out as intermediate delivery
-notifications (`esm_class` 0x20), the rest as delivery receipts (0x04).
+notifications (`esm_class` 0x20), the rest as delivery receipts (0x04). A receipt names a message
+the peer had accepted, so before the message is answered it returns `err`: call `sendResp()` first
+inside the handler, or keep `sms` and report after the handler returned.
### Bind direction
The three bind types are honoured in both directions, whichever end of the link the session is:
-- `session.sendSms()` on a receiver-bound session, and `sms.sendDlr()` to a transmitter-bound peer,
+- `sendSms()` on a receiver-bound client or session, and `sms.sendDlr()` to a transmitter-bound peer,
return `err` before anything reaches the wire.
- A `submit_sm` arriving on a receiver-bound session, or a `deliver_sm` on a transmitter-bound one,
is answered `ESME_RINVBNDSTS`.
- `data_sm` carries a message either way, so which end the session is decides: a client refuses one
on a transmitter bind, a `server()` session on a receiver bind. `bindAllows('data_sm')` answers for
the inbound direction. A `Session` you construct yourself is the ESME end, as `client()` builds;
- a hand-wired SMSC sets `session.linkEnd = 'smsc'`, as `server()` does.
-- `transceiver`, the default, carries both. `session.send()` is a passthrough and is not checked.
+ a hand-wired SMSC passes `linkEnd: 'smsc'`, as `server()` does.
+- `transceiver`, the default, carries both. `send()` is a passthrough and is not checked.
+
+## Glossary
+
+The SMPP terms this README uses, as SMPP 3.4 uses them.
+
+| Term | |
+| --- | --- |
+| ESME | External Short Message Entity: the application end of a link, the one `client()` builds. |
+| SMSC, MC | Short Message Service Centre, or Message Centre: the operator's end, the one `server()` stands in for. |
+| bind | The login that opens an SMPP session on a socket: `bind_transmitter`, `bind_receiver` or `bind_transceiver`, naming which way messages may travel. |
+| PDU | Protocol Data Unit: one SMPP command or response on the wire, a 16-octet header and a body. |
+| `submit_sm`, `deliver_sm` | The message-carrying commands: an ESME submits, an SMSC delivers. `data_sm` carries a message either way. |
+| DLR | Delivery report, or delivery receipt: the SMSC's word about a message you submitted, carried on `deliver_sm`. |
+| `esm_class` | The octet that says what kind of PDU a message is: a receipt, a notification, a segment with a UDH. |
+| `data_coding` | The octet that names the alphabet a body is written in, and a message class beside it. |
+| UDH | User Data Header: octets at the start of a body that number a long message's segments. |
+| `sar_*` | The TLVs that number segments where a UDH does not: `sar_msg_ref_num`, `sar_total_segments`, `sar_segment_seqnum`. |
+| TLV | Tag-Length-Value: an optional parameter after a PDU's body, SMPP 3.4 and later. |
+| send window | How many requests may be on the wire unanswered at once: `maxOutstanding`. |
## Logging
@@ -583,7 +627,7 @@ static; every dynamic value is in the metadata, so entries group by message.
import { Log } from '@larvit/log';
import { client } from '@larvit/smpp';
-const { err, session } = await client({ log: new Log('debug') });
+const { err, client: smpp } = await client({ log: new Log('debug') });
```
So does an object of your own:
@@ -650,7 +694,7 @@ if (isCommand(pduObj, 'submit_sm')) {
array is refused.
- A `Buffer` goes out exactly as given under any `data_coding`: binary payloads, hand-built user
data headers, deliberately malformed bodies.
-- `session.send()` and `session.sendReturn()` build through the same codec and refuse the same bodies.
+- `send()` and `session.sendReturn()` build through the same codec and refuse the same bodies.
- `unencodable(message, encoding)`: `{ char, index }` for the first character an alphabet cannot
carry, `undefined` where it carries them all. The check `sendSms()` makes before encoding.
- `dataCodingByEncoding[encoding]`: the `data_coding` this library writes each alphabet under, which
@@ -662,13 +706,13 @@ if (isCommand(pduObj, 'submit_sm')) {
| | |
| --- | --- |
-| Sessions | `client`, `server`, `Session`, `SmppServer` |
+| Sessions | `client`, `server`, `SmppClient`, `SmppServer`, `Session` |
| Codec | `pduToObj`, `objToPdu`, `pduReturn`, `isCommand`, `isResp`, `PduFramer`, `PduRefusedError`, `maxPduLength`, `maxSeqNr` |
| Messages | `encodeMessage`, `decodeMessage`, `splitMessage`, `bitCount`, `messageOctets`, `concatOf`, `concatInfo`, `detect`, `unencodable`, `messageClassOf`, `dataCodingByEncoding`, `encodingByDataCoding` |
| Receipts | `dlrFromPdu`, `parseReceipt`, `receiptCodes` |
| Time and ids | `smppDate`, `smppTime`, `uuidv7` |
| Spec tables | `cmds`, `consts`, `encodings`, `errors`, `tlvs`, `types`, the `cmdsById`, `constsById`, `errorsById` and `tlvsById` maps, and all of them grouped as `defs`. `isCommandName`, `isErrorName`, `isEncodingName`, `isTlvName`, `commandNameById` and `errorNameById` narrow a value into them. |
-| Types | Every option, result, event payload and table entry has a named type: `ClientOptions`, `ServerOptions`, `SendSmsOptions`, `SendSmsResult`, `Sms`, `Dlr`, `MessageDlr`, `Receipt`, `PduObject`, `PduHeader`, `SmppLog`, `Result` and the rest in `dist/index.d.ts`. |
+| Types | Every option, result, event payload and table entry has a named type: `ClientOptions`, `ClientEvents`, `ServerOptions`, `SessionOptions`, `OnSms`, `SendSmsOptions`, `SendSmsResult`, `SendRespOptions`, `Sms`, `Dlr`, `MessageDlr`, `Receipt`, `PduObject`, `PduHeader`, `SmppLog`, `Result` and the rest in `dist/index.d.ts`. |
## What changed per release
diff --git a/benchmarks/smsc-sink.ts b/benchmarks/smsc-sink.ts
index 324c11e..deb47c0 100644
--- a/benchmarks/smsc-sink.ts
+++ b/benchmarks/smsc-sink.ts
@@ -5,21 +5,14 @@ import { server } from '../src/server.ts';
* library's own ceiling rather than an SMSC's storage. Prints the bound port on stdout, then waits.
*/
const port = Number(process.env.PORT ?? 0);
-const { err, server: smpp } = await server({ port });
+let answered = 0;
+const { err, server: smpp } = await server({ onSms: () => { answered++; }, port });
if (err) {
process.stderr.write(`sink failed to listen: ${err.message}\n`);
process.exit(1);
}
-let answered = 0;
-
-smpp.on('session', session => {
- session.on('sms', async sms => {
- answered++;
- await sms.sendResp();
- });
-});
smpp.on('serverError', reason => {
process.stderr.write(`sink serverError: ${reason.message}\n`);
diff --git a/benchmarks/submit-load.ts b/benchmarks/submit-load.ts
index 95de428..85f3c57 100644
--- a/benchmarks/submit-load.ts
+++ b/benchmarks/submit-load.ts
@@ -19,7 +19,7 @@ const dlr = process.argv.includes('--dlr');
const username = arg('username', 'user');
const password = arg('password', 'pass');
-const { err, session } = await client({
+const { err, client: esme } = await client({
host,
maxOutstanding: concurrency,
password,
@@ -34,7 +34,7 @@ if (err) {
}
// Narrowing from the guard above does not reach into worker(), which runs after it.
-const bound = session;
+const bound = esme;
let issued = 0;
let failed = 0;
diff --git a/docs/decisions.md b/docs/decisions.md
index c245097..ecf9f5b 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -499,19 +499,42 @@ rule and an index of the titles below.
## The session's life
+- **A session is one socket's life, and the client is the composition that outlives it.**
+ Maintainer's call, 2026-09-29, from the third comprehension round: every reader panel named the
+ session lifecycle as the unit it least wanted to touch — a phase enum with a separate stopped
+ flag, seven predicates over it read by three collaborators, a generation counter, requests held
+ for a link that may never come, and a drain spanning links. Serves goal 8 (reshapeable internals a
+ reader can hold) and goal 5 (the session layer, on its defaults). A `Session` is bound once,
+ ends once when its socket closes, and is never revived: `end()` runs from the socket's `close`
+ event and nothing else, so close(), unbind(), the idle timeout, an unframeable stream and the
+ peer's unbind all reach it the same way, by destroying the socket. Reconnect is `SmppClient`, a
+ composition above it: `ReconnectLoop` opens a bound session per link on the backoff and hands out
+ the current one, the client holds the concat reference and the receipt merges, and a send waits
+ in the client for a link and is retried there only on `LinkLostError`, the one error that says
+ nothing reached the socket. Rejected: one state machine inside the session (round two's draft B),
+ which organised the complexity where this removes it. Rejected: a stream of sessions handed to
+ the application, which makes goal 5's reconnect the application's to wire. Accepted: `messageDlr`
+ is a client event, so a hand-wired `Session` merges nothing; and a message belongs to its
+ session, below. Valid while goal 5 keeps reconnect inside the library.
+
- **A close arriving after our own `unbind` is a clean unbind, not an error.** Maintainer's call,
2026-08-26: most SMSCs drop the socket instead of answering, so the documented shutdown would
otherwise always report a failure. It does mask a socket that died mid-unbind for an unrelated
reason, which is accepted — the peer sees the same TCP close either way.
-- **`close` means the session is over, and a drop the loop will retry is `disconnected`.**
+- **`close` means the client is over, and a drop the loop will retry is `disconnected`.**
Maintainer's call, 2026-08-31: without the split, an application that opens a replacement client on
- `close` ends up holding two binds on one account, which goal 4 forbids.
-
-- **An answer belongs to the link the message arrived on; a receipt does not.** Maintainer's call,
- 2026-09-01. Rejected: answering on the new link, which succeeds and reports `{}` for a response
- that correlates with nothing — goal 2's wrong answer. Accepted: a receipt sent after a refused
- response names an id the peer has no record of.
+ `close` ends up holding two binds on one account, which goal 4 forbids. A `close()` called from a
+ `sessionError` listener during a drop stops the loop before the drop is classified, so it reports
+ `close` alone: the application decided, and no retry follows.
+
+- **A message belongs to the session it arrived on, its answer and its receipt alike.** Maintainer's
+ call, 2026-09-01, narrowed 2026-09-29: a response carries the sequence number of the link it
+ arrived on, so answering on a new link succeeds and reports `{}` for a response that correlates
+ with nothing — goal 2's wrong answer. A receipt used to go out on the next link; now an `Sms` holds
+ its session's port and nothing else, since only a server session sends receipts and a server
+ session has no next link. Accepted: a hand-wired ESME that reports on a message across its own
+ reconnect gets `err` and opens the receipt on the new session itself.
- **`reconnect` takes `{ minDelay, maxDelay }` to retune and `false` to turn off**, so absent means
on and there is one spelling for each. Only `client()` reconnects — a `server()` session is a
@@ -653,28 +676,42 @@ rule and an index of the titles below.
fall-through could be gated on it, which buys a fail-open path with state and an internal contract
no other collaborator needs.
-- **The drain waits on the messages the application holds, and `sendResp()` is what says it is done
- with one.** Maintainer's call, 2026-09-01: waiting on the send window alone tore a server session
- down while the application was still answering a `submit_sm`, so the peer timed out and re-sent —
- the duplicate goal 2 forbids, in the direction the window already covers. No completion signal was
- added to the `sms` event: `sendResp()` is what an application already calls when it is done with a
- message, so it is the one the drain waits for. Counting every inbound request until `sendReturn()`
- answered it was rejected: an `onRequest` that deliberately answers nothing would then cost a full
- `shutdownTimeout` on every close. The response reaching the wire ends the wait, so a `sendResp()`
- the library refused or the socket would not carry leaves `close()` still reporting the message the
- peer is owed.
-
-- **The drain's wait on the application ignores `shutdownTimeout: 0`.** Waiting forever is safe for
- the peer, whose every request is bounded by `responseTimeout` unless the caller set that to 0 as
- well, and unsafe for the application, which nothing bounds — `close()` is what you reach for when
- the application is stuck, so it may not block on the application coming unstuck. That half falls
- back to `responseTimeout`, the same answer `LinkLife`'s hold already takes — and to that
- option's default where it is 0 as well, since neither option is an answer about the application.
-
-- **What the application holds unanswered is capped on constants, and a message past the cap is
- refused.** A bound the application cannot raise is the point: an application that answers nothing
+- **An inbound message is an `onSms` handler's, and the handler settling is what answers it.**
+ Maintainer's call, 2026-09-29, from the third comprehension round: the `sms` event capped every
+ reader — listener counts, `captureRejections` routed through a `WeakMap`, a `setImmediate` turn
+ and "answered" kept in three places. One handler is called directly and awaited, so a rejection
+ is a `catch`, and `sendResp()` in `sms.ts` is the one place a message is answered: it writes
+ once, and the handler's settle calls it for whatever it has not. Goal 2 settles the outcomes: a
+ bare return is the application saying it took the message, so `ESME_ROK`; a throw or rejection is
+ the application not taking it, so the retry status and the peer sends it again — never `ESME_ROK`
+ before the application has it, which is where round two's draft C failed. `sendResp()` stays for
+ the handler that must answer before it reports, since a receipt names an accepted message and
+ `sendDlr()` is refused until the answer. Rejected: the handler's return as the only answer, which
+ makes a receipt from inside the handler impossible or implicit. Rejected: implying the answer from
+ `sendDlr()`, which hides the wire order behind a method. Accepted: a returned `{ smsId }` after
+ `sendResp()` is a contradiction reported on `sessionError` and never written.
+
+- **A session with no `onSms` refuses every message with the retry status.** Maintainer's call,
+ 2026-09-29, on the judgement call round two's draft D raised. Goal 4: an unanswered `deliver_sm`
+ has the peer time out and resend into silence, where `ESME_RX_T_APPN` (or `ESME_RTHROTTLED` on a
+ submission) tells it to keep the message; goal 2: nothing is dropped and nothing is claimed taken.
+ Rejected: answering `ESME_ROK` for nobody, which loses the message.
+
+- **The drain waits on the `onSms` handlers still running, then on the requests on the wire, and a
+ handler running five minutes is no longer counted.** Maintainer's call, 2026-09-01, restated
+ 2026-09-29: waiting on the send window alone tore a server session down while the application was
+ still answering a `submit_sm`, so the peer timed out and re-sent — the duplicate goal 2 forbids.
+ The unit is the running handler, which is what the bound counts too, so one store answers both.
+ `shutdownTimeout: 0` waits forever for both halves, because the five-minute bound on a counted
+ handler is what keeps `close()` from blocking on an application that never returns; the old fall
+ back to `responseTimeout` for the message half is gone with the reason for it. `sendResp()` and
+ `sendDlr()` go out past the drain's refusal for as long as the socket lives, since answering and
+ reporting are what the drain waits for.
+
+- **What the running handlers hold is capped on constants, and a message past the cap is
+ refused.** A bound the application cannot raise is the point: an application that never returns
would otherwise hold ever more messages, which goal 4 forbids. Reassembly's `maxOctets` is an
- option because it bounds what the peer sends; this bounds what the application leaves unanswered.
+ option because it bounds what the peer sends; this bounds what the application holds.
Maintainer's call, 2026-09-26. Refusing leaves the message with the peer, which will send it again
(goal 2). Rejected: dropping the oldest to make room, which frees nothing while the application
still holds its `Sms`, and stops the drain waiting for a message the peer is owed. Rejected:
@@ -693,23 +730,19 @@ rule and an index of the titles below.
`ESME_RTHROTTLED` is the SMSC's to send, so an ESME answers with SMPP 3.4's temporary receiver
error, the one an SMSC retries on (goal 3).
-- **A reconnect keeps the delivery-receipt merges; everything else the link held is dropped.**
- `onDelivery()` answers each receipt before the group it belongs to is complete, and `teardown()`
- runs on every path — an idle timeout and a failed rebind, not only `close()` — so clearing the
- merges there loses receipts no peer has a reason to send again. They are cleared where the session
- is over instead. Inbound segments stay in `teardown()`: a concatenation reference is the
- peer's own counter, so a half-arrived group kept across a drop would take a later message's
- segments as readily as the rest of its own, and goal 2 will not hand the application a message
- assembled that way. What goes there is traffic already answered, which is why each group reaches
- `sessionError` like every other one given up on.
-
-- **The bind state is the session's, `bound()` alone writes it, and it holds through a reconnect's
- gap.** Maintainer's call, 2026-09-28. `client()` and `server()` record their bind through
- `bound()`, the call a hand-wired session makes, so the state has one writer — goal 8. Clearing the state at `teardown()` was rejected: `bindAllows()` and
- `acceptsOptionalParams()` then answer yes to everything while the link is down, so a
- receiver-bound client queues a `submit_sm` the peer refuses and a receipt built then carries TLVs a
- pre-3.4 peer must not get — goal 4. Valid while the reconnect loop binds again with the same bind
- type to the same peer.
+- **The client owns the delivery-receipt merges; everything else the link held ends with its
+ session.** Each receipt is answered before the group it belongs to is complete, so a merge that
+ died with a session would lose receipts no peer has a reason to send again; `DlrMerger` lives in
+ `SmppClient` and is fed from each session's `dlr`. Inbound segments end with the session: a
+ concatenation reference is the peer's own counter, so a half-arrived group kept across a drop
+ would take a later message's segments as readily as the rest of its own, and goal 2 will not hand
+ the application a message assembled that way. What goes there is traffic already answered, which
+ is why each group reaches `sessionError` like every other one given up on.
+
+- **The bind state is the session's, and `bound()` alone writes it.** Maintainer's call,
+ 2026-09-28. `client()` and `server()` record their bind through `bound()`, the call a hand-wired
+ session makes, so the state has one writer — goal 8. In a reconnect's gap there is no session, so
+ `SmppClient.sendSms()` refuses a receiver bind from its own `bindType` option.
- **A message id base is merged at most once.** A receipt carries nothing but `<base>-<n>`, so a
straggler for a message whose group is gone cannot be told from a receipt for a later message the
@@ -750,14 +783,13 @@ rule and an index of the titles below.
an abort while held for a link already gives. The drain half needs nothing: `close({ signal })` already hands the signal to
`window.idle()`, and `unbind()` taking none is the shape README states.
-- **One owner decides whether a link can carry a request, and a bind is what makes it one.**
- Maintainer's call, 2026-09-01, extended 2026-09-28; goal 1, since a send on a link not yet bound
- comes back `ESME_RINVBNDSTS`. `LinkLife` is told what happened and never reads back into the
- session; every other collaborator reads it and keeps no copy. Rejected: gating on the socket being
- attached, which admits a send one round trip before the bind is answered, and collaborators that
- ask the session, which answered the same question two ways at admit and at release.
- `ReconnectLoop.halted` is the one other flag, because `client()` also runs a loop with no session
- behind it for `fromStart`; a session's loop is stopped by `Session.stop()` alone.
+- **The reconnect loop alone decides whether a link can carry a request, and a bound session is
+ one.** Maintainer's call, 2026-09-01, restated 2026-09-29; goal 1, since a send on a link not yet
+ bound comes back `ESME_RINVBNDSTS`. `ReconnectLoop.adopt()` takes a session only once
+ `openBoundSession()` bound it, so `bound()` resolves with nothing that cannot carry a request, and
+ the session itself keeps two booleans — `stopping` for a drain begun and `over` for a socket gone
+ — and no phase. Rejected: gating on the socket being open, which admits a send one round trip
+ before the bind is answered.
## Internals and tests
@@ -777,7 +809,7 @@ rule and an index of the titles below.
handlers normalise through `errorFrom()` rather than inline — a route out of the handler would land
on a bare `process.nextTick` with nothing to catch it.
-- **The four-line abort dance is copied across `LinkLife`, `IdleWaiters`, `PendingRequests` and
+- **The four-line abort dance is copied across `ReconnectLoop`, `IdleWaiters`, `PendingRequests` and
`SendWindow` rather than extracted.** Architecture review, 2026-09-06: pre-check `aborted`, attach
`{ once: true }`, detach on settle, leave the registry. What differs at each site is the registry
and what settling means — a FIFO handing over a slot, a set released together, a map keyed by
diff --git a/interop-tests/cloudhopper.test.ts b/interop-tests/cloudhopper.test.ts
index dca5b5b..fc93a23 100644
--- a/interop-tests/cloudhopper.test.ts
+++ b/interop-tests/cloudhopper.test.ts
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test, { after, describe } from 'node:test';
+import type { OnSms } from '../src/session-options.ts';
import type { Session } from '../src/session.ts';
import type { Sms } from '../src/sms.ts';
import type { SmppServer } from '../src/server.ts';
@@ -38,26 +39,43 @@ async function driver(path: string, params: Record<string, string> = {}): Promis
return response.json() as Promise<DriverResult>;
}
-const manualTexts = new Set<string>();
+/** Messages a test answers itself: the handler holds each until released, since returning answers it. */
+const manualTexts = new Map<string, Promise<void>>();
const allSms: { session: Session; sms: Sms }[] = [];
-function attach(session: Session): void {
- session.on('sms', sms => {
- allSms.push({ session, sms });
+function answerManually(text: string): () => void {
+ let release = (): void => undefined;
- if (manualTexts.has(sms.message)) return;
+ manualTexts.set(text, new Promise(resolve => { release = resolve; }));
- // The slow server this phase's window scenarios need: every ordinary submit is held for
- // SLOW_DELAY_MS before being answered, so a burst genuinely presses on a small window.
- void delay(SLOW_DELAY_MS).then(() => sms.sendResp());
- });
+ return () => { release(); };
}
-const { err: serverErr, server: smpp } = await server({ authenticate: () => true, idleTimeout: 40_000, port: SMPP_PORT });
+const handleSms: OnSms = async sms => {
+ allSms.push({ session: sms.session, sms });
+
+ const held = manualTexts.get(sms.message);
+
+ if (held) {
+ await held;
+
+ return;
+ }
+
+ // The slow server this phase's window scenarios need: every ordinary submit is held for
+ // SLOW_DELAY_MS before being answered, so a burst genuinely presses on a small window.
+ await delay(SLOW_DELAY_MS);
+};
+
+const { err: serverErr, server: smpp } = await server({
+ authenticate: () => true,
+ idleTimeout: 40_000,
+ onSms: handleSms,
+ port: SMPP_PORT,
+});
assert.equal(serverErr, undefined);
assert.ok(smpp);
-smpp.on('session', attach);
const key = readFileSync('/shared-certs/server.key');
const cert = readFileSync('/shared-certs/server.crt');
@@ -67,13 +85,13 @@ const cert = readFileSync('/shared-certs/server.crt');
const { err: tlsServerErr, server: tlsSmpp } = await server({
authenticate: () => true,
idleTimeout: 40_000,
+ onSms: handleSms,
port: TLS_PORT,
tls: { cert, key, maxVersion: 'TLSv1.2' },
});
assert.equal(tlsServerErr, undefined);
assert.ok(tlsSmpp);
-tlsSmpp.on('session', attach);
after(async () => {
await smpp.close();
@@ -138,8 +156,7 @@ describe('S5 - request expiry shorter than the handler delay (target 11)', () =>
await waitForSessionCount(smpp, 1);
const text = 'expiry-probe';
-
- manualTexts.add(text);
+ const release = answerManually(text);
const submitted = driver('/submit', { session: 'expiry', text, timeoutMs: '5000' });
const sms = await waitFor(() => allSms.find(entry => entry.sms.message === text)?.sms);
@@ -150,6 +167,7 @@ describe('S5 - request expiry shorter than the handler delay (target 11)', () =>
// monitor gives up on it first - recorded, not asserted against, since that is the peer's call.
await delay(1000);
await sms.sendResp();
+ release();
const result = await submitted;
@@ -196,14 +214,14 @@ describe('a refusing status is surfaced back to Cloudhopper', () => {
await waitForSessionCount(smpp, 1);
const text = 'ch-refuse-me';
-
- manualTexts.add(text);
+ const release = answerManually(text);
const submitted = driver('/submit', { session: 'refuse', text, timeoutMs: '5000' });
const sms = await waitFor(() => allSms.find(entry => entry.sms.message === text)?.sms);
assert.ok(sms);
await sms.sendResp({ status: 'ESME_RMSGQFUL' });
+ release();
const result = await submitted;
diff --git a/interop-tests/dumbclient.test.ts b/interop-tests/dumbclient.test.ts
index 81bc9ff..59c1ac1 100644
--- a/interop-tests/dumbclient.test.ts
+++ b/interop-tests/dumbclient.test.ts
@@ -101,20 +101,6 @@ const memTimer = setInterval(() => {
memTimer.unref();
-const { err, server: smpp } = await server({
- authenticate: ({ systemId }) => ({ userData: { systemId } satisfies ScenarioUserData }),
- idleTimeout: 40_000,
- log,
- port: SMPP_PORT,
-});
-
-assert.equal(err, undefined);
-assert.ok(smpp);
-
-const serverErrors: Error[] = [];
-
-smpp.on('serverError', serverError => { serverErrors.push(serverError); });
-
const sessionByScenario = new Map<string, Session>();
const slowQueues = new Map<Session, Promise<void>>();
@@ -126,23 +112,27 @@ function answered(session: Session, arrivalIndex: number, result: { err?: Error
if (result.err) s.unansweredErrors++;
}
-function slowRespond(session: Session, sms: Sms, arrivalIndex: number): void {
+function slowRespond(session: Session, sms: Sms, arrivalIndex: number): Promise<void> {
const chain = (slowQueues.get(session) ?? Promise.resolve())
.then(async () => { await delay(SLOW_HANDLER_DELAY_MS); })
.then(async () => { answered(session, arrivalIndex, await sms.sendResp()); });
slowQueues.set(session, chain);
-}
-function fastRespond(session: Session, sms: Sms, arrivalIndex: number): void {
- void sms.sendResp().then(result => { answered(session, arrivalIndex, result); });
+ return chain;
}
-smpp.on('session', session => {
- // Attached now, not lazily in a test body - see the comment on ScenarioStats.closed.
- session.on('close', () => { statsFor(scenarioOf(session)).closed = true; });
+async function fastRespond(session: Session, sms: Sms, arrivalIndex: number): Promise<void> {
+ answered(session, arrivalIndex, await sms.sendResp());
+}
- session.on('sms', sms => {
+const { err, server: smpp } = await server({
+ authenticate: ({ systemId }) => ({ userData: { systemId } satisfies ScenarioUserData }),
+ idleTimeout: 40_000,
+ log,
+ // Answers through sendResp() so the result feeds the stats; the handler runs until the answer is out.
+ onSms: sms => {
+ const { session } = sms;
const name = scenarioOf(session);
sessionByScenario.set(name, session);
@@ -155,9 +145,23 @@ smpp.on('session', session => {
else s.ids.add(sms.smsId);
s.peakOutstanding = Math.max(s.peakOutstanding, s.arrived - s.answered);
- if (name === 'dumb-w500' || name === 'dumb-w2000') slowRespond(session, sms, arrivalIndex);
- else fastRespond(session, sms, arrivalIndex);
- });
+ return name === 'dumb-w500' || name === 'dumb-w2000'
+ ? slowRespond(session, sms, arrivalIndex)
+ : fastRespond(session, sms, arrivalIndex);
+ },
+ port: SMPP_PORT,
+});
+
+assert.equal(err, undefined);
+assert.ok(smpp);
+
+const serverErrors: Error[] = [];
+
+smpp.on('serverError', serverError => { serverErrors.push(serverError); });
+
+smpp.on('session', session => {
+ // Attached now, not lazily in a test body - see the comment on ScenarioStats.closed.
+ session.on('close', () => { statsFor(scenarioOf(session)).closed = true; });
});
function memShape(): string {
@@ -206,10 +210,10 @@ after(async () => {
// S9 (target 11) and the backpressure-at-server scenario: window 2000 at a high rate against a
// handler slowed enough to build a real backlog. window500 is the same shape with a window below
-// maxHeldMessages (1000, session-options.ts defaults.maxHeldMessages), the bound past which a
-// peer's window is answered ESME_RTHROTTLED. smpp-dumb-client counts a throttled message as sent
-// and never resends it, so window 2000 accounts for 20,000 as answered plus throttled.
-const throttleMessage = 'session - unanswered messages at their bound, asking the peer to retry';
+// maxRunningHandlers (1000, src/defaults.ts), the bound past which a peer's window is answered
+// ESME_RTHROTTLED. smpp-dumb-client counts a throttled message as sent and never resends it, so
+// window 2000 accounts for 20,000 as answered plus throttled.
+const throttleMessage = 'session - handlers at their bound, asking the peer to retry';
// window500's peak (<=500) and the soak's never reach the 1000 default, so every refusal is
// necessarily from the w2000 session - the runs share one server and one log.
@@ -236,7 +240,7 @@ describe('S9 - bounded window against a slowed handler', () => {
});
}
- test('window 2000 pressed past maxHeldMessages (1000): the peer is throttled, window500 never is', () => {
+ test('window 2000 pressed past maxRunningHandlers (1000): the peer is throttled, window500 never is', () => {
assert.ok(throttled('dumb-w2000') > 0, 'expected at least one ESME_RTHROTTLED under window 2000');
assert.ok(statsFor('dumb-w2000').peakOutstanding <= 1000);
assert.equal(statsFor('dumb-w500').peakOutstanding <= 500, true);
@@ -278,8 +282,8 @@ describe('S6 - idle peer, no enquire_link at all', () => {
assert.ok(droppedIdle, 'server never dropped the idle peer within idleTimeout + slack');
assert.ok(logEntries.some(entry => entry.message === 'linkTimers - closing an idle peer'));
- // teardown() (session.ts) is a raw close, not an unbind exchange - nothing further is on the
- // wire for this session, which the capture's histogram (findings/07-load.md) confirms.
+ // The idle drop (link-timers.ts) destroys the socket, no unbind exchange - nothing further is
+ // on the wire for this session, which the capture's histogram (findings/07-load.md) confirms.
assert.equal(statsFor('dumb-idle').answered, 1);
});
});
diff --git a/interop-tests/jasmin.test.ts b/interop-tests/jasmin.test.ts
index c6c9b92..71debb1 100644
--- a/interop-tests/jasmin.test.ts
+++ b/interop-tests/jasmin.test.ts
@@ -70,6 +70,15 @@ function variantFromSystemId(systemId: string): UpstreamVariant | undefined {
return undefined;
}
+/** The variant authenticate() recorded on the session, once it has run. */
+function variantOf(session: Session): UpstreamVariant | undefined {
+ const { userData } = session;
+
+ if (typeof userData !== 'object' || userData === null || !('variant' in userData)) return undefined;
+
+ return userData.variant === 'main' || userData.variant === 'datasm' ? userData.variant : undefined;
+}
+
const { err: upstreamErr, server: upstream } = await server({
authenticate: ({ password, systemId }) => {
// <=8 chars: Jasmin's own bind-PDU encoder enforces SMPP's 8-char password maximum strictly
@@ -86,6 +95,18 @@ const { err: upstreamErr, server: upstream } = await server({
// was already in flight for Jasmin's own requeue_delay (120s default) before it retries - far past
// any per-test wait budget here - so this is generous specifically to never be the trigger.
idleTimeout: 300_000,
+ onSms: async sms => {
+ const variant = variantOf(sms.session);
+
+ if (variant) upstreamSms.push({ sms, variant });
+
+ await sms.sendResp();
+
+ if (sms.dlr) {
+ await delay(150);
+ await sms.sendDlr('DELIVERED');
+ }
+ },
port: UPSTREAM_PORT,
});
@@ -97,7 +118,7 @@ const upstreamServer = upstream;
upstreamServer.on('session', session => {
// `session` fires on raw connect, before authenticate() has run - session.userData is not set
// yet, so the map is populated off the bind PDU itself (like kannel.test.ts's bindPdus), not off
- // userData; userData is only read later, from 'sms', where authenticate() has long since run.
+ // userData; userData is only read later, from onSms, where authenticate() has long since run.
session.on('incomingPduObj', pduObj => {
if (!pduObj.cmdName.startsWith('bind_')) return;
@@ -105,21 +126,6 @@ upstreamServer.on('session', session => {
if (variant) upstreamSessions.set(variant, session);
});
-
- session.on('sms', sms => {
- const variant = (session.userData as { variant?: UpstreamVariant } | undefined)?.variant;
-
- if (variant) upstreamSms.push({ sms, variant });
-
- void (async () => {
- await sms.sendResp();
-
- if (sms.dlr) {
- await delay(150);
- await sms.sendDlr('DELIVERED');
- }
- })();
- });
});
async function waitForUpstreamSession(variant: UpstreamVariant, budget = 20_000): Promise<Session> {
@@ -258,31 +264,32 @@ describe('C1 - bind, enquire_link, unbind', () => {
// own default 20s keepalive counts as activity and resets it, so Jasmin's probe never has a
// chance to fire on its own. Disabling ours (and widening idleTimeout, which defaults off
// enquireLinkInterval and would otherwise become 0) leaves the link quiet long enough to see it.
- const { err, session } = await bind(USERNAME, PASSWORD, { enquireLinkInterval: 0, idleTimeout: 60_000 });
+ const { err, client: esme } = await bind(USERNAME, PASSWORD, { enquireLinkInterval: 0, idleTimeout: 60_000 });
assert.equal(err, undefined);
- assert.ok(session);
+ assert.ok(esme);
+ assert.ok(esme.session);
const incoming: PduObject[] = [];
const closes: true[] = [];
const sessionErrors: Error[] = [];
- session.on('incomingPduObj', pduObj => { incoming.push(pduObj); });
- session.on('close', () => { closes.push(true); });
- session.on('sessionError', sessionError => { sessionErrors.push(sessionError); });
+ esme.session.on('incomingPduObj', pduObj => { incoming.push(pduObj); });
+ esme.on('close', () => { closes.push(true); });
+ esme.on('sessionError', sessionError => { sessionErrors.push(sessionError); });
// enquireLinkTimerSecs is 30 in Jasmin's default [smpp-server] config.
const theirs = await waitFor(() => incoming.find(pduObj => pduObj.cmdName === 'enquire_link'), 35_000);
assert.ok(theirs, 'expected Jasmin to send its own enquire_link within 35s');
- const ours = await session.send({ cmdName: 'enquire_link' });
+ const ours = await esme.send({ cmdName: 'enquire_link' });
assert.equal(ours.err, undefined);
assert.ok(ours.pduObj);
assert.equal(ours.pduObj.cmdStatus, 'ESME_ROK');
- const unbound = await session.unbind();
+ const unbound = await esme.unbind();
assert.equal(unbound.err, undefined);
assert.ok(await waitFor(() => (closes.length > 0 ? true : undefined), 5000), 'expected a clean close after unbind');
@@ -290,21 +297,23 @@ describe('C1 - bind, enquire_link, unbind', () => {
});
test('binds transmitter', async t => {
- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'transmitter' });
+ const { err, client: esme } = await bind(USERNAME, PASSWORD, { bindType: 'transmitter' });
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
- assert.equal(session.boundAs, 'transmitter');
+ assert.ok(esme);
+ closeAfter(t, esme);
+ assert.ok(esme.session);
+ assert.equal(esme.session.boundAs, 'transmitter');
});
test('binds receiver', async t => {
- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
+ const { err, client: esme } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
- assert.equal(session.boundAs, 'receiver');
+ assert.ok(esme);
+ closeAfter(t, esme);
+ assert.ok(esme.session);
+ assert.equal(esme.session.boundAs, 'receiver');
});
});
@@ -312,16 +321,16 @@ describe('C13 - maxOutstanding 1 with 10 parallel sends', () => {
test('every send is answered, none lost, order preserved at the fake upstream', async t => {
await waitForUpstreamSession('main');
- const { err, session } = await bind(USERNAME, PASSWORD, { maxOutstanding: 1 });
+ const { err, client: esme } = await bind(USERNAME, PASSWORD, { maxOutstanding: 1 });
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
const before = upstreamSms.length;
const texts = Array.from({ length: 10 }, (_, i) => `c13-order-${String(i).padStart(2, '0')}`);
- const results = await Promise.all(texts.map(async message => session.sendSms({ from: FROM, message, to: TO })));
+ const results = await Promise.all(texts.map(async message => esme.sendSms({ from: FROM, message, to: TO })));
const ids: string[] = [];
@@ -380,7 +389,7 @@ describe('S7 - Jasmin as the ESME against our server (HTTP send API, DLR callbac
assert.ok(arrived, 'expected the HTTP-submitted message to arrive as submit_sm at our server()');
// Our server() already answered (sendResp) and sent the DLR (sendDlr('DELIVERED')) from the
- // shared session handler above - Jasmin's own DLR pipeline should throw the HTTP callback.
+ // shared onSms above - Jasmin's own DLR pipeline should throw the HTTP callback.
const msgidMatch = /Success "([^"]+)"/i.exec(sent.body);
const msgid = msgidMatch?.[1];
@@ -397,14 +406,11 @@ describe('S7 - Jasmin as the ESME against our server (HTTP send API, DLR callbac
test('a long GSM message from our server reassembles at Jasmin (or is recorded as fragments)', async () => {
const upstreamSession = await waitForUpstreamSession('main');
- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
-
- assert.equal(err, undefined);
- assert.ok(session);
-
const sms: Sms[] = [];
+ const { err, client: esme } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } });
- session.on('sms', s => { sms.push(s); });
+ assert.equal(err, undefined);
+ assert.ok(esme);
const text = `s7-long-${'p'.repeat(300)}`;
@@ -412,20 +418,17 @@ describe('S7 - Jasmin as the ESME against our server (HTTP send API, DLR callbac
const whole = await waitFor(() => sms.find(s => s.message === text), 10_000);
- await session.close({ signal: AbortSignal.abort() });
+ await esme.close({ signal: AbortSignal.abort() });
assert.ok(whole ?? sms.length > 0, 'expected the long message to arrive whole or as recorded fragments');
});
test('a UCS-2 message with 一 and an emoji from our server (or is recorded as fragments)', async () => {
const upstreamSession = await waitForUpstreamSession('main');
- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
-
- assert.equal(err, undefined);
- assert.ok(session);
-
const sms: Sms[] = [];
+ const { err, client: esme } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } });
- session.on('sms', s => { sms.push(s); });
+ assert.equal(err, undefined);
+ assert.ok(esme);
const text = `一😀${'q'.repeat(60)}`;
@@ -433,7 +436,7 @@ describe('S7 - Jasmin as the ESME against our server (HTTP send API, DLR callbac
const whole = await waitFor(() => sms.find(s => s.message === text), 10_000);
- await session.close({ signal: AbortSignal.abort() });
+ await esme.close({ signal: AbortSignal.abort() });
assert.ok(whole ?? sms.length > 0, 'expected the UCS-2 message to arrive whole or as recorded fragments');
});
});
@@ -452,19 +455,19 @@ describe('C3+C7 - long MT through the fake upstream, receipts and id consistency
test(testCase.label, async t => {
await waitForUpstreamSession('main');
- const { err, session } = await bind(USERNAME, PASSWORD);
+ const { err, client: esme } = await bind(USERNAME, PASSWORD);
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
const dlrs: { dlr: Dlr; pduObj: PduObject }[] = [];
const messageDlrs: unknown[] = [];
- session.on('dlr', (dlr, pduObj) => { dlrs.push({ dlr, pduObj }); });
- session.on('messageDlr', merged => { messageDlrs.push(merged); });
+ esme.on('dlr', (dlr, pduObj) => { dlrs.push({ dlr, pduObj }); });
+ esme.on('messageDlr', merged => { messageDlrs.push(merged); });
- const sent = await session.sendSms({
+ const sent = await esme.sendSms({
dlr: true,
from: FROM,
message: testCase.message,
@@ -502,14 +505,11 @@ describe('C3+C7 - long MT through the fake upstream, receipts and id consistency
describe('C8 (target 3) - long MO from an upstream SMSC, SAR vs UDH segmentation', () => {
test('SAR-segmented deliver_sm from the fake upstream', async () => {
const upstreamSession = await waitForUpstreamSession('main');
- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
-
- assert.equal(err, undefined);
- assert.ok(session);
-
const sms: Sms[] = [];
+ const { err, client: esme } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } });
- session.on('sms', s => { sms.push(s); });
+ assert.equal(err, undefined);
+ assert.ok(esme);
const text = `sar-mo-${'m'.repeat(300)}`;
@@ -518,7 +518,7 @@ describe('C8 (target 3) - long MO from an upstream SMSC, SAR vs UDH segmentation
const whole = await waitFor(() => sms.find(s => s.message === text), 10_000);
const fragments = sms.filter(s => s.message !== text && text.includes(s.message) && s.message !== '');
- await session.close({ signal: AbortSignal.abort() });
+ await esme.close({ signal: AbortSignal.abort() });
assert.ok(whole, 'expected the SAR segments to reassemble into one whole sms');
assert.deepEqual(fragments.map(s => s.message), [], 'no segment reaches the application on its own');
@@ -526,14 +526,11 @@ describe('C8 (target 3) - long MO from an upstream SMSC, SAR vs UDH segmentation
test('UDH-segmented deliver_sm from the fake upstream', async () => {
const upstreamSession = await waitForUpstreamSession('main');
- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
-
- assert.equal(err, undefined);
- assert.ok(session);
-
const sms: Sms[] = [];
+ const { err, client: esme } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } });
- session.on('sms', s => { sms.push(s); });
+ assert.equal(err, undefined);
+ assert.ok(esme);
const text = `udh-mo-${'n'.repeat(300)}`;
@@ -542,7 +539,7 @@ describe('C8 (target 3) - long MO from an upstream SMSC, SAR vs UDH segmentation
const whole = await waitFor(() => sms.find(s => s.message === text), 10_000);
const fragments = sms.filter(s => text.includes(s.message) && s.message !== '');
- await session.close({ signal: AbortSignal.abort() });
+ await esme.close({ signal: AbortSignal.abort() });
assert.ok(whole ?? fragments.length > 0, 'expected either a reassembled sms or UDH fragments to arrive');
});
@@ -551,14 +548,11 @@ describe('C8 (target 3) - long MO from an upstream SMSC, SAR vs UDH segmentation
describe('C8 (target 2) - message_payload with sm_length 0', () => {
test('a deliver_sm carrying message_payload instead of short_message', async () => {
const upstreamSession = await waitForUpstreamSession('main');
- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
-
- assert.equal(err, undefined);
- assert.ok(session);
-
const sms: Sms[] = [];
+ const { err, client: esme } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } });
- session.on('sms', s => { sms.push(s); });
+ assert.equal(err, undefined);
+ assert.ok(esme);
const text = 'message-payload only, sm_length 0';
const pushed = await sendMessagePayloadMo(upstreamSession, { from: TO, message: text, to: FROM });
@@ -567,7 +561,7 @@ describe('C8 (target 2) - message_payload with sm_length 0', () => {
const arrived = await waitFor(() => sms.find(s => s.message === text), 5000);
- await session.close({ signal: AbortSignal.abort() });
+ await esme.close({ signal: AbortSignal.abort() });
// Jasmin relays message_payload faithfully (sm_length 0, the real text in the TLV), so the
// whole body has to reach the application from there.
@@ -581,19 +575,20 @@ describe('C9 (target 4) - DLR as data_sm against the jasmin-datasm instance', ()
test('a receipt thrown as data_sm reaches the dlr event', async t => {
await waitForUpstreamSession('datasm');
- const { err, session } = await client({ host: DATASM_HOST, password: PASSWORD, port: PEER_PORT, username: USERNAME });
+ const { err, client: esme } = await client({ host: DATASM_HOST, password: PASSWORD, port: PEER_PORT, username: USERNAME });
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
+ assert.ok(esme.session);
const dlrs: Dlr[] = [];
const incomingDataSm: PduObject[] = [];
- session.on('dlr', dlr => { dlrs.push(dlr); });
- session.on('incomingPduObj', pduObj => { if (pduObj.cmdName === 'data_sm') incomingDataSm.push(pduObj); });
+ esme.on('dlr', dlr => { dlrs.push(dlr); });
+ esme.session.on('incomingPduObj', pduObj => { if (pduObj.cmdName === 'data_sm') incomingDataSm.push(pduObj); });
- const sent = await session.sendSms({ dlr: true, from: FROM, message: 'data_sm dlr test', to: TO });
+ const sent = await esme.sendSms({ dlr: true, from: FROM, message: 'data_sm dlr test', to: TO });
assert.equal(sent.err, undefined);
@@ -623,10 +618,10 @@ describe('C11 - bind refusal and reconnect backoff', () => {
warn: () => undefined,
};
- const { err, session } = await bind(USERNAME, 'wrong-password', { log, reconnect: { maxDelay: 4000, minDelay: 1000 } });
+ const { err, client: esme } = await bind(USERNAME, 'wrong-password', { log, reconnect: { maxDelay: 4000, minDelay: 1000 } });
assert.ok(err);
- assert.equal(session, undefined);
+ assert.equal(esme, undefined);
await delay(2000);
assert.equal(refusals.length, 1, 'expected exactly one bind attempt, never a retry');
assert.equal(refusals[0]?.cmdStatus, 'ESME_RINVPASWD');
@@ -640,17 +635,18 @@ describe('C11 - bind refusal and reconnect backoff', () => {
reconnect: { maxDelay: 4000, minDelay: 1000 },
username: USERNAME,
};
- const { err, session } = await client(options);
+ const { err, client: esme } = await client(options);
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
+ assert.ok(esme.session);
const disconnectedAt: number[] = [];
- session.on('disconnected', () => { disconnectedAt.push(Date.now()); });
+ esme.on('disconnected', () => { disconnectedAt.push(Date.now()); });
options.password = 'wrong-after-drop';
- session.sock.destroy();
+ esme.session.sock.destroy();
await delay(12_000);
@@ -670,14 +666,14 @@ describe('C12 - throttling (esme2\'s smpps_throughput quota)', () => {
test('flooding submits past the quota gets an err naming the status; the session stays bound; a later send works', async t => {
await waitForUpstreamSession('main');
- const { err, session } = await bind(THROTTLED_USERNAME, THROTTLED_PASSWORD, { maxOutstanding: 20 });
+ const { err, client: esme } = await bind(THROTTLED_USERNAME, THROTTLED_PASSWORD, { maxOutstanding: 20 });
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
const results = await Promise.all(
- Array.from({ length: 15 }, async (_unused, index) => session.sendSms({ from: FROM, message: `throttle-${String(index)}`, to: TO })),
+ Array.from({ length: 15 }, async (_unused, index) => esme.sendSms({ from: FROM, message: `throttle-${String(index)}`, to: TO })),
);
const refused = results.filter(r => r.err !== undefined);
@@ -685,7 +681,7 @@ describe('C12 - throttling (esme2\'s smpps_throughput quota)', () => {
assert.ok(refused.length > 0, 'expected the 0.1/s quota to refuse at least one of 15 parallel sends');
assert.match(refused[0]?.err?.message ?? '', /ESME_RTHROTTLED/);
- const keepalive = await session.send({ cmdName: 'enquire_link' });
+ const keepalive = await esme.send({ cmdName: 'enquire_link' });
assert.equal(keepalive.err, undefined);
assert.ok(keepalive.pduObj);
@@ -694,10 +690,10 @@ describe('C12 - throttling (esme2\'s smpps_throughput quota)', () => {
// 0.1/s is one slot every 10s, so a single fixed delay is either wasteful or flaky - polling
// finds the next open slot instead of guessing it.
const deadline = Date.now() + 25_000;
- let later: Awaited<ReturnType<typeof session.sendSms>> | undefined;
+ let later: Awaited<ReturnType<typeof esme.sendSms>> | undefined;
while (!later && Date.now() < deadline) {
- const attempt = await session.sendSms({ from: FROM, message: 'after the burst', to: TO });
+ const attempt = await esme.sendSms({ from: FROM, message: 'after the burst', to: TO });
if (!attempt.err) later = attempt;
else await delay(500);
diff --git a/interop-tests/jsmpp.test.ts b/interop-tests/jsmpp.test.ts
index db96513..4889b87 100644
--- a/interop-tests/jsmpp.test.ts
+++ b/interop-tests/jsmpp.test.ts
@@ -39,13 +39,25 @@ async function driver(path: string, params: Record<string, string> = {}): Promis
const allSms: { session: Session; sms: Sms }[] = [];
const allSessionErrors: { err: Error; session: Session }[] = [];
const bindPdus: Record<string, unknown>[] = [];
-/** Messages a test answers itself (a refusing status, or asserting on the response) - populate
- * before triggering the submit that will carry this exact text, so the global auto-ack never runs. */
-const manualTexts = new Set<string>();
+/** Messages a test answers itself (a refusing status, or asserting on the response): the handler
+ * holds each until released, since returning answers it - register before triggering the submit. */
+const manualTexts = new Map<string, Promise<void>>();
+
+function answerManually(text: string): () => void {
+ let release = (): void => undefined;
+
+ manualTexts.set(text, new Promise(resolve => { release = resolve; }));
+
+ return () => { release(); };
+}
const { err: serverErr, server: smpp } = await server({
authenticate: () => true,
idleTimeout: 40_000,
+ onSms: async sms => {
+ allSms.push({ session: sms.session, sms });
+ await manualTexts.get(sms.message);
+ },
port: SMPP_PORT,
});
@@ -58,10 +70,6 @@ smppServer.on('session', session => {
session.on('incomingPduObj', pduObj => {
if (pduObj.cmdName.startsWith('bind_')) bindPdus.push(pduObj.params);
});
- session.on('sms', sms => {
- allSms.push({ session, sms });
- if (!manualTexts.has(sms.message)) void sms.sendResp();
- });
session.on('sessionError', err => { allSessionErrors.push({ err, session }); });
});
@@ -263,7 +271,7 @@ describe('S3 - known-but-unhandled and malformed commands (targets 1, 6)', () =>
// Not reachable through jsmpp's own typed API at all (it cannot construct wire garbage), so this
// is a raw fixture opened directly against our server().
- test('a deliver_sm ending in a bare TLV header gets ESME_RINVTLVSTREAM, and reaches no listener', async t => {
+ test('a deliver_sm ending in a bare TLV header gets ESME_RINVTLVSTREAM, and reaches no handler', async t => {
await waitForSessions(1);
const sock = net.connect(SMPP_PORT, '127.0.0.1');
@@ -311,13 +319,13 @@ describe('a refusing status is surfaced back to jsmpp', () => {
await waitForSessions(1);
const text = 'refuse-me';
-
- manualTexts.add(text);
+ const release = answerManually(text);
const submitted = driver('/submit', { encoding: 'gsm7', from: '1001', mode: 'plain', session: 'v34', text, to: '2001' });
const sms = await waitForSms(text);
await sms.sendResp({ status: 'ESME_RMSGQFUL' });
+ release();
const result = await submitted;
diff --git a/interop-tests/kannel.test.ts b/interop-tests/kannel.test.ts
index 0d911d0..c9c36d7 100644
--- a/interop-tests/kannel.test.ts
+++ b/interop-tests/kannel.test.ts
@@ -141,9 +141,30 @@ function variantFromSystemId(systemId: string): Variant | undefined {
return undefined;
}
+const variants: readonly Variant[] = ['iv33', 'main', 'maxp1', 'notrx'];
+
+/** The variant authenticate() recorded on the session, once it has run. */
+function variantOf(session: Session): Variant | undefined {
+ const { userData } = session;
+
+ if (typeof userData !== 'object' || userData === null || !('variant' in userData)) return undefined;
+
+ return variants.find(variant => variant === userData.variant);
+}
+
const allSms: { sms: Sms; variant: Variant }[] = [];
const allDlrs: { dlr: Dlr; variant: Variant }[] = [];
const bindPdus: { params: Record<string, unknown>; variant: Variant }[] = [];
+/** Messages a test answers itself: the handler holds each until released, since returning answers it. */
+const manualTexts = new Map<string, Promise<void>>();
+
+function answerManually(text: string): () => void {
+ let release = (): void => undefined;
+
+ manualTexts.set(text, new Promise(resolve => { release = resolve; }));
+
+ return () => { release(); };
+}
const { err: serverErr, server: smpp } = await server({
authenticate: ({ password, systemId }) => {
@@ -154,6 +175,13 @@ const { err: serverErr, server: smpp } = await server({
return variant ? { userData: { variant } } : false;
},
idleTimeout: 40_000,
+ onSms: async sms => {
+ const variant = variantOf(sms.session);
+
+ if (variant) allSms.push({ sms, variant });
+
+ await manualTexts.get(sms.message);
+ },
port: SMPP_PORT,
});
@@ -171,14 +199,8 @@ smppServer.on('session', session => {
if (variant) bindPdus.push({ params: pduObj.params, variant });
});
- session.on('sms', sms => {
- const variant = (session.userData as { variant?: Variant } | undefined)?.variant;
-
- if (variant) allSms.push({ sms, variant });
- });
-
session.on('dlr', dlr => {
- const variant = (session.userData as { variant?: Variant } | undefined)?.variant;
+ const variant = variantOf(session);
if (variant) allDlrs.push({ dlr, variant });
});
@@ -190,7 +212,7 @@ after(async () => {
});
function sessionsFor(variant: Variant): Session[] {
- return [...smppServer.sessions].filter(s => (s.userData as { variant?: Variant } | undefined)?.variant === variant);
+ return [...smppServer.sessions].filter(s => variantOf(s) === variant);
}
async function waitForSessions(variant: Variant, count: number, budget = 15_000): Promise<Session[]> {
@@ -308,8 +330,6 @@ describe('S1 - MT from Kannel with delivery reports', () => {
assert.equal(sms.to, '46709771337');
assert.equal(sms.dlr, true);
- assert.equal((await sms.sendResp()).err, undefined);
-
// dlr-mask bit 8: Kannel fires this off the submit_sm_resp alone, before any receipt.
const submitAck = await waitForDlrCallback(sms.smsId, '8');
@@ -339,7 +359,6 @@ describe('long MT from Kannel', () => {
const sms = await waitForSms('main', text, 15_000);
assert.equal(sms.message, text);
- assert.equal((await sms.sendResp()).err, undefined);
});
test('UCS-2 text with 一 and an emoji reassembles whole', async () => {
@@ -351,7 +370,6 @@ describe('long MT from Kannel', () => {
const sms = await waitForSms('main', text, 15_000);
assert.equal(sms.message, text);
- assert.equal((await sms.sendResp()).err, undefined);
});
});
@@ -365,7 +383,6 @@ describe('S11 - GSM extension characters', () => {
const sms = await waitForSms('main', text);
assert.equal(sms.message, text);
- assert.equal((await sms.sendResp()).err, undefined);
});
});
@@ -462,13 +479,15 @@ describe('S6 - wait-ack expiry and keepalive', () => {
const text = 's6-slow-resp';
const before = allSms.filter(e => e.variant === 'main').length;
+ const release = answerManually(text);
await sendsms(MAIN_SMSBOX, { from: '46701113311', text, to: '46709771337' });
const sms = await waitForSms('main', text);
await delay(7000);
- await sms.sendResp().catch(() => undefined);
+ await sms.sendResp();
+ release();
// wait-ack-expire defaults to 0x00 (disconnect/reconnect); reconnect-delay is 1s, so give it
// room to rebind and possibly resend the same submit_sm on the new session.
@@ -504,7 +523,6 @@ describe('iv33 variant - interface_version 0x33', () => {
const sms = await waitForSms('iv33', text);
- assert.equal((await sms.sendResp()).err, undefined);
await waitForDlrCallback(sms.smsId, '8');
await sms.sendDlr('DELIVERED');
await waitForDlrCallback(sms.smsId, '1');
@@ -518,9 +536,7 @@ describe('maxp1 variant - max-pending-submits 1', () => {
assert.ok(session);
// max-pending-submits=1 means bearerbox holds the link to one outstanding submit_sm at a
- // time - answer each as it lands, or the whole burst stalls behind the first message.
- session.on('sms', sms => { void sms.sendResp(); });
-
+ // time - onSms answers each as it lands, or the whole burst stalls behind the first message.
const texts = Array.from({ length: 20 }, (_, i) => `burst-${String(i).padStart(2, '0')}`);
// Sequential, not Promise.all: concurrent fetch()es reach smsbox's HTTP listener in whatever
@@ -565,7 +581,6 @@ describe('notrx variant - separate TX and RX binds', () => {
const sms = await waitForSms('notrx', text);
assert.equal(sms.session, tx);
- assert.equal((await sms.sendResp()).err, undefined);
});
test('a receipt built on the receiver bind reaches Kannel; the transmitter bind cannot carry one', async () => {
@@ -598,7 +613,6 @@ describe('notrx variant - separate TX and RX binds', () => {
const sms = await waitForSms('notrx', text);
- assert.equal((await sms.sendResp()).err, undefined);
await waitForDlrCallback(sms.smsId, '8');
const receiptDate = '2609051200';
diff --git a/interop-tests/php.test.ts b/interop-tests/php.test.ts
index 0415ce9..ac1e6c6 100644
--- a/interop-tests/php.test.ts
+++ b/interop-tests/php.test.ts
@@ -48,6 +48,15 @@ const { err: serverErr, server: smpp } = await server({
return true;
},
+ // Returning at once answers the message: php-smpp's submit_sm() blocks synchronously reading
+ // the response on the same connection that sent it, so holding the message for a test to
+ // answer would never let that read complete - unlike python-smpplib's driver, this one has no
+ // separate reader thread to poll afterwards.
+ onSms: sms => {
+ const systemId = systemIdBySession.get(sms.session);
+
+ if (systemId) allSms.push({ sms, systemId });
+ },
port: SMPP_PORT,
});
@@ -62,18 +71,6 @@ smppServer.on('session', session => {
systemIdBySession.set(session, paramText(pduObj.params.system_id));
});
-
- session.on('sms', sms => {
- const systemId = systemIdBySession.get(session);
-
- if (systemId) allSms.push({ sms, systemId });
-
- // php-smpp's submit_sm() blocks synchronously reading the response on the same connection
- // that sent it, so answering here (rather than after this event's own test observes the
- // sms) is the only way that read ever completes - unlike python-smpplib's driver, this one
- // has no separate reader thread to poll afterwards.
- void sms.sendResp();
- });
});
after(async () => {
diff --git a/interop-tests/python.test.ts b/interop-tests/python.test.ts
index 84f1c66..3fb0451 100644
--- a/interop-tests/python.test.ts
+++ b/interop-tests/python.test.ts
@@ -68,6 +68,11 @@ const { err: serverErr, server: smpp } = await server({
return true;
},
+ onSms: sms => {
+ const systemId = systemIdBySession.get(sms.session);
+
+ if (systemId) allSms.push({ sms, systemId });
+ },
port: SMPP_PORT,
});
@@ -82,12 +87,6 @@ smppServer.on('session', session => {
systemIdBySession.set(session, paramText(pduObj.params.system_id));
});
-
- session.on('sms', sms => {
- const systemId = systemIdBySession.get(session);
-
- if (systemId) allSms.push({ sms, systemId });
- });
});
after(async () => {
@@ -134,7 +133,6 @@ async function waitForReceived(name: string, predicate: (e: ReceivedEntry) => bo
type AckResult = { messageId?: string; status?: number };
-// A single-segment submit_sm is only answered once sendResp() is called on the arrived sms, so
// /submit itself does not wait for the ack (see driver.py) - this polls for it afterwards.
async function waitForAck(name: string, sequence: number, budget = 8000): Promise<AckResult> {
const found = await waitFor(async () => {
@@ -181,9 +179,7 @@ describe('S11 - encodings (python-smpplib)', () => {
assert.equal(sent.ok, true, JSON.stringify(sent));
- const sms = await waitForSms('s11-basic', expectBasic + extensionChars);
-
- assert.equal((await sms.sendResp()).err, undefined);
+ await waitForSms('s11-basic', expectBasic + extensionChars);
assert.equal((await waitForAck('s11-basic', sent.sequence as number)).status, 0);
});
@@ -194,9 +190,7 @@ describe('S11 - encodings (python-smpplib)', () => {
assert.equal(sent.ok, true, JSON.stringify(sent));
- const sms = await waitForSms('s11-ff', 'before-\f');
-
- assert.equal((await sms.sendResp()).err, undefined);
+ await waitForSms('s11-ff', 'before-\f');
assert.equal((await waitForAck('s11-ff', sent.sequence as number)).status, 0);
});
@@ -208,9 +202,7 @@ describe('S11 - encodings (python-smpplib)', () => {
assert.equal(sent.ok, true, JSON.stringify(sent));
- const sms = await waitForSms('s11-latin1', text);
-
- assert.equal((await sms.sendResp()).err, undefined);
+ await waitForSms('s11-latin1', text);
assert.equal((await waitForAck('s11-latin1', sent.sequence as number)).status, 0);
});
@@ -222,9 +214,7 @@ describe('S11 - encodings (python-smpplib)', () => {
assert.equal(sent.ok, true, JSON.stringify(sent));
- const sms = await waitForSms('s11-ucs2', text);
-
- assert.equal((await sms.sendResp()).err, undefined);
+ await waitForSms('s11-ucs2', text);
assert.equal((await waitForAck('s11-ucs2', sent.sequence as number)).status, 0);
});
@@ -238,7 +228,6 @@ describe('S11 - encodings (python-smpplib)', () => {
const sms = await waitForSms('s11-echo-gsm', 'seed');
- assert.equal((await sms.sendResp()).err, undefined);
assert.equal((await waitForAck('s11-echo-gsm', sent.sequence as number)).status, 0);
const session = sessionFor('s11-echo-gsm');
@@ -261,7 +250,6 @@ describe('S11 - encodings (python-smpplib)', () => {
const sms = await waitForSms('s11-echo-ucs2', seed);
- assert.equal((await sms.sendResp()).err, undefined);
assert.equal((await waitForAck('s11-echo-ucs2', sent.sequence as number)).status, 0);
const session = sessionFor('s11-echo-ucs2');
@@ -288,7 +276,6 @@ describe('S11 - encodings (python-smpplib)', () => {
const sms = await waitForSms('s11-quirk', '§');
- assert.equal((await sms.sendResp()).err, undefined);
assert.equal((await waitForAck('s11-quirk', sent.sequence as number)).status, 0);
// Sent back the spec-correct way (byte 0x5F again), python's own (non-standard) table reads
@@ -382,8 +369,8 @@ describe('Refusals via onRequest', () => {
await bindReader(name);
- // Refused through onRequest, before reassembly and before the sms event, so this is answered
- // without any sendResp() call - unlike every other submit in this file.
+ // Refused through onRequest, before reassembly and before onSms - unlike every other submit
+ // in this file.
const refused = await post('/submit', { dataCoding: 0, from: '46700000001', name, text: 'nope', to: REFUSED_DEST });
assert.equal(refused.ok, true, JSON.stringify(refused));
@@ -397,9 +384,7 @@ describe('Refusals via onRequest', () => {
assert.equal(after1.ok, true, JSON.stringify(after1));
- const sms = await waitForSms(name, 'still works');
-
- assert.equal((await sms.sendResp()).err, undefined);
+ await waitForSms(name, 'still works');
assert.equal((await waitForAck(name, after1.sequence as number)).status, 0);
});
});
diff --git a/interop-tests/smppsim.test.ts b/interop-tests/smppsim.test.ts
index 8a08817..31425d1 100644
--- a/interop-tests/smppsim.test.ts
+++ b/interop-tests/smppsim.test.ts
@@ -4,7 +4,7 @@ import type { Dlr } from '../src/dlr.ts';
import type { EncodingName } from '../src/defs/encodings.ts';
import type { MessageDlr } from '../src/dlr-merger.ts';
import type { PduObject } from '../src/pdu.ts';
-import type { Session } from '../src/session.ts';
+import type { SmppClient } from '../src/client.ts';
import type { Sms } from '../src/sms.ts';
import { client } from '../src/client.ts';
import { closeAfter } from '../test/teardown.ts';
@@ -64,22 +64,14 @@ function gsmFiller(targetSeptets: number): string {
/** Delivery-receipt fields the wire actually carried, alongside the parsed Dlr. */
type Received = { dlr: Dlr; pduObj: PduObject };
-function collectDlrs(session: Session): Received[] {
+function collectDlrs(esme: SmppClient): Received[] {
const received: Received[] = [];
- session.on('dlr', (dlr, pduObj) => { received.push({ dlr, pduObj }); });
+ esme.on('dlr', (dlr, pduObj) => { received.push({ dlr, pduObj }); });
return received;
}
-function collectSms(session: Session): Sms[] {
- const collected: Sms[] = [];
-
- session.on('sms', sms => { collected.push(sms); });
-
- return collected;
-}
-
const DLR_RETRY_BUDGET_MS = 3000;
const DLR_MAX_ATTEMPTS = 10;
@@ -112,13 +104,13 @@ function namedIds(smsIds: (string | undefined)[]): string[] {
* fails here if it keeps missing well past what that alone explains.
*/
async function sendUntilAllDlrsArrive(
- session: Session,
+ esme: SmppClient,
dlrs: Received[],
message: string,
encoding?: EncodingName,
): Promise<string[]> {
for (let attempt = 0; attempt < DLR_MAX_ATTEMPTS; attempt++) {
- const sent = await session.sendSms({
+ const sent = await esme.sendSms({
dlr: true,
from: FROM,
message,
@@ -143,14 +135,14 @@ async function sendUntilAllDlrsArrive(
/** As above, but also waits for the loopback deliver_sm(s) to reassemble into the original text. */
async function sendUntilComplete(
- session: Session,
+ esme: SmppClient,
dlrs: Received[],
sms: Sms[],
message: string,
encoding?: EncodingName,
): Promise<{ reassembled: Sms; smsIds: string[] }> {
for (let attempt = 0; attempt < DLR_MAX_ATTEMPTS; attempt++) {
- const sent = await session.sendSms({
+ const sent = await esme.sendSms({
dlr: true,
from: FROM,
message,
@@ -202,17 +194,17 @@ describe('smppsim - C3+C7 long MT, receipts and loopback reassembly', () => {
for (const testCase of cases) {
test(testCase.label, async t => {
- const { err, session } = await bind(PEER_HOST);
+ const sms: Sms[] = [];
+ const { err, client: esme } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } });
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
- const dlrs = collectDlrs(session);
- const sms = collectSms(session);
+ const dlrs = collectDlrs(esme);
- const { reassembled, smsIds } = await sendUntilComplete(
- session,
+ const { smsIds } = await sendUntilComplete(
+ esme,
dlrs,
sms,
testCase.message,
@@ -236,22 +228,21 @@ describe('smppsim - C3+C7 long MT, receipts and loopback reassembly', () => {
assert.equal(received.dlr.receipt.err, '000');
}
- assert.equal((await reassembled.sendResp()).err, undefined);
});
}
});
describe('smppsim-textdlr - C2 text-only receipts', () => {
test('no TLVs; dlr.smsId parsed from the body matches the submit_sm_resp id', async t => {
- const { err, session } = await bind(TEXTDLR_HOST);
+ const { err, client: esme } = await bind(TEXTDLR_HOST);
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
- const dlrs = collectDlrs(session);
+ const dlrs = collectDlrs(esme);
- const sent = await session.sendSms({ dlr: true, from: FROM, message: 'text-only dlr', to: TO });
+ const sent = await esme.sendSms({ dlr: true, from: FROM, message: 'text-only dlr', to: TO });
assert.equal(sent.err, undefined);
assert.equal(sent.smsIds.length, 1);
@@ -272,21 +263,21 @@ describe('smppsim-textdlr - C2 text-only receipts', () => {
describe('smppsim-transition - C4 intermediate then final', () => {
test('an intermediate report arrives, but registered_delivery 0x11 never gets a final one', async t => {
- const { err, session } = await bind(TRANSITION_HOST);
+ const { err, client: esme } = await bind(TRANSITION_HOST);
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
- const dlrs = collectDlrs(session);
+ const dlrs = collectDlrs(esme);
const messageDlrs: unknown[] = [];
- session.on('messageDlr', merged => { messageDlrs.push(merged); });
+ esme.on('messageDlr', merged => { messageDlrs.push(merged); });
// registered_delivery 0x11: final (0x01) + intermediate (0x10). sendSms() only ever
// requests 0x01, so this scenario needs the raw passthrough - which also means
// dlrMerger.expect() is never called, so messageDlr cannot fire here (see findings).
- const sent = await session.send({
+ const sent = await esme.send({
cmdName: 'submit_sm',
params: {
data_coding: 0,
@@ -331,14 +322,14 @@ describe('smppsim single-state variants - C5 failure states', () => {
for (const variant of variants) {
test(`${variant.label} maps to ${variant.expected}`, async t => {
- const { err, session } = await bind(variant.host);
+ const { err, client: esme } = await bind(variant.host);
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
- const dlrs = collectDlrs(session);
- const sent = await session.sendSms({ dlr: true, from: FROM, message: 'failure state test', to: TO });
+ const dlrs = collectDlrs(esme);
+ const sent = await esme.sendSms({ dlr: true, from: FROM, message: 'failure state test', to: TO });
assert.equal(sent.err, undefined);
@@ -354,18 +345,18 @@ describe('smppsim single-state variants - C5 failure states', () => {
}
test('a 2-segment message: both segments report the same status; messageDlr never fires', async t => {
- const { err, session } = await bind(UNDELIV_HOST);
+ const { err, client: esme } = await bind(UNDELIV_HOST);
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
- const dlrs = collectDlrs(session);
+ const dlrs = collectDlrs(esme);
const messageDlrs: MessageDlr[] = [];
- session.on('messageDlr', merged => { messageDlrs.push(merged); });
+ esme.on('messageDlr', merged => { messageDlrs.push(merged); });
- const smsIds = await sendUntilAllDlrsArrive(session, dlrs, gsmFiller(200));
+ const smsIds = await sendUntilAllDlrsArrive(esme, dlrs, gsmFiller(200));
assert.equal(smsIds.length, 2);
@@ -387,21 +378,21 @@ describe('smppsim single-state variants - C5 failure states', () => {
describe('smppsim-delayed - C6 receipt delayed past a link drop', () => {
test('the merge survives a reconnect; the late receipt still reaches dlr', async t => {
- const { err, session } = await bind(DELAYED_HOST, { reconnect: { maxDelay: 1000, minDelay: 200 } });
+ const { err, client: esme } = await bind(DELAYED_HOST, { reconnect: { maxDelay: 1000, minDelay: 200 } });
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
- const dlrs = collectDlrs(session);
+ const dlrs = collectDlrs(esme);
const disconnected: true[] = [];
const reconnected: true[] = [];
- session.on('disconnected', () => { disconnected.push(true); });
- session.on('reconnected', () => { reconnected.push(true); });
+ esme.on('disconnected', () => { disconnected.push(true); });
+ esme.on('reconnected', () => { reconnected.push(true); });
const sendStart = Date.now();
- const sent = await session.sendSms({ dlr: true, from: FROM, message: 'delayed dlr test', to: TO });
+ const sent = await esme.sendSms({ dlr: true, from: FROM, message: 'delayed dlr test', to: TO });
assert.equal(sent.err, undefined);
@@ -410,7 +401,8 @@ describe('smppsim-delayed - C6 receipt delayed past a link drop', () => {
assert.ok(smsId);
await delay(300);
- session.sock.destroy();
+ assert.ok(esme.session);
+ esme.session.sock.destroy();
assert.ok(await waitFor(() => (disconnected.length > 0 ? true : undefined), 2000), 'expected disconnected');
assert.ok(await waitFor(() => (reconnected.length > 0 ? true : undefined), 4000), 'expected reconnected');
@@ -439,20 +431,20 @@ describe('smppsim - C11 bind refusal and reconnect backoff', () => {
warn: () => undefined,
};
- const { err, session } = await bind(PEER_HOST, { log, password: 'wrong', reconnect: { maxDelay: 4000, minDelay: 1000 } });
+ const { err, client: esme } = await bind(PEER_HOST, { log, password: 'wrong', reconnect: { maxDelay: 4000, minDelay: 1000 } });
assert.ok(err);
- assert.equal(session, undefined);
+ assert.equal(esme, undefined);
await delay(2000);
assert.equal(refusals.length, 1, 'expected exactly one bind attempt, never a retry');
assert.equal(refusals[0]?.cmdStatus, 'ESME_RINVPASWD');
});
test('a closed port on the very first connect: one attempt, no retry', async () => {
- const { err, session } = await bind(PEER_HOST, { port: 46775, reconnect: { maxDelay: 4000, minDelay: 1000 } });
+ const { err, client: esme } = await bind(PEER_HOST, { port: 46775, reconnect: { maxDelay: 4000, minDelay: 1000 } });
assert.ok(err);
- assert.equal(session, undefined);
+ assert.equal(esme, undefined);
});
test('a rebind refused after a live link drops: backs off, never floods', async t => {
@@ -463,19 +455,20 @@ describe('smppsim - C11 bind refusal and reconnect backoff', () => {
reconnect: { maxDelay: 4000, minDelay: 1000 },
username: USERNAME,
};
- const { err, session } = await client(options);
+ const { err, client: esme } = await client(options);
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
const disconnectedAt: number[] = [];
- session.on('disconnected', () => { disconnectedAt.push(Date.now()); });
- // Mutates the same object reference the reconnect loop's onConnected closure reads, so
- // every rebind attempt from here on is refused - see interop-tests/findings/02-smppsim.md.
+ esme.on('disconnected', () => { disconnectedAt.push(Date.now()); });
+ // Mutates the same object reference the reconnect loop's open() reads, so every rebind
+ // attempt from here on is refused - see interop-tests/findings/02-smppsim.md.
options.password = 'wrong-after-drop';
- session.sock.destroy();
+ assert.ok(esme.session);
+ esme.session.sock.destroy();
await delay(12_000);
@@ -493,32 +486,32 @@ describe('smppsim - C11 bind refusal and reconnect backoff', () => {
describe('smppsim-queuefull - C12 ESME_RMSGQFUL', () => {
test('refuses when the queue is full; the session stays bound; a later send succeeds once it drains', async t => {
- const { err, session } = await bind(QUEUEFULL_HOST);
+ const { err, client: esme } = await bind(QUEUEFULL_HOST);
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
- const first = await session.sendSms({ from: FROM, message: 'occupies the one queue slot', to: TO });
+ const first = await esme.sendSms({ from: FROM, message: 'occupies the one queue slot', to: TO });
assert.equal(first.err, undefined);
- const second = await session.sendSms({ from: FROM, message: 'should be refused', to: TO });
+ const second = await esme.sendSms({ from: FROM, message: 'should be refused', to: TO });
assert.ok(second.err);
assert.match(second.err.message, /ESME_RMSGQFUL/);
- const keepalive = await session.send({ cmdName: 'enquire_link' });
+ const keepalive = await esme.send({ cmdName: 'enquire_link' });
assert.equal(keepalive.err, undefined);
assert.ok(keepalive.pduObj);
assert.equal(keepalive.pduObj.cmdStatus, 'ESME_ROK');
const deadline = Date.now() + 5000;
- let drained: Awaited<ReturnType<typeof session.sendSms>> | undefined;
+ let drained: Awaited<ReturnType<typeof esme.sendSms>> | undefined;
while (!drained && Date.now() < deadline) {
- const attempt = await session.sendSms({ from: FROM, message: 'after drain', to: TO });
+ const attempt = await esme.sendSms({ from: FROM, message: 'after drain', to: TO });
if (!attempt.err) drained = attempt;
else await delay(100);
@@ -530,14 +523,14 @@ describe('smppsim-queuefull - C12 ESME_RMSGQFUL', () => {
describe('smppsim - C13 maxOutstanding 1 with 10 parallel sends', () => {
test('every send is answered, in order, none lost', async t => {
- const { err, session } = await bind(PEER_HOST, { maxOutstanding: 1 });
+ const { err, client: esme } = await bind(PEER_HOST, { maxOutstanding: 1 });
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
const results = await Promise.all(
- Array.from({ length: 10 }, async (_unused, index) => session.sendSms({
+ Array.from({ length: 10 }, async (_unused, index) => esme.sendSms({
from: FROM,
message: `order test ${String(index)}`,
to: TO,
@@ -571,32 +564,33 @@ describe('smppsim - C13 maxOutstanding 1 with 10 parallel sends', () => {
describe('smppsim - C15 bind version negotiation', () => {
for (const interfaceVersion of [0x34, 0x50]) {
test(`interfaceVersion 0x${interfaceVersion.toString(16)}: SMPPSim's bind_resp never declares a version`, async t => {
- const { err, session } = await bind(PEER_HOST, { interfaceVersion });
+ const { err, client: esme } = await bind(PEER_HOST, { interfaceVersion });
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
// Confirmed from source (no BindXResp class ever sets sc_interface_version): SMPPSim
// never declares its own version, whatever we declared - so acceptsOptionalParams()
// reads it as pre-3.4, even though it happily sends us TLVs (see C3).
- assert.equal(session.peerInterfaceVersion, 0x00);
- assert.equal(session.acceptsOptionalParams(), false);
+ assert.ok(esme.session);
+ assert.equal(esme.session.peerInterfaceVersion, 0x00);
+ assert.equal(esme.session.acceptsOptionalParams(), false);
});
}
});
describe('smppsim - C17 encodings round trip over loopback', () => {
test('Latin-1 (å ä ö)', async t => {
- const { err, session } = await bind(PEER_HOST);
+ const sms: Sms[] = [];
+ const { err, client: esme } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } });
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
- const sms = collectSms(session);
- await session.sendSms({ encoding: 'LATIN1', from: FROM, message: 'å ä ö', to: TO });
+ await esme.sendSms({ encoding: 'LATIN1', from: FROM, message: 'å ä ö', to: TO });
const received = await waitFor(() => sms.find(s => s.message === 'å ä ö'));
@@ -605,15 +599,15 @@ describe('smppsim - C17 encodings round trip over loopback', () => {
});
test('UCS-2', async t => {
- const { err, session } = await bind(PEER_HOST);
+ const sms: Sms[] = [];
+ const { err, client: esme } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } });
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
- const sms = collectSms(session);
- await session.sendSms({ encoding: 'UCS2', from: FROM, message: 'ucs2 round trip', to: TO });
+ await esme.sendSms({ encoding: 'UCS2', from: FROM, message: 'ucs2 round trip', to: TO });
const received = await waitFor(() => sms.find(s => s.message === 'ucs2 round trip'));
@@ -622,15 +616,15 @@ describe('smppsim - C17 encodings round trip over loopback', () => {
});
test('flash (data_coding records the message-class group)', async t => {
- const { err, session } = await bind(PEER_HOST);
+ const sms: Sms[] = [];
+ const { err, client: esme } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } });
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
- const sms = collectSms(session);
- await session.sendSms({ flash: true, from: FROM, message: 'flash test', to: TO });
+ await esme.sendSms({ flash: true, from: FROM, message: 'flash test', to: TO });
const received = await waitFor(() => sms.find(s => s.message === 'flash test'));
@@ -640,16 +634,16 @@ describe('smppsim - C17 encodings round trip over loopback', () => {
});
test('a raw submit_sm with data_coding 0xF0 is read as flash', async t => {
- const { err, session } = await bind(PEER_HOST);
+ const sms: Sms[] = [];
+ const { err, client: esme } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } });
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
- const sms = collectSms(session);
const body = 'message class test';
- const sent = await session.send({
+ const sent = await esme.send({
cmdName: 'submit_sm',
params: {
data_coding: 0xF0,
@@ -668,13 +662,13 @@ describe('smppsim - C17 encodings round trip over loopback', () => {
});
test('a raw submit_sm with 8-bit binary and a UDH (esm_class 0x40)', async t => {
- const { err, session } = await bind(PEER_HOST);
+ const sms: Sms[] = [];
+ const { err, client: esme } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } });
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
- const sms = collectSms(session);
// A UDH carrying no recognised concatenation IE (0x00/0x08): one element in GSM 03.40's
// reserved-for-future-use range (0x70), so Wireshark's gsm_sms_ud dissector - which
// validates the *typed* IEs' own lengths (0x01 "Special SMS Message Indication" must be
@@ -682,7 +676,7 @@ describe('smppsim - C17 encodings round trip over loopback', () => {
const udh = Buffer.from([0x03, 0x70, 0x01, 0xAA]);
const payload = Buffer.from([0xDE, 0xAD, 0xBE, 0xEF]);
- const sent = await session.send({
+ const sent = await esme.send({
cmdName: 'submit_sm',
params: {
data_coding: consts.ENCODING.BINARY,
diff --git a/interop-tests/smscsim.test.ts b/interop-tests/smscsim.test.ts
index ba23042..26b84ab 100644
--- a/interop-tests/smscsim.test.ts
+++ b/interop-tests/smscsim.test.ts
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test, { describe } from 'node:test';
import type { Dlr } from '../src/dlr.ts';
-import type { Session } from '../src/session.ts';
+import type { SmppClient } from '../src/client.ts';
import type { Sms } from '../src/sms.ts';
import { client } from '../src/client.ts';
import { closeAfter } from '../test/teardown.ts';
@@ -33,8 +33,8 @@ async function waitFor<T>(get: () => T | undefined, budget = 5000): Promise<T |
}
/** Sends once and waits for every segment of it to be matched by a `dlr` event. */
-async function sendAndAwaitDlrs(session: Session, dlrs: Dlr[], message: string): Promise<string[]> {
- const sent = await session.sendSms({ dlr: true, from: '46701113311', message, to: '46709771337' });
+async function sendAndAwaitDlrs(esme: SmppClient, dlrs: Dlr[], message: string): Promise<string[]> {
+ const sent = await esme.sendSms({ dlr: true, from: '46701113311', message, to: '46709771337' });
assert.equal(sent.err, undefined);
@@ -57,7 +57,7 @@ describe('smscsim - C1 bind, keepalive, unbind', () => {
const closes: unknown[] = [];
const sessionErrors: Error[] = [];
- const { err, session } = await client({
+ const { err, client: esme } = await client({
bindType,
enquireLinkInterval: 1000,
host: PEER_HOST,
@@ -66,16 +66,16 @@ describe('smscsim - C1 bind, keepalive, unbind', () => {
});
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
- session.on('close', () => { closes.push(undefined); });
- session.on('sessionError', sessionError => { sessionErrors.push(sessionError); });
+ esme.on('close', () => { closes.push(undefined); });
+ esme.on('sessionError', sessionError => { sessionErrors.push(sessionError); });
// smscsim never sends an unsolicited enquire_link of its own - its ENQUIRE_LINK case
// only answers one (confirmed in its source, smsc.go). So the interval-driven
// keepalive is checked through its own response, not through `incomingPduObj`.
- const enquired = await session.send({ cmdName: 'enquire_link' });
+ const enquired = await esme.send({ cmdName: 'enquire_link' });
assert.equal(enquired.err, undefined);
assert.ok(enquired.pduObj);
@@ -84,7 +84,7 @@ describe('smscsim - C1 bind, keepalive, unbind', () => {
await delay(1500);
- const unbound = await session.unbind();
+ const unbound = await esme.unbind();
assert.equal(unbound.err, undefined);
@@ -100,15 +100,15 @@ describe('smscsim - a single SMS', () => {
test('one id back, a DLR within 5s naming it DELIVERED', async t => {
const dlrs: Dlr[] = [];
- const { err, session } = await client({ host: PEER_HOST, port: PEER_PORT, username: 'single-sms' });
+ const { err, client: esme } = await client({ host: PEER_HOST, port: PEER_PORT, username: 'single-sms' });
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
- session.on('dlr', dlr => { dlrs.push(dlr); });
+ esme.on('dlr', dlr => { dlrs.push(dlr); });
- const smsIds = await sendAndAwaitDlrs(session, dlrs, 'hello world');
+ const smsIds = await sendAndAwaitDlrs(esme, dlrs, 'hello world');
assert.equal(smsIds.length, 1);
@@ -127,17 +127,17 @@ describe('smscsim - multipart segments', () => {
test('a 2-segment GSM message gets 2 ids and a DLR per id', async t => {
const dlrs: Dlr[] = [];
- const { err, session } = await client({ host: PEER_HOST, port: PEER_PORT, username: 'gsm-multipart' });
+ const { err, client: esme } = await client({ host: PEER_HOST, port: PEER_PORT, username: 'gsm-multipart' });
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
- session.on('dlr', dlr => { dlrs.push(dlr); });
+ esme.on('dlr', dlr => { dlrs.push(dlr); });
// 200 plain GSM chars: over the 160-char single-segment budget, under the 306-char
// 2-segment one (153 septets each).
- const smsIds = await sendAndAwaitDlrs(session, dlrs, 'a'.repeat(200));
+ const smsIds = await sendAndAwaitDlrs(esme, dlrs, 'a'.repeat(200));
assert.equal(smsIds.length, 2);
});
@@ -145,38 +145,36 @@ describe('smscsim - multipart segments', () => {
test('a 2-segment UCS2 message (一 and an emoji) gets 2 ids and a DLR per id', async t => {
const dlrs: Dlr[] = [];
- const { err, session } = await client({ host: PEER_HOST, port: PEER_PORT, username: 'ucs2-multipart' });
+ const { err, client: esme } = await client({ host: PEER_HOST, port: PEER_PORT, username: 'ucs2-multipart' });
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
- session.on('dlr', dlr => { dlrs.push(dlr); });
+ esme.on('dlr', dlr => { dlrs.push(dlr); });
// 一 (2 bytes) + an emoji (a surrogate pair, 4 bytes) + 70 padding chars (2 bytes each):
// 146 bytes, over the 140-byte single-segment budget, under the 268-byte 2-segment one.
- const smsIds = await sendAndAwaitDlrs(session, dlrs, `一😀${'x'.repeat(70)}`);
+ const smsIds = await sendAndAwaitDlrs(esme, dlrs, `一😀${'x'.repeat(70)}`);
assert.equal(smsIds.length, 2);
});
});
describe('smscsim - MO injection through the web UI', () => {
- test('a message posted to the web page arrives as an sms event', async t => {
- const { err, session } = await client({
+ test('a message posted to the web page reaches onSms', async t => {
+ const incoming: Sms[] = [];
+ const { err, client: esme } = await client({
bindType: 'transceiver',
host: PEER_HOST,
+ onSms: sms => { incoming.push(sms); },
port: PEER_PORT,
username: 'mo-inject',
});
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
-
- const incoming: Sms[] = [];
-
- session.on('sms', sms => { incoming.push(sms); });
+ assert.ok(esme);
+ closeAfter(t, esme);
const response = await fetch(`http://${PEER_HOST}:${String(PEER_WEB_PORT)}/`, {
body: new URLSearchParams({
@@ -199,7 +197,6 @@ describe('smscsim - MO injection through the web UI', () => {
assert.equal(sms.to, '46709771337');
assert.equal(sms.message, 'hello from the web UI');
- assert.equal((await sms.sendResp()).err, undefined);
});
});
@@ -207,13 +204,13 @@ describe('smscsim-failing - C12 refusals', () => {
test('even sequence numbers are refused, odd ones get an undeliverable DLR', async t => {
const dlrs: Dlr[] = [];
- const { err, session } = await client({ host: FAILING_PEER_HOST, port: FAILING_PEER_PORT, username: 'c12' });
+ const { err, client: esme } = await client({ host: FAILING_PEER_HOST, port: FAILING_PEER_PORT, username: 'c12' });
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
- session.on('dlr', dlr => { dlrs.push(dlr); });
+ esme.on('dlr', dlr => { dlrs.push(dlr); });
let refusedSeen = false;
let acceptedConfirmed = false;
@@ -222,7 +219,7 @@ describe('smscsim-failing - C12 refusals', () => {
const before = dlrs.length;
// Sequential: smscsim keys its refusal on each submit_sm's own sequence number parity.
- const result = await session.sendSms({
+ const result = await esme.sendSms({
dlr: true,
from: '46701113311',
message: `refusal check ${String(attempt)}`,
@@ -253,7 +250,7 @@ describe('smscsim-failing - C12 refusals', () => {
assert.ok(acceptedConfirmed, 'no accepted send got a confirmed undeliverable DLR');
// The session must stay bound and usable after a refusal.
- const enquired = await session.send({ cmdName: 'enquire_link' });
+ const enquired = await esme.send({ cmdName: 'enquire_link' });
assert.equal(enquired.err, undefined);
});
diff --git a/src/client.ts b/src/client.ts
index 6b7299c..5031634 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -1,21 +1,31 @@
import type { ConnectionOptions } from 'node:tls';
+import type { Dlr } from './dlr.ts';
+import type { MessageDlr } from './dlr-merger.ts';
+import type { PduObject, PduObjectInput } from './pdu.ts';
+import type { PduRefusedError } from './pdu-refusal.ts';
import type { Result, VoidResult } from './result.ts';
-import type { BindType, ReconnectOptions } from './session-options.ts';
+import type { BindType, CloseOptions, OnSms, SendOptions } from './session-options.ts';
+import type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
import type { SmppLog } from './log.ts';
import type { SmsIdFormat } from './sms-id.ts';
import type { Socket } from 'node:net';
-export type { BindType };
-
+import { EventEmitter } from 'node:events';
+import { DlrMerger } from './dlr-merger.ts';
+import { LinkLostError } from './link-lost-error.ts';
import { ReconnectLoop } from './reconnect-loop.ts';
import { Session } from './session.ts';
import { checkSessionOptions } from './session-options.ts';
import { connect as netConnect } from 'node:net';
import { connect as tlsConnect } from 'node:tls';
-import { defaultInterfaceVersion } from './defs/constants.ts';
+import { defaults } from './defaults.ts';
+import { errorFrom } from './error-from.ts';
import { guardedLog } from './log.ts';
+import { leftOf } from './idle-waiters.ts';
+import { submitSms, unsent } from './send-sms.ts';
+import { ConcatReference } from './udh.ts';
/** `fromStart` puts the very first connect and bind through the same backoff loop as a drop. */
-type ReconnectTuning = { fromStart?: boolean; maxDelay?: number; minDelay?: number };
+export type ReconnectTuning = { fromStart?: boolean; maxDelay?: number; minDelay?: number };
export type ClientOptions = {
addressRange?: string;
@@ -29,6 +39,7 @@ export type ClientOptions = {
interfaceVersion?: number;
log?: SmppLog;
maxOutstanding?: number;
+ onSms?: OnSms;
password?: string;
port?: number;
reconnect?: ReconnectTuning | false;
@@ -41,18 +52,204 @@ export type ClientOptions = {
username?: string;
};
-const defaults = {
- bindType: 'transceiver',
- connectTimeout: 10_000,
- enquireLinkInterval: 20_000,
- host: 'localhost',
- /** The idle timeout is what notices a dead link, so it has to outlast one silent probe. */
- idleTimeoutFactor: 2,
- interfaceVersion: defaultInterfaceVersion,
- password: 'pass',
- port: 2775,
- username: 'user',
-} as const;
+export type ClientEvents = {
+ close: [];
+ disconnected: [];
+ dlr: [Dlr, PduObject];
+ messageDlr: [MessageDlr];
+ reconnected: [];
+ sessionError: [Error | PduRefusedError];
+};
+
+/** A listener may return a promise: an `async` one that rejects is routed like one that throws. */
+type ClientListener<K extends keyof ClientEvents> = (...args: ClientEvents[K]) => unknown;
+
+/**
+ * The ESME's stable handle: one bound session at a time, opened again after a drop, with the
+ * sends waiting for a link and the receipt merges that outlive any one session.
+ */
+export class SmppClient extends EventEmitter<ClientEvents> {
+ declare addListener: <K extends keyof ClientEvents>(event: K, listener: ClientListener<K>) => this;
+ declare off: <K extends keyof ClientEvents>(event: K, listener: ClientListener<K>) => this;
+ declare on: <K extends keyof ClientEvents>(event: K, listener: ClientListener<K>) => this;
+ declare once: <K extends keyof ClientEvents>(event: K, listener: ClientListener<K>) => this;
+ declare prependListener: <K extends keyof ClientEvents>(event: K, listener: ClientListener<K>) => this;
+ declare prependOnceListener: <K extends keyof ClientEvents>(event: K, listener: ClientListener<K>) => this;
+ declare removeListener: <K extends keyof ClientEvents>(event: K, listener: ClientListener<K>) => this;
+
+ readonly log: SmppLog;
+
+ private closed = false;
+ private readonly concatReference = new ConcatReference();
+ private readonly links: ReconnectLoop;
+ private readonly merger: DlrMerger;
+ private readonly options: ClientOptions;
+ private readonly responseTimeout: number;
+
+ /** A listener that throws is the application's bug; it must not become ours. Hard rule 1. */
+ override emit<K extends keyof ClientEvents>(
+ event: K,
+ ...args: K extends keyof ClientEvents ? ClientEvents[K] : never
+ ): boolean {
+ try {
+ return super.emit(event, ...args);
+ } catch (thrown: unknown) {
+ const err = errorFrom(thrown);
+
+ this.log.error('client - a listener threw', { event, message: err.message });
+
+ if (event !== 'sessionError') this.emit('sessionError', err);
+
+ return false;
+ }
+ }
+
+ override [EventEmitter.captureRejectionSymbol](
+ reason: unknown,
+ ...args: [event: keyof ClientEvents, ...rest: unknown[]]
+ ): void {
+ const [event] = args;
+ const error = errorFrom(reason);
+
+ this.log.error('client - a listener rejected', { event, message: error.message });
+
+ if (event !== 'sessionError') this.emit('sessionError', error);
+ }
+
+ constructor(options: ClientOptions, log: SmppLog) {
+ super({ captureRejections: true });
+
+ this.log = log;
+ this.options = options;
+ this.responseTimeout = options.responseTimeout ?? defaults.responseTimeout;
+ this.merger = new DlrMerger({ log, max: defaults.maxDlrMerges, timeout: defaults.dlrMergeTimeout });
+ this.links = new ReconnectLoop({
+ log,
+ maxDelay: options.reconnect ? options.reconnect.maxDelay : undefined,
+ minDelay: options.reconnect ? options.reconnect.minDelay : undefined,
+ onDown: retrying => { this.down(retrying); },
+ onUp: (session, first) => { this.up(session, first); },
+ open: () => openBoundSession(options, log),
+ });
+ options.signal?.addEventListener('abort', () => { void this.close({ signal: options.signal }); }, { once: true });
+ }
+
+ /** The bound session, or undefined while the link is down. Replaced on every reconnect. */
+ get session(): Session | undefined {
+ return this.links.current();
+ }
+
+ /** Sends a request on the bound link, waiting for one up to `responseTimeout`, and resolves with the response. */
+ async send(input: PduObjectInput, options: SendOptions = {}): Promise<Result<{ pduObj: PduObject }>> {
+ const deadline = this.responseTimeout > 0 ? Date.now() + this.responseTimeout : 0;
+
+ for (;;) {
+ const link = await this.links.bound(leftOf(deadline), options.signal);
+
+ if (link.err) return { err: link.err };
+
+ const sent = await link.session.send(input, options);
+
+ // Nothing reached the socket, so the next link carries it; anything else is the answer.
+ if (!(sent.err instanceof LinkLostError)) return sent;
+ }
+ }
+
+ async sendSms(sms: SendSmsOptions, options: SendOptions = {}): Promise<SendSmsResult> {
+ if ((this.options.bindType ?? defaults.bindType) === 'receiver') {
+ return unsent(new Error('A receiver-bound session does not carry submit_sm'));
+ }
+
+ const sent = await submitSms({
+ log: this.log,
+ reference: this.concatReference.next(),
+ respIdNotation: this.options.smsIdFormat?.submitResp,
+ send: input => this.send(input, options),
+ }, sms);
+
+ if (!sent.err && sms.dlr === true) this.merger.expect(sent.smsIds);
+
+ return sent;
+ }
+
+ /** Drains and unbinds the bound session, and opens no other. */
+ async unbind(): Promise<VoidResult> {
+ const session = this.links.current();
+
+ this.links.stop();
+
+ const unbound = session ? await session.unbind() : { err: new Error('Session is closed') };
+
+ this.end();
+
+ return unbound;
+ }
+
+ /** Drains and closes the bound session, and opens no other. */
+ async close(options: CloseOptions = {}): Promise<VoidResult> {
+ const session = this.links.current();
+
+ this.links.stop();
+
+ const closed = session ? await session.close(options) : {};
+
+ this.end();
+
+ return closed;
+ }
+
+ /** The first session is the caller's to take; every later one is announced. */
+ takeFirst(session: Session): void {
+ this.links.adopt(session);
+
+ if (this.options.reconnect === false) this.links.stop();
+ }
+
+ /** Keeps trying, on the backoff, until a link binds or the signal ends the wait. */
+ async keepTrying(): Promise<VoidResult> {
+ this.links.schedule();
+
+ const link = await this.links.bound(0, this.options.signal);
+
+ if (link.err) this.links.stop();
+
+ return link.err ? { err: link.err } : {};
+ }
+
+ private up(session: Session, first: boolean): void {
+ session.on('dlr', (dlr, pduObj) => {
+ this.emit('dlr', dlr, pduObj);
+
+ const merged = this.merger.collect(dlr);
+
+ if (merged) this.emit('messageDlr', merged);
+ });
+ session.on('sessionError', err => { this.emit('sessionError', err); });
+
+ if (first) return;
+
+ this.log.info('client - reconnected');
+ this.emit('reconnected');
+ }
+
+ private down(retrying: boolean): void {
+ if (retrying) {
+ this.emit('disconnected');
+
+ return;
+ }
+
+ this.end();
+ }
+
+ private end(): void {
+ if (this.closed) return;
+
+ this.closed = true;
+ this.merger.clear();
+ this.emit('close');
+ }
+}
function armConnectTimeout(
sock: Socket,
@@ -97,7 +294,7 @@ function openSocket(options: ClientOptions): Promise<Result<{ sock: Socket }>> {
try {
sock = secure ? tlsConnect({ host, port, ...tlsOptions }) : netConnect({ host, port });
} catch (thrown: unknown) {
- resolve({ err: thrown instanceof Error ? thrown : new Error(String(thrown)) });
+ resolve({ err: errorFrom(thrown) });
return;
}
@@ -128,21 +325,6 @@ function openSocket(options: ClientOptions): Promise<Result<{ sock: Socket }>> {
});
}
-/** Every connect this client makes goes through here, so a failed one is named the same way once. */
-async function connectSocket(options: ClientOptions, log: SmppLog): Promise<Result<{ sock: Socket }>> {
- const opened = await openSocket(options);
-
- if (opened.err) {
- log.warn('client - could not connect', {
- host: options.host ?? defaults.host,
- message: opened.err.message,
- port: options.port ?? defaults.port,
- });
- }
-
- return opened;
-}
-
function bindParams(options: ClientOptions, systemId: string) {
return {
address_range: options.addressRange ?? '',
@@ -183,19 +365,6 @@ async function bind(session: Session, options: ClientOptions): Promise<VoidResul
return {};
}
-function reconnectFor(options: ClientOptions, log: SmppLog): ReconnectOptions | undefined {
- if (options.reconnect === false) return undefined;
-
- const tuning = options.reconnect ?? {};
-
- return {
- connect: () => connectSocket(options, log),
- maxDelay: tuning.maxDelay,
- minDelay: tuning.minDelay,
- onConnected: reconnected => bind(reconnected, options),
- };
-}
-
function createSession(options: ClientOptions, log: SmppLog, sock: Socket): Session {
const enquireLinkInterval = options.enquireLinkInterval ?? defaults.enquireLinkInterval;
@@ -204,7 +373,7 @@ function createSession(options: ClientOptions, log: SmppLog, sock: Socket): Sess
idleTimeout: options.idleTimeout ?? enquireLinkInterval * defaults.idleTimeoutFactor,
log,
maxOutstanding: options.maxOutstanding,
- reconnect: reconnectFor(options, log),
+ onSms: options.onSms,
responseTimeout: options.responseTimeout,
shutdownTimeout: options.shutdownTimeout,
smsIdFormat: options.smsIdFormat,
@@ -212,41 +381,25 @@ function createSession(options: ClientOptions, log: SmppLog, sock: Socket): Sess
});
}
-async function connectAndBind(
- options: ClientOptions,
- log: SmppLog,
-): Promise<Result<{ session: Session }>> {
- const opened = await connectSocket(options, log);
-
- if (opened.err) return { err: opened.err };
-
- return bindOn(createSession(options, log, opened.sock), options);
-}
-
-/** Binds a session the caller has not seen yet, so a failure takes it down instead of surfacing. */
-async function bindOn(
- session: Session,
- options: ClientOptions,
-): Promise<Result<{ session: Session }>> {
- const signal = options.signal;
+/** Every link this client opens goes through here: one socket, one session, one bind. */
+async function openBoundSession(options: ClientOptions, log: SmppLog): Promise<Result<{ session: Session }>> {
+ const opened = await openSocket(options);
- if (signal?.aborted === true) {
- void session.close({ signal });
+ if (opened.err) {
+ log.warn('client - could not connect', {
+ host: options.host ?? defaults.host,
+ message: opened.err.message,
+ port: options.port ?? defaults.port,
+ });
- return { err: new Error('Aborted before binding') };
+ return { err: opened.err };
}
- const onAbort = (): void => { void session.close({ signal }); };
-
- // Registered before the bind: an abort landing while it is in flight has to close the session.
- signal?.addEventListener('abort', onAbort, { once: true });
-
+ const session = createSession(options, log, opened.sock);
const bound = await bind(session, options);
if (bound.err) {
- signal?.removeEventListener('abort', onAbort);
- // close() must reach the loop's stop() before its first await, or this session retries too.
- void session.close({ signal });
+ await session.close({ signal: AbortSignal.abort() });
return { err: bound.err };
}
@@ -254,82 +407,12 @@ async function bindOn(
return { session };
}
-function retriesFromStart(reconnect: ClientOptions['reconnect']): reconnect is ReconnectTuning {
+function retriesFromStart(reconnect: ClientOptions['reconnect']): boolean {
return reconnect !== undefined && reconnect !== false && reconnect.fromStart === true;
}
-/** A fresh session per attempt, and the failure to answer an abort with when none of them binds. */
-function initialAttempts(options: ClientOptions, log: SmppLog, failed: Error) {
- let lastErr = failed;
-
- return {
- bind: async (sock: Socket): Promise<Result<{ session: Session }>> => {
- const bound = await bindOn(createSession(options, log, sock), options);
-
- if (bound.err) lastErr = bound.err;
-
- return bound;
- },
- connect: async (): Promise<Result<{ sock: Socket }>> => {
- const opened = await connectSocket(options, log);
-
- if (opened.err) lastErr = opened.err;
-
- return opened;
- },
- lastErr: (): Error => lastErr,
- };
-}
-
-/** Retries the first connect and bind, on the backoff a drop takes, until one of them binds. */
-function keepTrying(
- options: ClientOptions,
- log: SmppLog,
- tuning: ReconnectTuning,
- failed: Error,
-): Promise<Result<{ session: Session }>> {
- return new Promise(resolve => {
- const attempts = initialAttempts(options, log, failed);
- const signal = options.signal;
- let settled = false;
- const loop = new ReconnectLoop({
- connect: attempts.connect,
- log,
- maxDelay: tuning.maxDelay,
- minDelay: tuning.minDelay,
- onConnected: async sock => {
- const bound = await attempts.bind(sock);
-
- if (bound.err) return { err: bound.err };
-
- settle({ session: bound.session });
-
- return {};
- },
- // Awaited with no other handle, so an unref()'d wait would exit the process unbound.
- unref: false,
- });
-
- function settle(result: Result<{ session: Session }>): void {
- if (settled) return;
-
- settled = true;
- loop.stop();
- signal?.removeEventListener('abort', onAbort);
- resolve(result);
- }
-
- function onAbort(): void {
- settle({ err: new Error('Aborted while connecting', { cause: attempts.lastErr() }) });
- }
-
- signal?.addEventListener('abort', onAbort, { once: true });
- loop.schedule();
- });
-}
-
/** Connects to an SMSC and binds. */
-export async function client(options: ClientOptions = {}): Promise<Result<{ session: Session }>> {
+export async function client(options: ClientOptions = {}): Promise<Result<{ client: SmppClient }>> {
const log = guardedLog(options.log);
const checked = checkSessionOptions(options);
@@ -339,10 +422,18 @@ export async function client(options: ClientOptions = {}): Promise<Result<{ sess
return { err: checked.err };
}
- const first = await connectAndBind(options, log);
- const reconnect = options.reconnect;
+ const smpp = new SmppClient(options, log);
+ const first = await openBoundSession(options, log);
+
+ if (!first.err) {
+ smpp.takeFirst(first.session);
+
+ return { client: smpp };
+ }
+
+ if (options.signal?.aborted === true || !retriesFromStart(options.reconnect)) return { err: first.err };
- if (!first.err || options.signal?.aborted === true || !retriesFromStart(reconnect)) return first;
+ const bound = await smpp.keepTrying();
- return keepTrying(options, log, reconnect, first.err);
+ return bound.err ? { err: new Error(bound.err.message, { cause: first.err }) } : { client: smpp };
}
diff --git a/src/defaults.ts b/src/defaults.ts
new file mode 100644
index 0000000..f2584ea
--- /dev/null
+++ b/src/defaults.ts
@@ -0,0 +1,32 @@
+import { defaultInterfaceVersion } from './defs/constants.ts';
+
+/** Every default the session layer runs on. README's option tables restate the public ones. */
+export const defaults = {
+ bindType: 'transceiver',
+ connectTimeout: 10_000,
+ /** Receipts of a multipart message can be a working day apart, so the cap does the bounding. */
+ dlrMergeTimeout: 86_400_000,
+ enquireLinkInterval: 20_000,
+ /** The peer gave up on an unanswered message long before this; the bound is against growth. */
+ handlerTimeout: 300_000,
+ host: 'localhost',
+ /** The idle timeout is what notices a dead link, so it has to outlast one silent probe. */
+ idleTimeoutFactor: 2,
+ interfaceVersion: defaultInterfaceVersion,
+ maxDelay: 30_000,
+ maxDlrMerges: 1000,
+ maxOutstanding: 10,
+ maxReassembly: 1000,
+ maxReassemblyOctets: 64 * 1024 * 1024,
+ maxRunningHandlers: 1000,
+ maxRunningOctets: 64 * 1024 * 1024,
+ minDelay: 1000,
+ password: 'pass',
+ port: 2775,
+ reassemblyTimeout: 300_000,
+ responseTimeout: 30_000,
+ serverIdleTimeout: 40_000,
+ shutdownTimeout: 5000,
+ systemId: '',
+ username: 'user',
+} as const;
diff --git a/src/defs/encodings.ts b/src/defs/encodings.ts
index c454b02..4684f37 100644
--- a/src/defs/encodings.ts
+++ b/src/defs/encodings.ts
@@ -42,7 +42,8 @@ for (const [extended, base] of gsmExtendedPairs) {
gsmExtChars.set(base, extended);
}
-const ascii: Encoding = {
+// The option's historical name for this alphabet is ASCII; the table it writes is GSM 03.38.
+const gsm0338: Encoding = {
decode(buffer) {
let result = '';
@@ -103,7 +104,7 @@ const ucs2: Encoding = {
};
export const encodings: Record<EncodingName, Encoding> = {
- ASCII: ascii,
+ ASCII: gsm0338,
LATIN1: latin1,
UCS2: ucs2,
};
diff --git a/src/dlr-merger.ts b/src/dlr-merger.ts
index 0c20cae..ac282af 100644
--- a/src/dlr-merger.ts
+++ b/src/dlr-merger.ts
@@ -69,13 +69,22 @@ export class DlrMerger {
private readonly groups: ExpiringGroups<Group>;
private readonly log: SmppLog;
private readonly max: number;
+ /** The bases already merged or given up on, so none is opened twice. */
private readonly spent: ExpiringGroups<true>;
constructor(options: DlrMergerOptions) {
this.groups = new ExpiringGroups<Group>({
max: options.max,
now: options.now,
- onSweep: () => { this.sweep(); },
+ onDrop: (base, group, reason) => {
+ this.spend(base);
+
+ if (reason === 'expired') {
+ this.log.info('dlrMerger - incomplete receipts expired', { base, expected: group.expected.size });
+ } else {
+ this.log.warn('dlrMerger - buffer full, dropping the oldest message', { base, max: this.max });
+ }
+ },
timeout: options.timeout,
});
this.log = options.log;
@@ -83,7 +92,7 @@ export class DlrMerger {
this.spent = new ExpiringGroups<true>({
max: options.max,
now: options.now,
- onSweep: () => { this.spent.takeExpired(); },
+ onDrop: () => undefined,
timeout: options.timeout,
});
}
@@ -103,8 +112,6 @@ export class DlrMerger {
/** The whole message's report, on the receipt that completes it. */
collect(dlr: Dlr): MessageDlr | undefined {
- this.sweep();
-
if (dlr.intermediate || dlr.smsId === undefined) return undefined;
const numbering = parseSegmentId(dlr.smsId);
@@ -126,7 +133,7 @@ export class DlrMerger {
if (group.parts.size < group.expected.size) return undefined;
- this.close(base);
+ this.spend(base);
const segments = [...group.parts.entries()].sort(([a], [b]) => a - b).map(([, one]) => one);
const worst = segments.reduce((carry, one) => (severity[one.statusMsg] > severity[carry.statusMsg] ? one : carry));
@@ -139,46 +146,20 @@ export class DlrMerger {
this.spent.takeAll();
}
- /** Drops every group past its deadline. Runs before each collect and on its own timer. */
- sweep(): void {
- for (const [base, group] of this.groups.takeExpired()) {
- this.close(base);
- this.log.info('dlrMerger - incomplete receipts expired', { base, expected: group.expected.size });
- }
- }
-
private open(base: string, expected: Set<number>): void {
- this.spent.takeExpired();
-
if (this.groups.get(base) !== undefined || this.spent.get(base) === true) {
- this.close(base);
+ this.spend(base);
this.log.info('dlrMerger - message id handed out again, leaving its receipts unmerged', { base });
return;
}
- if (this.groups.full) this.dropOldest();
-
this.groups.set(base, { expected, parts: new Map() });
}
- private close(base: string): void {
+ /** The base is finished with, merged or not, and stays refused for as long as a receipt could still arrive. */
+ private spend(base: string): void {
this.groups.delete(base);
- this.spent.delete(base);
-
- if (this.spent.full) this.spent.takeOldest();
-
this.spent.set(base, true);
}
-
- private dropOldest(): void {
- const oldest = this.groups.takeOldest();
-
- if (!oldest) return;
-
- const [base] = oldest;
-
- this.close(base);
- this.log.warn('dlrMerger - buffer full, dropping the oldest message', { base, max: this.max });
- }
}
diff --git a/src/expiring-groups.ts b/src/expiring-groups.ts
index 3e276aa..b03f116 100644
--- a/src/expiring-groups.ts
+++ b/src/expiring-groups.ts
@@ -1,11 +1,12 @@
-export type ExpiringGroupsOptions = {
+export type DropReason = 'evicted' | 'expired';
+
+export type ExpiringGroupsOptions<T> = {
max: number;
- /** Enforced by weigh() alone; set() never evicts. */
maxWeight?: number | undefined;
/** Injected so expiry can be exercised without a wall clock. */
now?: (() => number) | undefined;
- /** Must call takeExpired(): the timer itself removes nothing. */
- onSweep: () => void;
+ /** A group this store let go of on its own: over the count, over the weight, or past its deadline. */
+ onDrop: (key: string, group: T, reason: DropReason) => void;
timeout: number;
};
@@ -15,49 +16,61 @@ type Entry<T> = {
weight: number;
};
-/** Enforces neither max nor timeout itself: owners check full and call takeExpired(); only weigh() evicts. */
+/** A capped, weighed, expiring store: the oldest goes when a cap is hit, and the expired go on every access. */
export class ExpiringGroups<T> {
private readonly entries = new Map<string, Entry<T>>();
private readonly max: number;
private readonly maxWeight: number;
private readonly now: () => number;
- private readonly onSweep: () => void;
+ private readonly onDrop: (key: string, group: T, reason: DropReason) => void;
private readonly timeout: number;
private sweeper: NodeJS.Timeout | undefined;
private total = 0;
- constructor(options: ExpiringGroupsOptions) {
+ constructor(options: ExpiringGroupsOptions<T>) {
this.max = options.max;
this.maxWeight = options.maxWeight ?? Infinity;
this.now = options.now ?? Date.now;
- this.onSweep = options.onSweep;
+ this.onDrop = options.onDrop;
this.timeout = options.timeout;
}
get full(): boolean {
+ this.expire();
+
return this.entries.size >= this.max;
}
get size(): number {
+ this.expire();
+
return this.entries.size;
}
get weight(): number {
+ this.expire();
+
return this.total;
}
get(key: string): T | undefined {
+ this.expire();
+
return this.entries.get(key)?.group;
}
/** Replacing a key restarts its deadline and zeroes its weight; weigh() it again. */
set(key: string, group: T): void {
+ this.expire();
this.remove(key);
+
+ if (this.entries.size >= this.max) this.dropOldest();
+
this.entries.set(key, { deadline: this.now() + this.timeout, group, weight: 0 });
if (this.sweeper) return;
- this.sweeper = setInterval(() => { this.onSweep(); }, this.timeout);
+ this.sweeper = setInterval(() => { this.expire(); }, this.timeout);
this.sweeper.unref();
}
@@ -66,27 +79,21 @@ export class ExpiringGroups<T> {
this.idle();
}
- /** The returned groups are already removed, and may include key itself. */
- weigh(key: string, weight: number): [string, T][] {
+ /** The oldest groups go until the total is back under the cap, the weighed one included. */
+ weigh(key: string, weight: number): void {
const entry = this.entries.get(key);
- const taken: [string, T][] = [];
if (entry) {
this.total += weight - entry.weight;
entry.weight = weight;
}
- while (this.total > this.maxWeight) {
- const oldest = this.takeOldest();
-
- if (!oldest) break;
-
- taken.push(oldest);
+ while (this.total > this.maxWeight && this.entries.size > 0) {
+ this.dropOldest();
}
-
- return taken;
}
+ /** Everything, unreported: the owner says what it means for these to go. */
takeAll(): [string, T][] {
const taken: [string, T][] = [];
@@ -101,32 +108,28 @@ export class ExpiringGroups<T> {
return taken;
}
- takeExpired(): [string, T][] {
+ private expire(): void {
const now = this.now();
- const taken: [string, T][] = [];
for (const [key, entry] of this.entries) {
if (entry.deadline > now) continue;
- taken.push([key, entry.group]);
this.remove(key);
+ this.onDrop(key, entry.group, 'expired');
}
this.idle();
-
- return taken;
}
- takeOldest(): [string, T] | undefined {
+ private dropOldest(): void {
const oldest = this.entries.entries().next();
- if (oldest.done) return undefined;
+ if (oldest.done) return;
const [key, entry] = oldest.value;
- this.delete(key);
-
- return [key, entry.group];
+ this.remove(key);
+ this.onDrop(key, entry.group, 'evicted');
}
private remove(key: string): void {
diff --git a/src/held-messages.ts b/src/held-messages.ts
deleted file mode 100644
index b9e740e..0000000
--- a/src/held-messages.ts
+++ /dev/null
@@ -1,201 +0,0 @@
-import type { LinkLife } from './link-life.ts';
-import type { PduObject, PduObjectInput } from './pdu.ts';
-import type { Result } from './result.ts';
-import type { Session } from './session.ts';
-import type { SmsHandlers } from './sms.ts';
-import type { SmppLog } from './log.ts';
-import { ExpiringGroups } from './expiring-groups.ts';
-import { IdleWaiters } from './idle-waiters.ts';
-import { createSms } from './sms.ts';
-import { retainedOctets } from './retained-pdu.ts';
-
-export type HeldMessagesOptions = {
- link: LinkLife;
- log: SmppLog;
- max: number;
- maxOctets: number;
- /** Injected so expiry can be exercised without a wall clock. */
- now?: (() => number) | undefined;
- sendPastDrain: SmsHandlers['send'];
- session: Session;
- timeout: number;
-};
-
-/** The peer's own sequence number, which is what our answer to this message will carry. */
-function keyOf(pduObjs: PduObject[]): string | undefined {
- const first = pduObjs[0];
-
- return first ? String(first.seqNr) : undefined;
-}
-
-type HoldRoute = Pick<HeldMessagesOptions, 'link' | 'sendPastDrain' | 'session'>;
-
-/**
- * One message offered to the application, and the handlers its `Sms` answers through. A drain
- * waits on it until the first of: `answered()`, every listener that took it rejecting, no listener
- * taking it or one throwing, a later message on its sequence number, its deadline, or the link going.
- */
-export class MessageHold implements SmsHandlers {
- private readonly generation: number;
- private readonly heldMessages: HeldMessages;
- private readonly pduObjs: PduObject[];
- private readonly route: HoldRoute;
- private working: number;
-
- constructor(heldMessages: HeldMessages, route: HoldRoute, pduObjs: PduObject[], listeners: number) {
- this.generation = route.link.generation();
- this.heldMessages = heldMessages;
- this.pduObjs = pduObjs;
- this.route = route;
- this.working = listeners;
- }
-
- /** Whether a drain is still waiting for this message to be answered. */
- isHeld(): boolean {
- return this.heldMessages.holds(this.pduObjs);
- }
-
- /** A turn later, so a `sendDlr()` called straight after `sendResp()` still goes out past a drain. */
- answered(): void {
- setImmediate(() => { this.release(); });
- }
-
- lostLink(): boolean {
- return this.route.link.generation() !== this.generation;
- }
-
- /** A rejection leaves the other listeners running, so only the last one to fail gives the message up. */
- listenerGaveUp(): void {
- this.working--;
-
- if (this.working <= 0) this.answered();
- }
-
- /** At once, for a message nobody took or a listener threw on: that is not work a shutdown can wait for. */
- release(): void {
- this.heldMessages.release(this.pduObjs);
- }
-
- /** A receipt for a message still held is what a drain waits for, so it goes out past the drain. */
- send(input: PduObjectInput): Promise<Result<{ pduObj: PduObject }>> {
- return this.isHeld() ? this.route.sendPastDrain(input) : this.route.session.send(input);
- }
-}
-
-/** The messages handed to the application that it has not answered yet, held by their segments. */
-export class HeldMessages {
- private readonly held: ExpiringGroups<PduObject[]>;
- private readonly idleWaiters = new IdleWaiters();
- private readonly log: SmppLog;
- private readonly maxOctets: number;
- /** A rejecting listener hands the message back as an `unknown`, so its hold is found by identity. */
- private readonly offered = new WeakMap<object, MessageHold>();
- private readonly route: HoldRoute;
-
- constructor(options: HeldMessagesOptions) {
- this.held = new ExpiringGroups({
- max: options.max,
- now: options.now,
- onSweep: () => { this.sweep(); },
- timeout: options.timeout,
- });
- this.log = options.log;
- this.maxOctets = options.maxOctets;
- this.route = { link: options.link, sendPastDrain: options.sendPastDrain, session: options.session };
- }
-
- get octetsHeld(): number {
- return this.held.weight;
- }
-
- get size(): number {
- return this.held.size;
- }
-
- /** Whether a message arriving now is past the bound, once the expired are swept. */
- full(): boolean {
- this.sweep();
-
- return this.held.full || this.held.weight >= this.maxOctets;
- }
-
- private hold(key: string, pduObjs: PduObject[], listeners: number): MessageHold {
- const hold = new MessageHold(this, this.route, pduObjs, listeners);
-
- this.sweep();
-
- if (this.held.get(key)) {
- this.log.warn('heldMessages - replacing a message on a re-used sequence number', { seqNr: Number(key) });
- }
-
- this.held.set(key, pduObjs);
- this.held.weigh(key, pduObjs.reduce((sum, pduObj) => sum + retainedOctets(pduObj), 0));
-
- return hold;
- }
-
- offer(pduObjs: PduObject[], answeredAs?: string): MessageHold | undefined {
- const key = keyOf(pduObjs);
-
- if (key === undefined) return undefined;
-
- const hold = this.hold(key, pduObjs, this.route.session.listenerCount('sms'));
- const sms = createSms({ answeredAs, pduObjs, session: this.route.session }, hold);
-
- this.offered.set(sms, hold);
-
- if (!this.route.session.emit('sms', sms)) hold.release();
-
- return hold;
- }
-
- /** One listener gave up on a message; the last one to do so is what releases it. */
- listenerRejected(message: unknown): void {
- if (typeof message !== 'object' || message === null) return;
-
- this.offered.get(message)?.listenerGaveUp();
- }
-
- holds(pduObjs: PduObject[]): boolean {
- const key = keyOf(pduObjs);
-
- return key !== undefined && this.held.get(key) === pduObjs;
- }
-
- release(pduObjs: PduObject[]): void {
- const key = keyOf(pduObjs);
-
- // Identity, not the key: a wrapped sequence number must not release someone else's message.
- if (key === undefined || this.held.get(key) !== pduObjs) return;
-
- this.held.delete(key);
- this.settle();
- }
-
- /** Drops every message: their segments went with the link, so no answer of ours correlates now. */
- clear(): void {
- this.held.takeAll();
- this.idleWaiters.settle();
- }
-
- /** Resolves 0 once every message has been answered, or with how many have not. */
- idle(timeout: number, signal: AbortSignal | undefined): Promise<number> {
- return this.idleWaiters.wait(() => this.held.size, timeout, signal);
- }
-
- /** Drops every message past its deadline. Runs before each hold and on its own timer. */
- sweep(): void {
- const expired = this.held.takeExpired();
-
- if (expired.length === 0) return;
-
- this.log.warn('heldMessages - messages the application never answered', {
- messages: expired.length,
- });
- this.settle();
- }
-
- private settle(): void {
- if (this.held.size === 0) this.idleWaiters.settle();
- }
-}
diff --git a/src/incoming-requests.ts b/src/incoming-requests.ts
index 51aeec7..90bca8d 100644
--- a/src/incoming-requests.ts
+++ b/src/incoming-requests.ts
@@ -1,21 +1,24 @@
+import type { BindType, LinkEnd, OnRequest, OnSms } from './session-options.ts';
import type { Concat } from './concat.ts';
-import type { DlrMerger } from './dlr-merger.ts';
+import type { Dlr } from './dlr.ts';
import type { ErrorName } from './defs/errors.ts';
-import type { HeldMessagesOptions } from './held-messages.ts';
-import type { LinkLife } from './link-life.ts';
import type { LostGroup, Refusal } from './reassembly.ts';
-import type { OnRequest } from './session-options.ts';
-import type { PduObject } from './pdu.ts';
-import type { VoidResult } from './result.ts';
+import type { ParamValue } from './defs/types.ts';
+import type { PduObject, PduObjectInput, TlvInputs } from './pdu.ts';
+import type { Result, VoidResult } from './result.ts';
+import type { SendRespOptions, Sms } from './sms.ts';
import type { Session } from './session.ts';
import type { SmppLog } from './log.ts';
import type { SmsIdFormat } from './sms-id.ts';
-import { HeldMessages } from './held-messages.ts';
import { Reassembler } from './reassembly.ts';
-import { bindCommands, defaults, standsInFor } from './session-options.ts';
+import { RunningHandlers } from './running-handlers.ts';
+import { bindCommands, standsInFor } from './session-options.ts';
import { concatOf } from './concat.ts';
-import { detach } from './retained-pdu.ts';
+import { createSms } from './sms.ts';
+import { defaults } from './defaults.ts';
+import { detach, retainedOctets } from './retained-pdu.ts';
import { dlrFromPdu } from './dlr.ts';
+import { errorFrom } from './error-from.ts';
import { respIdParams, segmentId } from './sms-id.ts';
import { respNameFor } from './defs/commands.ts';
@@ -43,47 +46,60 @@ const lostReasons: Record<LostGroup['reason'], string> = {
linkGone: 'the link they arrived on went',
};
+/** What the peer's requests need from the session they arrived on. */
+export type SessionPort = {
+ acceptsOptionalParams: () => boolean;
+ answer: (pduObj: PduObject, status?: ErrorName, params?: Record<string, ParamValue>, tlvs?: TlvInputs) => Promise<VoidResult>;
+ bindAllows: (cmdName: string) => boolean;
+ boundAs: () => BindType | undefined;
+ /** Whether the socket has closed, so nothing answered now correlates. */
+ closed: () => boolean;
+ /** The peer is done with the link. */
+ end: () => void;
+ linkEnd: LinkEnd;
+ onDlr: (dlr: Dlr, pduObj: PduObject) => void;
+ report: (err: Error) => void;
+ /** A request past the drain's refusal, which a receipt for a message still being handled takes. */
+ request: (input: PduObjectInput) => Promise<Result<{ pduObj: PduObject }>>;
+ /** The public handle, for the hooks. */
+ session: Session;
+};
+
export type IncomingRequestsOptions = {
- dlrMerger: DlrMerger;
- link: LinkLife;
log: SmppLog;
maxOctets?: number | undefined;
maxReassembly?: number | undefined;
onRequest?: OnRequest | undefined;
+ onSms?: OnSms | undefined;
reassemblyTimeout?: number | undefined;
- sendPastDrain: HeldMessagesOptions['sendPastDrain'];
- session: Session;
smsIdFormat?: SmsIdFormat | undefined;
systemId?: string | undefined;
};
+type Outcome = { answer: SendRespOptions | undefined } | { thrown: Error };
+
+/** The handler's return as an answer; a bare return is the answer with nothing named. */
+function answerOf(returned: Awaited<ReturnType<OnSms>>): SendRespOptions | undefined {
+ return typeof returned === 'object' ? returned : undefined;
+}
+
/** Everything the peer asks of a session: messages, receipts, links and the answers to them. */
export class IncomingRequests {
- private readonly dlrMerger: DlrMerger;
- private readonly held: HeldMessages;
- private readonly link: LinkLife;
private readonly log: SmppLog;
private readonly onRequest: OnRequest | undefined;
+ private readonly onSms: OnSms | undefined;
+ private readonly port: SessionPort;
private readonly reassembler: Reassembler;
- private readonly session: Session;
+ private readonly running: RunningHandlers;
private readonly smsIdFormat: SmsIdFormat;
private readonly systemId: string;
private refusing = false;
- constructor(options: IncomingRequestsOptions) {
- this.dlrMerger = options.dlrMerger;
- this.held = new HeldMessages({
- link: options.link,
- log: options.log,
- max: defaults.maxHeldMessages,
- maxOctets: defaults.maxHeldOctets,
- sendPastDrain: options.sendPastDrain,
- session: options.session,
- timeout: defaults.heldMessageTimeout,
- });
- this.link = options.link;
+ constructor(port: SessionPort, options: IncomingRequestsOptions) {
this.log = options.log;
this.onRequest = options.onRequest;
+ this.onSms = options.onSms;
+ this.port = port;
this.reassembler = new Reassembler({
log: options.log,
max: options.maxReassembly ?? defaults.maxReassembly,
@@ -91,31 +107,34 @@ export class IncomingRequests {
onLost: lost => { this.reportLost(lost); },
timeout: options.reassemblyTimeout ?? defaults.reassemblyTimeout,
});
- this.session = options.session;
+ this.running = new RunningHandlers({
+ log: options.log,
+ max: defaults.maxRunningHandlers,
+ maxOctets: defaults.maxRunningOctets,
+ timeout: defaults.handlerTimeout,
+ });
this.smsIdFormat = options.smsIdFormat ?? {};
this.systemId = options.systemId ?? defaults.systemId;
}
async handle(pduObj: PduObject): Promise<void> {
- const generation = this.link.generation();
const { onRequest } = this;
// Called unbound, so the application's hook never sees this class as its `this`.
- if (onRequest && await onRequest(this.session, pduObj)) return;
+ if (onRequest && await onRequest(this.port.session, pduObj)) return;
- // The link it arrived on went while the hook ran, so nothing we answer now correlates.
- if (this.link.generation() !== generation) {
+ if (this.port.closed()) {
this.log.info('session - dropping a request whose link went', { cmdName: pduObj.cmdName });
return;
}
- if (!this.session.bindAllows(pduObj.cmdName)) {
+ if (!this.port.bindAllows(pduObj.cmdName)) {
this.log.info('session - command the peer\'s bind direction does not carry', {
- bindType: this.session.boundAs ?? '',
+ bindType: this.port.boundAs() ?? '',
cmdName: pduObj.cmdName,
});
- await this.session.sendReturn(pduObj, 'ESME_RINVBNDSTS');
+ await this.port.answer(pduObj, 'ESME_RINVBNDSTS');
return;
}
@@ -123,57 +142,53 @@ export class IncomingRequests {
await this.route(pduObj);
}
+ /** Waits out the handlers still running, and says how many never returned. */
+ async drain(timeout: number, signal: AbortSignal | undefined): Promise<VoidResult> {
+ const unanswered = await this.running.idle(timeout, signal);
+
+ if (unanswered === 0) return {};
+
+ this.log.warn('session - shutting down with handlers still running', { timeout, unanswered });
+
+ return { err: new Error(`Shut down with ${String(unanswered)} message(s) unanswered`) };
+ }
+
+ /** The link is gone: its half-arrived messages are lost, and its handlers answer nothing that correlates. */
+ end(): void {
+ this.refusing = false;
+ this.running.clear();
+ this.reassembler.clear();
+ }
+
private async route(pduObj: PduObject): Promise<void> {
switch (pduObj.cmdName) {
case 'data_sm':
case 'deliver_sm':
// A data_sm at the SMSC end is a submission, and a submission is never a report.
- await (this.carriedAs(pduObj) === 'submit_sm'
+ await (this.carriedAs(pduObj.cmdName) === 'submit_sm'
? this.onMessage(pduObj)
: this.onDelivery(pduObj));
break;
case 'enquire_link':
- await this.session.sendReturn(pduObj);
+ await this.port.answer(pduObj);
break;
case 'submit_sm':
await this.onMessage(pduObj);
break;
case 'unbind':
- await this.session.sendReturn(pduObj);
+ await this.port.answer(pduObj);
// A peer that has said it is finished will not answer what we still have outstanding.
- await this.session.close({ signal: AbortSignal.abort() });
+ this.port.end();
break;
default:
await this.unhandled(pduObj);
}
}
- /** Drops the segments of every message that never became whole, and of every one still held. */
- clear(): void {
- this.refusing = false;
- this.held.clear();
- this.reassembler.clear();
- }
-
- listenerRejected(sms: unknown): void {
- this.held.listenerRejected(sms);
- }
-
- /** Waits out the messages the application still holds, and says how many it never answered. */
- async drain(timeout: number, signal: AbortSignal | undefined): Promise<VoidResult> {
- const unanswered = await this.held.idle(timeout, signal);
-
- if (unanswered === 0) return {};
-
- this.log.warn('session - shutting down with messages unanswered', { timeout, unanswered });
-
- return { err: new Error(`Shut down with ${String(unanswered)} message(s) unanswered`) };
- }
-
private async unhandled(pduObj: PduObject): Promise<void> {
if (bindCommands.includes(pduObj.cmdName)) {
this.log.info('session - bind on an already bound session', { cmdName: pduObj.cmdName });
- await this.session.sendReturn(pduObj, 'ESME_RALYBND', { system_id: this.systemId });
+ await this.port.answer(pduObj, 'ESME_RALYBND', { system_id: this.systemId });
return;
}
@@ -185,11 +200,11 @@ export class IncomingRequests {
}
this.log.info('session - no handler for command', { cmdName: pduObj.cmdName });
- await this.session.sendReturn(pduObj, 'ESME_RINVCMDID');
+ await this.port.answer(pduObj, 'ESME_RINVCMDID');
}
- private carriedAs(pduObj: PduObject): string {
- return standsInFor(pduObj.cmdName, this.session.linkEnd);
+ private carriedAs(cmdName: string): string {
+ return standsInFor(cmdName, this.port.linkEnd);
}
/** SMPP carries a mobile-originated message and a delivery receipt on the same command. */
@@ -202,30 +217,25 @@ export class IncomingRequests {
return;
}
- this.session.emit('dlr', dlr, pduObj);
-
- const merged = this.dlrMerger.collect(dlr);
-
- if (merged) this.session.emit('messageDlr', merged);
-
- await this.session.sendReturn(pduObj);
+ this.port.onDlr(dlr, pduObj);
+ await this.port.answer(pduObj);
}
private async refusedAtBound(pduObj: PduObject): Promise<boolean> {
- if (this.held.full()) {
+ if (this.running.full()) {
if (!this.refusing) {
this.refusing = true;
- this.log.warn('session - unanswered messages at their bound, refusing new ones until the application answers', {
- messages: this.held.size,
- octets: this.held.octetsHeld,
+ this.log.warn('session - handlers at their bound, refusing new messages until they return', {
+ messages: this.running.size,
+ octets: this.running.octets,
});
}
- this.log.verbose('session - unanswered messages at their bound, asking the peer to retry', {
+ this.log.verbose('session - handlers at their bound, asking the peer to retry', {
cmdName: pduObj.cmdName,
seqNr: pduObj.seqNr,
});
- await this.session.sendReturn(pduObj, throttledStatus(this.carriedAs(pduObj)));
+ await this.port.answer(pduObj, throttledStatus(this.carriedAs(pduObj.cmdName)));
return true;
}
@@ -233,11 +243,11 @@ export class IncomingRequests {
// Half, so a peer keeping its window full does not flip this on every answer.
if (
this.refusing
- && this.held.size <= defaults.maxHeldMessages / 2
- && this.held.octetsHeld <= defaults.maxHeldOctets / 2
+ && this.running.size <= defaults.maxRunningHandlers / 2
+ && this.running.octets <= defaults.maxRunningOctets / 2
) {
this.refusing = false;
- this.log.info('session - unanswered messages down to half their bound, accepting again', { messages: this.held.size });
+ this.log.info('session - handlers down to half their bound, accepting again', { messages: this.running.size });
}
return false;
@@ -253,7 +263,7 @@ export class IncomingRequests {
const concat = concatOf(pduObj);
if (!concat) {
- this.held.offer([detach(pduObj)]);
+ await this.handOver([detach(pduObj)], undefined);
return;
}
@@ -261,25 +271,87 @@ export class IncomingRequests {
const collected = this.reassembler.collect(pduObj, concat);
if (!collected.kept) {
- await this.session.sendReturn(
+ await this.port.answer(
pduObj,
- refusedSegmentStatus(this.carriedAs(pduObj), collected.refusal, concat.spelling),
+ refusedSegmentStatus(this.carriedAs(pduObj.cmdName), collected.refusal, concat.spelling),
);
return;
}
- await this.session.sendReturn(
+ await this.port.answer(
pduObj,
'ESME_ROK',
respIdParams(pduObj.cmdName, segmentId(collected.smsId, concat.part - 1, concat.total)),
);
- if (collected.whole) this.held.offer(collected.whole, collected.smsId);
+ if (collected.whole) await this.handOver(collected.whole, collected.smsId);
+ }
+
+ /** Runs the handler on the message; whatever it leaves unanswered is answered from its outcome. */
+ private async handOver(pduObjs: PduObject[], answeredAs: string | undefined): Promise<void> {
+ const sms = createSms({ answeredAs, pduObjs, session: this.port.session }, {
+ acceptsOptionalParams: this.port.acceptsOptionalParams,
+ answer: (pduObj, status, params) => this.port.answer(pduObj, status, params),
+ bindAllows: this.port.bindAllows,
+ request: this.port.request,
+ });
+ const { onSms } = this;
+
+ // Its segments' answers went with the link, so the peer sends the message again.
+ if (this.port.closed()) return;
+
+ if (!onSms) {
+ this.log.warn('session - no onSms handler, refusing the message', { seqNr: pduObjs[0]?.seqNr ?? 0 });
+ await this.refuse(sms, new Error('No onSms handler took the message'));
+
+ return;
+ }
+
+ const done = this.running.start(pduObjs.reduce((sum, pduObj) => sum + retainedOctets(pduObj), 0));
+ const outcome = await this.run(onSms, sms);
+
+ done();
+
+ if ('thrown' in outcome) {
+ this.port.report(outcome.thrown);
+ await this.refuse(sms, outcome.thrown);
+
+ return;
+ }
+
+ const answered = await sms.sendResp(outcome.answer ?? {});
+
+ if (answered.err) this.port.report(answered.err);
+ }
+
+ private async run(onSms: OnSms, sms: Sms): Promise<Outcome> {
+ try {
+ return { answer: answerOf(await onSms(sms)) };
+ } catch (thrown: unknown) {
+ const err = errorFrom(thrown);
+
+ this.log.error('session - the onSms handler failed', { message: err.message });
+
+ return { thrown: err };
+ }
+ }
+
+ /** The application did not take the message: the peer keeps it, or it is lost where already answered. */
+ private async refuse(sms: Sms, reason: Error): Promise<void> {
+ if (sms.answeredOnArrival) {
+ this.port.report(new Error(`Lost a message answered on arrival: ${reason.message}`));
+
+ return;
+ }
+
+ const refused = await sms.sendResp({ status: throttledStatus(this.carriedAs(sms.pduObjs[0]?.cmdName ?? 'submit_sm')) });
+
+ if (refused.err) this.port.report(refused.err);
}
private reportLost(lost: LostGroup): void {
- this.session.emit('sessionError', new Error(
+ this.port.report(new Error(
`Gave up ${String(lost.parts)} of ${String(lost.total)} segments of an incomplete concatenated message: ${lostReasons[lost.reason]}`,
));
}
diff --git a/src/index.ts b/src/index.ts
index 71fa973..f75773f 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,4 +1,4 @@
-export { client } from './client.ts';
+export { client, SmppClient } from './client.ts';
export { server, SmppServer } from './server.ts';
export { Session } from './session.ts';
@@ -36,7 +36,7 @@ export { concatInfo } from './udh.ts';
export { PduFramer } from './pdu-framer.ts';
export { uuidv7 } from './uuid.ts';
-export type { BindType, ClientOptions } from './client.ts';
+export type { ClientEvents, ClientOptions, ReconnectTuning } from './client.ts';
export type { Dlr, Receipt } from './dlr.ts';
export type { SendDlrResult, SendRespOptions, Sms } from './sms.ts';
export type { Concat } from './concat.ts';
@@ -50,16 +50,18 @@ export type {
ServerEvents,
ServerOptions,
} from './server.ts';
+export type { MessageDlr } from './dlr-merger.ts';
+export type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
export type {
+ BindType,
CloseOptions,
- MessageDlr,
- ReconnectOptions,
+ LinkEnd,
+ OnRequest,
+ OnSms,
SendOptions,
- SendSmsOptions,
- SendSmsResult,
SessionEvents,
SessionOptions,
-} from './session.ts';
+} from './session-options.ts';
export type { CommandName, PduParams, PduParamsInput } from './defs/commands.ts';
export type { ConstGroup, MessageState, SubmitMessagingMode } from './defs/constants.ts';
export type { Encoding, EncodingName, Unencodable } from './defs/encodings.ts';
diff --git a/src/link-life.ts b/src/link-life.ts
deleted file mode 100644
index f44f2ea..0000000
--- a/src/link-life.ts
+++ /dev/null
@@ -1,188 +0,0 @@
-import type { SmppLog } from './log.ts';
-import type { VoidResult } from './result.ts';
-
-export type LinkLifeOptions = {
- log: SmppLog;
- now?: (() => number) | undefined;
- /** Whether a dropped link is followed by another one until stop(). */
- reconnects: boolean;
- /** How long a request may wait for a link. 0 waits for as long as one may still arrive. */
- timeout: number;
-};
-
-/** `binding`: a socket is attached and its bind is not answered yet, so it carries nothing but that bind. */
-type Phase = 'binding' | 'down' | 'ended' | 'up';
-
-type Waiter = (result: VoidResult) => void;
-
-function aborted(): Error {
- return new Error('Aborted while waiting for a link');
-}
-
-function expired(): Error {
- return new Error('The link did not come back in time');
-}
-
-function over(): Error {
- return new Error('Session is closed');
-}
-
-/** Whether the session's link lives, and where a request with no link to go out on waits for the next one. */
-export class LinkLife {
- private readonly log: SmppLog;
- private readonly now: () => number;
- private readonly reconnects: boolean;
- private readonly timeout: number;
- private readonly waiting = new Set<Waiter>();
- private drops = 0;
- private phase: Phase = 'up';
- private stopped = false;
-
- constructor(options: LinkLifeOptions) {
- this.log = options.log;
- this.now = options.now ?? Date.now;
- this.reconnects = options.reconnects;
- this.timeout = options.timeout;
- }
-
- /** A socket is on the link, bound or not. */
- isAttached(): boolean {
- return this.phase === 'binding' || this.phase === 'up';
- }
-
- /** Whether a request can go out right now. */
- isUp(): boolean {
- return this.phase === 'up';
- }
-
- private isOver(): boolean {
- return this.phase === 'ended';
- }
-
- /** The session is shutting down: nothing new is taken, and no link follows this one. */
- isStopped(): boolean {
- return this.stopped;
- }
-
- /** Whether a link that drops now is followed by another. */
- retrying(): boolean {
- return this.reconnects && !this.stopped;
- }
-
- /** Not up and not over, with a link to come. */
- awaitsNextLink(): boolean {
- return !this.isUp() && !this.isOver() && this.retrying();
- }
-
- /** Changes with every drop, so what was read off one link can tell that link is gone. */
- generation(): number {
- return this.drops;
- }
-
- /** Why no request will ever be admitted, or undefined while one may still get through. */
- refusal(): Error | undefined {
- return this.isUp() || this.awaitsNextLink() ? undefined : over();
- }
-
- /** One budget for a request, however many links it waits through. */
- hold(signal: AbortSignal | undefined): () => Promise<VoidResult> {
- const deadline = this.timeout > 0 ? this.now() + this.timeout : 0;
-
- return () => this.wait(deadline, signal);
- }
-
- /** A socket from the reconnect loop, not yet bound. An ended session stays ended. */
- attach(): void {
- if (this.isOver()) return;
-
- this.phase = 'binding';
- }
-
- /** The link is bound: everything held goes out on it. */
- open(): void {
- this.phase = 'up';
-
- if (this.waiting.size > 0) {
- this.log.verbose('linkLife - sending what was held for a link', { held: this.waiting.size });
- }
-
- this.release({});
- }
-
- /** The attached link is gone: the event that says so, or undefined when there was none to lose. */
- drop(): 'close' | 'disconnected' | undefined {
- if (!this.isAttached()) return undefined;
-
- this.phase = 'down';
- this.drops++;
-
- return this.retrying() ? 'disconnected' : 'close';
- }
-
- stop(): void {
- this.stopped = true;
- }
-
- /** The session is over: nothing held will ever go out. False means it already was. */
- end(): boolean {
- if (this.isOver()) return false;
-
- this.phase = 'ended';
- this.stopped = true;
- this.release({ err: over() });
-
- return true;
- }
-
- /** Resolves once a link can carry the request, or with the reason none ever will. */
- private wait(deadline: number, signal: AbortSignal | undefined): Promise<VoidResult> {
- if (this.isUp()) return Promise.resolve({});
-
- const refused = this.refusal();
-
- if (refused) return Promise.resolve({ err: refused });
-
- if (signal?.aborted === true) return Promise.resolve({ err: aborted() });
-
- const left = deadline === 0 ? 0 : deadline - this.now();
-
- if (deadline !== 0 && left <= 0) return Promise.resolve({ err: expired() });
-
- return this.waitForLink(left, signal);
- }
-
- private waitForLink(left: number, signal: AbortSignal | undefined): Promise<VoidResult> {
- this.log.verbose('linkLife - holding a request until a link is back', { timeout: left });
-
- return new Promise<VoidResult>(resolve => {
- let timer: NodeJS.Timeout | undefined = undefined;
- const settle = (result: VoidResult): void => {
- if (timer) clearTimeout(timer);
-
- signal?.removeEventListener('abort', onAbort);
- this.waiting.delete(settle);
- resolve(result);
- };
- const giveUp = (): void => {
- this.log.warn('linkLife - no link came back in time', { timeout: left });
- settle({ err: expired() });
- };
-
- function onAbort(): void {
- settle({ err: aborted() });
- }
-
- // Not unref()'d: a held request is awaited with no other handle, so the process would exit unsettled.
- if (left > 0) timer = setTimeout(giveUp, left);
-
- signal?.addEventListener('abort', onAbort, { once: true });
- this.waiting.add(settle);
- });
- }
-
- private release(result: VoidResult): void {
- for (const settle of [...this.waiting]) {
- settle(result);
- }
- }
-}
diff --git a/src/link-lost-error.ts b/src/link-lost-error.ts
new file mode 100644
index 0000000..485398b
--- /dev/null
+++ b/src/link-lost-error.ts
@@ -0,0 +1,7 @@
+/** The link went before the request reached the socket, so nothing the peer could have taken. */
+export class LinkLostError extends Error {
+ constructor() {
+ super('The link went before the request was sent');
+ this.name = 'LinkLostError';
+ }
+}
diff --git a/src/outgoing-requests.ts b/src/outgoing-requests.ts
index a0adf24..30e79be 100644
--- a/src/outgoing-requests.ts
+++ b/src/outgoing-requests.ts
@@ -1,40 +1,27 @@
-import type { LinkLife } from './link-life.ts';
import type { PduObject, PduObjectInput } from './pdu.ts';
import type { PduTransport } from './pdu-transport.ts';
import type { Result, VoidResult } from './result.ts';
import type { SendOptions } from './session-options.ts';
import type { SmppLog } from './log.ts';
+import { LinkLostError } from './link-lost-error.ts';
import { PendingRequests } from './pending-requests.ts';
import { SendWindow } from './send-window.ts';
import { UnansweredError } from './unanswered-error.ts';
-import { bindCommands } from './session-options.ts';
import { objToPdu } from './pdu.ts';
export type OutgoingRequestsOptions = {
- link: LinkLife;
log: SmppLog;
maxOutstanding: number;
responseTimeout: number;
transport: PduTransport;
};
-/** `retryOnNextLink`: the write failed, so nothing reached the socket and another link may carry it. */
-type Attempt = { result: Result<{ pduObj: PduObject }>; retryOnNextLink: boolean };
-
function abortedBeforeSend(): Error {
return new Error('Aborted before the request was sent');
}
-/** A response carries the request's sequence number, which only sendReturn() has. */
-function misuse(input: PduObjectInput): Error | undefined {
- return input.cmdName.endsWith('_resp')
- ? new Error(`Use sendReturn() for responses, not send(): ${input.cmdName}`)
- : undefined;
-}
-
-/** Everything this end asks of the peer: which link carries it, how many at once, and the answer. */
+/** Every request this end asks of the peer: the window it waits in, its sequence number, and the answer. */
export class OutgoingRequests {
- private readonly link: LinkLife;
private readonly log: SmppLog;
private readonly pending: PendingRequests;
private readonly responseTimeout: number;
@@ -42,7 +29,6 @@ export class OutgoingRequests {
private readonly window: SendWindow;
constructor(options: OutgoingRequestsOptions) {
- this.link = options.link;
this.log = options.log;
this.pending = new PendingRequests(options.log);
this.responseTimeout = options.responseTimeout;
@@ -50,15 +36,6 @@ export class OutgoingRequests {
this.window = new SendWindow({ limit: options.maxOutstanding, log: options.log });
}
- canCarry(): boolean {
- return this.link.isUp() && !this.transport.sock.destroyed;
- }
-
- /** The link is gone, and every answer still owed on it with it. */
- linkLost(): void {
- this.pending.settleAll(new Error('Session closed before a response arrived'));
- }
-
/** Hands a response to the request waiting for it. False means nothing was. */
deliver(pduObj: PduObject): boolean {
return this.pending.deliver(pduObj);
@@ -69,59 +46,20 @@ export class OutgoingRequests {
this.pending.settle(seqNr, { err });
}
- request(input: PduObjectInput, options: SendOptions): Promise<Result<{ pduObj: PduObject }>> {
- // Ahead of the drain, so a misuse is named as one rather than blamed on the shutdown.
- const wrong = misuse(input);
-
- if (wrong) return Promise.resolve({ err: wrong });
-
- // With no link, the request is refused as closed further on.
- if (this.link.isStopped() && this.canCarry()) {
- return Promise.resolve({ err: new Error('Session is shutting down') });
- }
-
- return this.requestPastDrain(input, options);
- }
-
- /** request() without the drain's refusal, which a receipt for a held message has to take. */
- async requestPastDrain(
- input: PduObjectInput,
- options: SendOptions,
- ): Promise<Result<{ pduObj: PduObject }>> {
- const refused = this.refuse(input, options);
+ /** Waits for a window slot, writes, and resolves with the answer. A `LinkLostError` means nothing was written. */
+ async request(input: PduObjectInput, options: SendOptions): Promise<Result<{ pduObj: PduObject }>> {
+ // Before the window, or an aborted call queues for a slot it will never use.
+ if (options.signal?.aborted === true) return { err: abortedBeforeSend() };
- if (refused) return { err: refused };
+ const slot = await this.window.acquire(options.signal);
- // A bind is what makes a link usable, so it cannot wait for one: it takes the link's answer now.
- if (bindCommands.includes(input.cmdName)) {
- const shut = this.link.refusal();
+ if (slot.err) return { err: slot.err };
- return shut ? { err: shut } : this.requestOnCurrentLink(input, options);
+ try {
+ return await this.attempt(input, options);
+ } finally {
+ this.window.release();
}
-
- const waitForLink = this.link.hold(options.signal);
-
- for (;;) {
- const held = await waitForLink();
-
- if (held.err) return { err: held.err };
-
- const slot = await this.window.acquire(options.signal);
-
- if (slot.err) return { err: slot.err };
-
- const attempt = await this.attempt(input, options).finally(() => { this.window.release(); });
-
- if (!this.retriesOnNextLink(attempt)) return attempt.result;
- }
- }
-
- /** Straight onto the current link, for what has to go out either way. */
- async requestOnCurrentLink(
- input: PduObjectInput,
- options: SendOptions = {},
- ): Promise<Result<{ pduObj: PduObject }>> {
- return (await this.attempt(input, options)).result;
}
/** Waits out the requests already on the wire, and says how many never finished. */
@@ -135,44 +73,33 @@ export class OutgoingRequests {
return { err: new Error(`Shut down with ${String(unfinished)} request(s) unfinished`) };
}
- /** Nothing reached the socket, so the next link carries it. */
- private retriesOnNextLink(attempt: Attempt): boolean {
- // Until the link is dropped it admits the retry straight back onto the dead socket, and the loop spins.
- return attempt.retryOnNextLink && this.link.awaitsNextLink();
- }
-
- /** Why a request cannot go out at all, as opposed to not yet. */
- private refuse(input: PduObjectInput, options: SendOptions): Error | undefined {
- // Before the link and the window, or an aborted call waits for what it will never use.
- return misuse(input) ?? (options.signal?.aborted === true ? abortedBeforeSend() : undefined);
+ /** The link is gone: what went out is unanswered, and what was still queued never left. */
+ end(): void {
+ this.pending.settleAll(new Error('Session closed before a response arrived'));
+ this.window.close(new LinkLostError());
}
- private async attempt(input: PduObjectInput, options: SendOptions): Promise<Attempt> {
+ private async attempt(input: PduObjectInput, options: SendOptions): Promise<Result<{ pduObj: PduObject }>> {
// pending.wait() alone settles the caller while the request still goes out to the peer.
- if (options.signal?.aborted === true) {
- return { result: { err: abortedBeforeSend() }, retryOnNextLink: false };
- }
+ if (options.signal?.aborted === true) return { err: abortedBeforeSend() };
const seqNr = this.pending.nextSeqNr();
const built = objToPdu({ ...input, seqNr });
- if (built.err) return { result: { err: built.err }, retryOnNextLink: false };
+ if (built.err) return { err: built.err };
- const response = this.pending.wait(seqNr, {
- signal: options.signal,
- timeout: this.responseTimeout,
- });
+ const response = this.pending.wait(seqNr, { signal: options.signal, timeout: this.responseTimeout });
const written = this.transport.write(built.buffer);
if (written.err) {
this.pending.settle(seqNr, { err: written.err });
- return { result: { err: written.err }, retryOnNextLink: true };
+ return { err: new LinkLostError() };
}
const answered = await response;
// It went out, so a failure now means the peer may have taken it and the answer was the loss.
- return { result: answered.err ? { err: new UnansweredError(answered.err) } : answered, retryOnNextLink: false };
+ return answered.err ? { err: new UnansweredError(answered.err) } : answered;
}
}
diff --git a/src/pdu-transport.ts b/src/pdu-transport.ts
index 966cdee..730d880 100644
--- a/src/pdu-transport.ts
+++ b/src/pdu-transport.ts
@@ -21,45 +21,27 @@ export type PduTransportOptions = {
onUnreadable: (err: Error) => void;
};
-/** A socket read as a stream of complete PDUs. A reconnect attaches a new socket in its place. */
+/** One socket read as a stream of complete PDUs. */
export class PduTransport {
+ readonly sock: Socket;
+ private readonly framer = new PduFramer();
private readonly options: PduTransportOptions;
- private framer = new PduFramer();
- private socket: Socket;
constructor(options: PduTransportOptions, sock: Socket) {
this.options = options;
- this.socket = sock;
- this.wire(sock);
- }
-
- get sock(): Socket {
- return this.socket;
- }
-
- /** Takes over a freshly opened socket. Half a PDU left on the old one must not prefix this one. */
- attach(sock: Socket): void {
- // The socket being replaced is already dead, and its three handlers still point here.
- this.socket.removeAllListeners();
- this.socket = sock;
- this.framer = new PduFramer();
- this.wire(sock);
- }
-
- private wire(sock: Socket): void {
+ this.sock = sock;
sock.on('data', chunk => { this.read(chunk); });
sock.on('close', () => { this.options.onClose(); });
sock.on('error', err => {
this.options.log.warn('transport - socket error', { message: err.message });
this.options.onError(err);
- this.options.onClose();
});
}
write(pdu: Buffer): VoidResult {
- if (this.socket.destroyed) return { err: new Error('Socket is closed') };
+ if (this.sock.destroyed) return { err: new Error('Socket is closed') };
- this.socket.write(pdu);
+ this.sock.write(pdu);
return {};
}
diff --git a/src/reassembly.ts b/src/reassembly.ts
index 4f3d2c5..438d86f 100644
--- a/src/reassembly.ts
+++ b/src/reassembly.ts
@@ -1,8 +1,10 @@
import type { Concat } from './concat.ts';
+import type { DropReason } from './expiring-groups.ts';
import type { PduObject } from './pdu.ts';
import type { SmppLog } from './log.ts';
import { ExpiringGroups } from './expiring-groups.ts';
import { decodeMessage } from './message.ts';
+import { defaults } from './defaults.ts';
import { detach, retainedOctets } from './retained-pdu.ts';
import { messageOctets } from './message-body.ts';
import { paramNumber, paramText } from './defs/types.ts';
@@ -11,7 +13,7 @@ import { uuidv7 } from './uuid.ts';
/** A concatenated message given up on, whose segments the peer has already been answered for. */
export type LostGroup = {
parts: number;
- reason: 'evicted' | 'expired' | 'linkGone';
+ reason: DropReason | 'linkGone';
smsId: string;
total: number;
};
@@ -42,8 +44,6 @@ export type Collected =
whole?: PduObject[] | undefined;
};
-export const defaultMaxOctets = 64 * 1024 * 1024;
-
type Group = {
parts: Map<number, PduObject>;
smsId: string;
@@ -87,14 +87,16 @@ export class Reassembler {
private readonly maxOctets: number;
private readonly newId: () => string;
private readonly onLost: (lost: LostGroup) => void;
+ /** The group being weighed: its newest segment stays with the peer if the group goes, so it is none of the loss. */
+ private weighing: string | undefined;
constructor(options: ReassemblerOptions) {
- this.maxOctets = options.maxOctets ?? defaultMaxOctets;
+ this.maxOctets = options.maxOctets ?? defaults.maxReassemblyOctets;
this.groups = new ExpiringGroups<Group>({
max: options.max,
maxWeight: this.maxOctets,
now: options.now,
- onSweep: () => { this.sweep(); },
+ onDrop: (key, group, reason) => { this.dropped(key, group, reason); },
timeout: options.timeout,
});
this.log = options.log;
@@ -109,8 +111,6 @@ export class Reassembler {
/** The group the segment joined, and always an answer for it: an unanswered one stalls a peer. */
collect(pduObj: PduObject, concat: Concat): Collected {
- this.sweep();
-
const key = groupKey(pduObj, concat);
const existing = this.groups.get(key);
@@ -122,7 +122,7 @@ export class Reassembler {
if (group.parts.size < group.total) {
// Its own arrival overran the octet cap, so the peer keeps it rather than being told we did.
- if (!this.trim(key, group)) return { kept: false, refusal: 'full' };
+ if (!this.weighed(key, group)) return { kept: false, refusal: 'full' };
return { kept: true, smsId: group.smsId };
}
@@ -136,19 +136,13 @@ export class Reassembler {
};
}
+ /** The link is gone: every group on it is lost, and reported so. */
clear(): void {
for (const [, group] of this.groups.takeAll()) {
this.lost(group, 'linkGone');
}
}
- /** Drops every group past its deadline. Runs before each collect and on its own timer. */
- sweep(): void {
- for (const [, group] of this.groups.takeExpired()) {
- this.lost(group, 'expired');
- }
- }
-
/** Whether a segment can join a group at all: its own numbering, and the group's total. */
private placeable(concat: Concat, existing: Group | undefined): boolean {
if (concat.part < 1 || concat.total < 1 || concat.part > concat.total) {
@@ -175,8 +169,6 @@ export class Reassembler {
}
private open(key: string, total: number): Group {
- if (this.groups.full) this.dropOldest();
-
const group: Group = { parts: new Map(), smsId: this.newId(), total };
this.groups.set(key, group);
@@ -184,32 +176,25 @@ export class Reassembler {
return group;
}
- /** Drops the oldest groups until the retained payload is back under the octet cap. False if the current one went. */
- private trim(current: string, group: Group): boolean {
+ /** Weighs the group in; false if that put it over the cap and it went. */
+ private weighed(key: string, group: Group): boolean {
let octets = 0;
for (const part of group.parts.values()) {
octets += retainedOctets(part);
}
- let survived = true;
-
- for (const [key, oldest] of this.groups.weigh(current, octets)) {
- if (key === current) survived = false;
-
- // The refused segment is in the group but stays with the peer, so it is none of the loss.
- const answered = key === current ? oldest.parts.size - 1 : oldest.parts.size;
-
- if (answered > 0) this.lost(oldest, 'evicted', answered);
- }
+ this.weighing = key;
+ this.groups.weigh(key, octets);
+ this.weighing = undefined;
- return survived;
+ return this.groups.get(key) !== undefined;
}
- private dropOldest(): void {
- const oldest = this.groups.takeOldest();
+ private dropped(key: string, group: Group, reason: DropReason): void {
+ const parts = key === this.weighing ? group.parts.size - 1 : group.parts.size;
- if (oldest) this.lost(oldest[1], 'evicted');
+ if (parts > 0) this.lost(group, reason, parts);
}
/** Its segments are answered, so the peer will not send them again: this is traffic gone. */
diff --git a/src/reconnect-loop.ts b/src/reconnect-loop.ts
index af8044d..fc89d2b 100644
--- a/src/reconnect-loop.ts
+++ b/src/reconnect-loop.ts
@@ -1,51 +1,87 @@
-import type { Result, VoidResult } from './result.ts';
+import type { Result } from './result.ts';
+import type { Session } from './session.ts';
import type { SmppLog } from './log.ts';
-import type { Socket } from 'node:net';
-
-export const backoffDefaults = {
- maxDelay: 30_000,
- minDelay: 1000,
-};
+import { defaults } from './defaults.ts';
export type ReconnectLoopOptions = {
- connect: () => Promise<Result<{ sock: Socket }>>;
log: SmppLog;
maxDelay?: number | undefined;
minDelay?: number | undefined;
now?: (() => number) | undefined;
- /** Brings the owner back up on a freshly opened socket. An err means try again. */
- onConnected: (sock: Socket) => Promise<VoidResult>;
- /** Whether the wait between attempts lets the process exit. Default true. */
- unref?: boolean | undefined;
+ /** The current link went. `retrying` says whether another is on its way. */
+ onDown: (retrying: boolean) => void;
+ /** A link came up bound; `first` is false for every one after the first. */
+ onUp: (session: Session, first: boolean) => void;
+ /** Opens a socket and binds a session on it. An err means try again after the backoff. */
+ open: () => Promise<Result<{ session: Session }>>;
};
-/** Reopens a dropped connection, backing off between attempts until it is told to stop. */
+type Waiter = (result: Result<{ session: Session }>) => void;
+
+function aborted(): Error {
+ return new Error('Aborted while waiting for a link');
+}
+
+function expired(): Error {
+ return new Error('The link did not come back in time');
+}
+
+function over(): Error {
+ return new Error('Session is closed');
+}
+
+/** One bound session at a time, opened again after each one ends, backing off until told to stop. */
export class ReconnectLoop {
+ private readonly log: SmppLog;
private readonly maxDelay: number;
private readonly minDelay: number;
private readonly now: () => number;
private readonly options: ReconnectLoopOptions;
+ private readonly waiting = new Set<Waiter>();
private attempting = false;
private delay: number;
- private halted = false;
+ private links = 0;
+ private session: Session | undefined;
+ private stopped = false;
private timer: NodeJS.Timeout | undefined;
private upAt: number | undefined;
constructor(options: ReconnectLoopOptions) {
- this.maxDelay = options.maxDelay ?? backoffDefaults.maxDelay;
- this.minDelay = options.minDelay ?? backoffDefaults.minDelay;
+ this.log = options.log;
+ this.maxDelay = options.maxDelay ?? defaults.maxDelay;
+ this.minDelay = options.minDelay ?? defaults.minDelay;
this.now = options.now ?? Date.now;
this.options = options;
this.delay = this.minDelay;
}
- /** Read through a method: stop() can land while an attempt is awaiting. */
- private isStopped(): boolean {
- return this.halted;
+ /** The bound session, while one is up. */
+ current(): Session | undefined {
+ return this.session;
+ }
+
+ /** Takes a session that was opened elsewhere as the current link. */
+ adopt(session: Session): void {
+ this.session = session;
+ this.upAt = this.now();
+ this.links++;
+ session.on('close', () => { this.down(session); });
+ this.options.onUp(session, this.links === 1);
+ this.release({ session });
+ }
+
+ /** Resolves with the bound session now or the next one to come up; a timeout of 0 waits for as long as one may. */
+ bound(timeout: number, signal: AbortSignal | undefined): Promise<Result<{ session: Session }>> {
+ if (this.session) return Promise.resolve({ session: this.session });
+ if (this.stopped) return Promise.resolve({ err: over() });
+ if (signal?.aborted === true) return Promise.resolve({ err: aborted() });
+
+ return this.waitForLink(timeout, signal);
}
+ /** Arms the next attempt, at the current backoff. */
schedule(): void {
- if (this.timer || this.attempting || this.isStopped()) return;
+ if (this.timer || this.attempting || this.stopped) return;
// Coming up is not proof: a stream we cannot read is only found once the link is bound.
if (this.upAt !== undefined && this.now() - this.upAt >= this.maxDelay) {
@@ -59,85 +95,96 @@ export class ReconnectLoop {
// Announced when the wait is over rather than when it starts: a cancelled one never happened.
this.timer = setTimeout(() => {
this.timer = undefined;
- this.options.log.info('reconnect - retrying', { delay });
- void this.run();
+ this.log.info('reconnect - retrying', { delay });
+ void this.attempt();
}, delay);
- if (this.options.unref ?? true) this.timer.unref();
+ // Before the first link the wait is all the process has; after one, it must not hold the process.
+ if (this.links > 0) this.timer.unref();
this.delay = Math.min(delay * 2, this.maxDelay);
}
+ /** No further attempts; the current session, if any, is the caller's to close. */
stop(): void {
- this.halted = true;
+ this.stopped = true;
if (this.timer) clearTimeout(this.timer);
this.timer = undefined;
+ this.release({ err: over() });
}
- private async run(): Promise<void> {
- this.attempting = true;
-
- // connect() and onConnected() are the application's, so a throw from either lands here.
- const retry = await this.attempt().catch((thrown: unknown) => {
- const err = thrown instanceof Error ? thrown : new Error(String(thrown));
-
- this.options.log.error('reconnect - an attempt threw', { message: err.message });
-
- return true;
- });
+ private down(session: Session): void {
+ if (this.session !== session) return;
- this.attempting = false;
+ this.session = undefined;
+ this.options.onDown(!this.stopped);
- if (retry) this.schedule();
+ if (!this.stopped) this.schedule();
}
- /** True means the attempt failed and the loop should try again. */
- private async attempt(): Promise<boolean> {
- if (this.isStopped()) return false;
+ private async attempt(): Promise<void> {
+ this.attempting = true;
+
+ // open() runs the application's connect and bind, so a throw from either lands here.
+ const opened = await Promise.resolve()
+ .then(() => this.options.open())
+ .catch((thrown: unknown): Result<{ session: Session }> => ({
+ err: thrown instanceof Error ? thrown : new Error(String(thrown)),
+ }));
- const opened = await this.options.connect();
+ this.attempting = false;
if (opened.err) {
- this.options.log.warn('reconnect - could not open a socket', {
- message: opened.err.message,
- });
+ this.log.warn('reconnect - could not come back up', { message: opened.err.message });
+ this.schedule();
- return true;
+ return;
}
- if (this.isStopped()) {
- opened.sock.destroy();
+ if (this.stopped) {
+ void opened.session.close({ signal: AbortSignal.abort() });
- return false;
+ return;
}
- const up = await this.bringUp(opened.sock);
-
- if (up.err) {
- this.options.log.warn('reconnect - could not come back up', { message: up.err.message });
+ this.adopt(opened.session);
+ }
- return true;
- }
+ private waitForLink(timeout: number, signal: AbortSignal | undefined): Promise<Result<{ session: Session }>> {
+ this.log.verbose('reconnect - holding a request until a link is back', { timeout });
- this.upAt = this.now();
+ return new Promise<Result<{ session: Session }>>(resolve => {
+ let timer: NodeJS.Timeout | undefined = undefined;
+ const settle: Waiter = result => {
+ if (timer) clearTimeout(timer);
- return false;
- }
+ signal?.removeEventListener('abort', onAbort);
+ this.waiting.delete(settle);
+ resolve(result);
+ };
+ const giveUp = (): void => {
+ this.log.warn('reconnect - no link came back in time', { timeout });
+ settle({ err: expired() });
+ };
- /** The loop owns the socket until the owner is up on it, so a failed handover must not leak it. */
- private async bringUp(sock: Socket): Promise<VoidResult> {
- try {
- const up = await this.options.onConnected(sock);
+ function onAbort(): void {
+ settle({ err: aborted() });
+ }
- if (up.err) sock.destroy();
+ // Not unref()'d: a held request is awaited with no other handle, so the process would exit unsettled.
+ if (timeout > 0) timer = setTimeout(giveUp, timeout);
- return up;
- } catch (thrown: unknown) {
- sock.destroy();
+ signal?.addEventListener('abort', onAbort, { once: true });
+ this.waiting.add(settle);
+ });
+ }
- return { err: thrown instanceof Error ? thrown : new Error(String(thrown)) };
+ private release(result: Result<{ session: Session }>): void {
+ for (const settle of [...this.waiting]) {
+ settle(result);
}
}
}
+
diff --git a/src/running-handlers.ts b/src/running-handlers.ts
new file mode 100644
index 0000000..18bade2
--- /dev/null
+++ b/src/running-handlers.ts
@@ -0,0 +1,76 @@
+import type { SmppLog } from './log.ts';
+import { ExpiringGroups } from './expiring-groups.ts';
+import { IdleWaiters } from './idle-waiters.ts';
+
+export type RunningHandlersOptions = {
+ log: SmppLog;
+ max: number;
+ maxOctets: number;
+ /** Injected so expiry can be exercised without a wall clock. */
+ now?: (() => number) | undefined;
+ timeout: number;
+};
+
+/** The onSms handlers still running: counted against a bound, waited on by a drain, forgotten past a timeout. */
+export class RunningHandlers {
+ private readonly idleWaiters = new IdleWaiters();
+ private readonly log: SmppLog;
+ private readonly maxOctets: number;
+ private readonly running: ExpiringGroups<true>;
+ private serial = 0;
+
+ constructor(options: RunningHandlersOptions) {
+ this.log = options.log;
+ this.maxOctets = options.maxOctets;
+ this.running = new ExpiringGroups({
+ max: options.max,
+ now: options.now,
+ onDrop: () => {
+ this.log.warn('runningHandlers - giving up on a handler that never returned', { timeout: options.timeout });
+ this.settle();
+ },
+ timeout: options.timeout,
+ });
+ }
+
+ get octets(): number {
+ return this.running.weight;
+ }
+
+ get size(): number {
+ return this.running.size;
+ }
+
+ /** Whether a message arriving now is past the bound. */
+ full(): boolean {
+ return this.running.full || this.running.weight >= this.maxOctets;
+ }
+
+ /** Counts a handler from now until the returned function is called. */
+ start(octets: number): () => void {
+ const key = String(this.serial++);
+
+ this.running.set(key, true);
+ this.running.weigh(key, octets);
+
+ return () => {
+ this.running.delete(key);
+ this.settle();
+ };
+ }
+
+ /** Resolves 0 once no handler is running, or with how many still are. */
+ idle(timeout: number, signal: AbortSignal | undefined): Promise<number> {
+ return this.idleWaiters.wait(() => this.running.size, timeout, signal);
+ }
+
+ /** Forgets every handler: the link is gone, so nothing they answer correlates now. */
+ clear(): void {
+ this.running.takeAll();
+ this.idleWaiters.settle();
+ }
+
+ private settle(): void {
+ if (this.running.size === 0) this.idleWaiters.settle();
+ }
+}
diff --git a/src/send-window.ts b/src/send-window.ts
index e13d67d..36c1cbb 100644
--- a/src/send-window.ts
+++ b/src/send-window.ts
@@ -56,6 +56,13 @@ export class SendWindow {
this.idleWaiters.settle();
}
+ /** Settles everything still queued with the reason no slot will ever come. */
+ close(err: Error): void {
+ for (const settle of [...this.waiting]) {
+ settle({ err });
+ }
+ }
+
/** Everything the caller is still owed: on the wire, plus queued behind a full window. */
unfinished(): number {
return this.inFlight + this.waiting.size;
diff --git a/src/server.ts b/src/server.ts
index 2b9aefe..c90d9ca 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -1,15 +1,15 @@
-import type { BindType, CloseOptions, OnRequest } from './session-options.ts';
+import type { BindType, CloseOptions, OnRequest, OnSms } from './session-options.ts';
import type { PduObject, TlvInputs } from './pdu.ts';
import type { Result, VoidResult } from './result.ts';
import type { Server as NetServer, Socket } from 'node:net';
import type { Server as TlsServer, TlsOptions } from 'node:tls';
import type { SmppLog } from './log.ts';
import { EventEmitter } from 'node:events';
-import { Session, defaultSystemId } from './session.ts';
+import { Session } from './session.ts';
import { bindTypeFromCommand, checkSessionOptions } from './session-options.ts';
import { createServer as createNetServer } from 'node:net';
import { createServer as createTlsServer } from 'node:tls';
-import { defaultInterfaceVersion } from './defs/constants.ts';
+import { defaults } from './defaults.ts';
import { errorFrom } from './error-from.ts';
import { paramText } from './defs/types.ts';
import { guardedLog } from './log.ts';
@@ -35,6 +35,8 @@ export type ServerOptions = {
maxReassembly?: number;
/** First refusal on every request a bound peer sends. */
onRequest?: OnRequest;
+ /** Every message a bound peer submits, on any session; `sms.session` says which. */
+ onSms?: OnSms;
port?: number;
reassemblyTimeout?: number;
responseTimeout?: number;
@@ -49,13 +51,6 @@ export type ServerEvents = {
session: [Session];
};
-const defaults = {
- idleTimeout: 40_000,
- interfaceVersion: defaultInterfaceVersion,
- port: 2775,
- systemId: defaultSystemId,
-};
-
/** A listener may return a promise: an `async` one that rejects is routed like one that throws. */
type ServerListener<K extends keyof ServerEvents> = (...args: ServerEvents[K]) => unknown;
@@ -233,12 +228,14 @@ async function handleRequest(
function onConnection(sock: Socket, options: ServerOptions, server: SmppServer): void {
const log = guardedLog(options.log);
const session = new Session({
- idleTimeout: options.idleTimeout ?? defaults.idleTimeout,
+ idleTimeout: options.idleTimeout ?? defaults.serverIdleTimeout,
+ linkEnd: 'smsc',
log,
maxOutstanding: options.maxOutstanding,
maxOctets: options.maxOctets,
maxReassembly: options.maxReassembly,
onRequest: (bound, pduObj) => handleRequest(bound, pduObj, options),
+ onSms: options.onSms,
reassemblyTimeout: options.reassemblyTimeout,
responseTimeout: options.responseTimeout,
shutdownTimeout: options.shutdownTimeout,
@@ -246,7 +243,6 @@ function onConnection(sock: Socket, options: ServerOptions, server: SmppServer):
systemId: options.systemId ?? defaults.systemId,
});
- session.linkEnd = 'smsc';
server.sessions.add(session);
session.on('close', () => server.sessions.delete(session));
@@ -269,28 +265,11 @@ function createSecureListener(tlsOptions: TlsOptions, log: SmppLog): TlsServer {
return listener;
}
-/** A caller without types would otherwise reach a TypeError once per PDU rather than once here. */
-function checkHooks(options: ServerOptions): VoidResult {
- for (const name of ['authenticate', 'onRequest'] as const) {
- const hook: unknown = options[name];
-
- if (hook !== undefined && typeof hook !== 'function') {
- return { err: new Error(`${name} must be a function, got ${typeof hook}`) };
- }
- }
-
- return {};
-}
-
function checkOptions(options: ServerOptions, log: SmppLog, port: number): VoidResult {
const checked = checkSessionOptions(options);
if (checked.err) return { err: checked.err };
- const hooks = checkHooks(options);
-
- if (hooks.err) return hooks;
-
if (options.tls === true) {
log.warn('server - tls without a certificate', { port });
diff --git a/src/session-options.ts b/src/session-options.ts
index 0b768c9..86ffab7 100644
--- a/src/session-options.ts
+++ b/src/session-options.ts
@@ -1,29 +1,23 @@
import type { Dlr } from './dlr.ts';
-import type { MessageDlr } from './dlr-merger.ts';
import type { PduObject } from './pdu.ts';
import type { PduRefusedError } from './pdu-refusal.ts';
import type { Result, VoidResult } from './result.ts';
+import type { SendRespOptions, Sms } from './sms.ts';
import type { Session } from './session.ts';
import type { SmppLog } from './log.ts';
import type { SmsIdFormat } from './sms-id.ts';
-import type { Sms } from './sms.ts';
import type { Socket } from 'node:net';
-import { backoffDefaults } from './reconnect-loop.ts';
-import { defaultMaxOctets } from './reassembly.ts';
+import { defaults } from './defaults.ts';
import { isSmsIdNotation, smsIdNotations, smsIdPlaces } from './sms-id.ts';
import { namedValue } from './error-from.ts';
export type SessionEvents = {
close: [];
data: [Buffer];
- disconnected: [];
dlr: [Dlr, PduObject];
incomingPdu: [Buffer];
incomingPduObj: [PduObject];
- messageDlr: [MessageDlr];
- reconnected: [];
sessionError: [Error | PduRefusedError];
- sms: [Sms];
};
export const bindCommands: readonly string[] = [
@@ -86,28 +80,27 @@ export type CloseOptions = { signal?: AbortSignal | undefined };
export type OnRequest = (session: Session, pduObj: PduObject) => Promise<boolean> | boolean;
/**
- * How to come back after an unexpected disconnect. The session owns the retry loop; the caller
- * supplies how to open a socket and what to do once it is open (bind, for a client).
+ * Every inbound message. Returning answers it: nothing for `ESME_ROK` under `sms.smsId`, or what
+ * `sendResp()` takes. Throwing or rejecting refuses it with the status that has the peer retry.
*/
-export type ReconnectOptions = {
- connect: () => Promise<Result<{ sock: Socket }>>;
- maxDelay?: number | undefined;
- minDelay?: number | undefined;
- onConnected: (session: Session) => Promise<VoidResult>;
-};
+// A handler that returns nothing is the common case, and void is what such an arrow infers.
+// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
+export type OnSms = (sms: Sms) => Promise<SendRespOptions | void> | SendRespOptions | void;
export type SessionOptions = {
enquireLinkInterval?: number | undefined;
idleTimeout?: number | undefined;
+ /** Which end of the link this is; `server()` says `smsc`. Default `esme`. */
+ linkEnd?: LinkEnd | undefined;
log?: SmppLog | undefined;
maxOctets?: number | undefined;
maxOutstanding?: number | undefined;
maxReassembly?: number | undefined;
onRequest?: OnRequest | undefined;
+ onSms?: OnSms | undefined;
reassemblyTimeout?: number | undefined;
- reconnect?: ReconnectOptions | undefined;
responseTimeout?: number | undefined;
- /** How long a drain waits for the requests already on the wire. 0 waits forever. */
+ /** How long a drain waits for the handlers still running and the requests on the wire. 0 waits forever. */
shutdownTimeout?: number | undefined;
/** The notation the peer writes message ids in, where it is not the one they are compared in. */
smsIdFormat?: SmsIdFormat | undefined;
@@ -116,8 +109,6 @@ export type SessionOptions = {
systemId?: string | undefined;
};
-export const defaultSystemId = '';
-
/** SMPP 3.4: a peer that declares no version at all is one from before optional parameters. */
export const undeclaredInterfaceVersion = 0x00;
@@ -146,22 +137,6 @@ export function checkedBind(bindType: unknown, declaredVersion: unknown): Result
return { bind: { as: bindType, peerVersion: declaredVersion } };
}
-export const defaults = {
- /** Receipts of a multipart message can be a working day apart, so the cap does the bounding. */
- dlrMergeTimeout: 86_400_000,
- /** The peer gave up on an unanswered message long before this; the bound is against growth. */
- heldMessageTimeout: 300_000,
- maxDlrMerges: 1000,
- maxHeldMessages: 1000,
- maxHeldOctets: 64 * 1024 * 1024,
- maxOutstanding: 10,
- maxReassembly: 1000,
- reassemblyTimeout: 300_000,
- responseTimeout: 30_000,
- shutdownTimeout: 5000,
- systemId: defaultSystemId,
-};
-
/**
* A count below 1 does not fail loudly anywhere downstream: `maxOutstanding: 0` leaves every send
* queued behind a slot that is never freed, so a send with no `signal` never settles at all.
@@ -171,6 +146,10 @@ export function checkSessionOptions(options: CheckableOptions): VoidResult {
return { err: new Error('fromStart is part of the reconnect policy, spell it reconnect: { fromStart: true }') };
}
+ const hooks = checkHooks(options);
+
+ if (hooks.err) return hooks;
+
const connect = checkConnectTimeout(options.connectTimeout);
if (connect.err) return connect;
@@ -184,10 +163,23 @@ export function checkSessionOptions(options: CheckableOptions): VoidResult {
return backoff.err ? backoff : checkSmsIdFormat(options.smsIdFormat);
}
+/** A caller without types would otherwise reach a TypeError once per PDU rather than once here. */
+function checkHooks(options: CheckableOptions): VoidResult {
+ for (const name of ['authenticate', 'onRequest', 'onSms'] as const) {
+ const hook = options[name];
+
+ if (hook !== undefined && typeof hook !== 'function') {
+ return { err: new Error(`${name} must be a function, got ${typeof hook}`) };
+ }
+ }
+
+ return {};
+}
+
function limitsOf(options: CheckableOptions): [string, number, number][] {
return [
['idleTimeout', options.idleTimeout ?? 0, 0],
- ['maxOctets', options.maxOctets ?? defaultMaxOctets, 1],
+ ['maxOctets', options.maxOctets ?? defaults.maxReassemblyOctets, 1],
['maxOutstanding', options.maxOutstanding ?? defaults.maxOutstanding, 1],
['maxReassembly', options.maxReassembly ?? defaults.maxReassembly, 1],
['reassemblyTimeout', options.reassemblyTimeout ?? defaults.reassemblyTimeout, 0],
@@ -243,8 +235,8 @@ function checkReconnect(reconnect: unknown): VoidResult {
return { err: new Error(`reconnect.fromStart must be true or false, got ${typeof reconnect.fromStart}`) };
}
- const maxDelay = delayOr(reconnect.maxDelay, backoffDefaults.maxDelay);
- const minDelay = delayOr(reconnect.minDelay, backoffDefaults.minDelay);
+ const maxDelay = delayOr(reconnect.maxDelay, defaults.maxDelay);
+ const minDelay = delayOr(reconnect.minDelay, defaults.minDelay);
// A delay of 0 never doubles, so the backoff never starts and every retry lands at once.
const checked = checkLimits([['maxDelay', maxDelay, 1], ['minDelay', minDelay, 1]]);
@@ -292,6 +284,7 @@ function checkSmsIdFormat(smsIdFormat: unknown): VoidResult {
/** What the checker reads, as it arrives: a caller without types can put anything in it. */
export type CheckableOptions = {
+ authenticate?: unknown;
connectTimeout?: unknown;
/** Not an option: the one spelling is inside reconnect, and this is where the other is refused. */
fromStart?: unknown;
@@ -299,6 +292,8 @@ export type CheckableOptions = {
maxOctets?: number | undefined;
maxOutstanding?: number | undefined;
maxReassembly?: number | undefined;
+ onRequest?: unknown;
+ onSms?: unknown;
reassemblyTimeout?: number | undefined;
reconnect?: unknown;
responseTimeout?: number | undefined;
diff --git a/src/session.ts b/src/session.ts
index 1fd9b46..2e74da3 100644
--- a/src/session.ts
+++ b/src/session.ts
@@ -1,47 +1,44 @@
import type { ErrorName } from './defs/errors.ts';
-import type { MessageDlr } from './dlr-merger.ts';
import type { ParamValue } from './defs/types.ts';
import type { PduObject, PduObjectInput, TlvInputs } from './pdu.ts';
import type { PduRefusedError } from './pdu-refusal.ts';
-import type { BindType, CloseOptions, LinkEnd, ReconnectOptions, SendOptions, SessionBind, SessionEvents, SessionOptions } from './session-options.ts';
+import type { BindType, CloseOptions, LinkEnd, SendOptions, SessionBind, SessionEvents, SessionOptions } from './session-options.ts';
import type { Result, VoidResult } from './result.ts';
import type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
+import type { SessionPort } from './incoming-requests.ts';
import type { SmppLog } from './log.ts';
import type { Socket } from 'node:net';
-import { DlrMerger } from './dlr-merger.ts';
import { EventEmitter } from 'node:events';
import { IncomingRequests } from './incoming-requests.ts';
-import { LinkLife } from './link-life.ts';
import { LinkTimers } from './link-timers.ts';
import { OutgoingRequests } from './outgoing-requests.ts';
import { PduTransport } from './pdu-transport.ts';
-import { ReconnectLoop } from './reconnect-loop.ts';
+import { UnansweredError } from './unanswered-error.ts';
import { leftOf } from './idle-waiters.ts';
import { errorFrom } from './error-from.ts';
import { optionalParamsMinVersion } from './defs/constants.ts';
-import { bindCarries, bindCommands, checkedBind, defaultSystemId, defaults } from './session-options.ts';
+import { bindCarries, checkedBind } from './session-options.ts';
+import { defaults } from './defaults.ts';
import { isResp, objToPdu, pduReturn } from './pdu.ts';
import { refusalAnswer } from './pdu-refusal.ts';
import { guardedLog } from './log.ts';
import { submitSms, unsent } from './send-sms.ts';
import { ConcatReference } from './udh.ts';
-export type {
- CloseOptions,
- MessageDlr,
- ReconnectOptions,
- SendOptions,
- SendSmsOptions,
- SendSmsResult,
- SessionEvents,
- SessionOptions,
-};
-export type { BindType };
-export { bindCommands, defaultSystemId };
+/** A response carries the request's sequence number, which only sendReturn() has. */
+function misuse(input: PduObjectInput): Error | undefined {
+ return input.cmdName.endsWith('_resp')
+ ? new Error(`Use sendReturn() for responses, not send(): ${input.cmdName}`)
+ : undefined;
+}
/** A listener may return a promise: an `async` one that rejects is routed like one that throws. */
type SessionListener<K extends keyof SessionEvents> = (...args: SessionEvents[K]) => unknown;
+/**
+ * One socket's life as an SMPP session: it is bound once, ends once when the socket closes, and
+ * never comes back. `close()` and `unbind()` drain first; the peer dropping the link does not.
+ */
export class Session extends EventEmitter<SessionEvents> {
declare addListener: <K extends keyof SessionEvents>(event: K, listener: SessionListener<K>) => this;
declare off: <K extends keyof SessionEvents>(event: K, listener: SessionListener<K>) => this;
@@ -51,21 +48,23 @@ export class Session extends EventEmitter<SessionEvents> {
declare prependOnceListener: <K extends keyof SessionEvents>(event: K, listener: SessionListener<K>) => this;
declare removeListener: <K extends keyof SessionEvents>(event: K, listener: SessionListener<K>) => this;
+ /** Which end of the link this is: `server()` builds the `smsc` end. */
+ readonly linkEnd: LinkEnd;
readonly log: SmppLog;
-
- /** Which end of the link this is. `server()` sets it; a hand-wired SMSC must set it too. */
- linkEnd: LinkEnd = 'esme';
+ readonly sock: Socket;
userData: unknown = undefined;
private bind: SessionBind | undefined = undefined;
-
private readonly concatReference = new ConcatReference();
- private readonly dlrMerger: DlrMerger;
+ private readonly ended: Promise<void>;
+ private endedResolve: () => void = () => undefined;
private readonly incoming: IncomingRequests;
- private readonly link: LinkLife;
private readonly options: SessionOptions;
private readonly outgoing: OutgoingRequests;
- private readonly reconnectLoop: ReconnectLoop | undefined;
+ /** The socket closed: nothing goes out or comes in after this. */
+ private over = false;
+ /** close() or unbind() was called: no new sends from the application. */
+ private stopping = false;
private readonly timers: LinkTimers;
private readonly transport: PduTransport;
@@ -93,63 +92,47 @@ export class Session extends EventEmitter<SessionEvents> {
reason: unknown,
...args: [event: keyof SessionEvents, ...rest: unknown[]]
): void {
- const [event, ...rest] = args;
+ const [event] = args;
const error = errorFrom(reason);
this.log.error('session - a listener rejected', { event, message: error.message });
- if (event === 'sms') this.incoming.listenerRejected(rest[0]);
-
if (event !== 'sessionError') this.emit('sessionError', error);
}
constructor(options: SessionOptions) {
super({ captureRejections: true });
+ this.linkEnd = options.linkEnd ?? 'esme';
this.log = guardedLog(options.log);
this.options = options;
- this.dlrMerger = new DlrMerger({ log: this.log, max: defaults.maxDlrMerges, timeout: defaults.dlrMergeTimeout });
- this.reconnectLoop = this.loopFor(options.reconnect);
-
- const responseTimeout = options.responseTimeout ?? defaults.responseTimeout;
-
- this.link = new LinkLife({ log: this.log, reconnects: this.reconnectLoop !== undefined, timeout: responseTimeout });
+ this.sock = options.sock;
+ this.ended = new Promise(resolve => { this.endedResolve = resolve; });
this.timers = new LinkTimers({
enquireLinkInterval: options.enquireLinkInterval,
idleTimeout: options.idleTimeout,
log: this.log,
onEnquireLink: () => { void this.send({ cmdName: 'enquire_link' }); },
- // Not close(): a link that went quiet is a drop, and a drop is what reconnect is for.
- onIdle: () => { this.teardown(); },
+ onIdle: () => { this.sock.destroy(); },
});
this.transport = this.transportFor(options.sock);
this.outgoing = new OutgoingRequests({
- link: this.link,
log: this.log,
maxOutstanding: options.maxOutstanding ?? defaults.maxOutstanding,
- responseTimeout,
+ responseTimeout: options.responseTimeout ?? defaults.responseTimeout,
transport: this.transport,
});
- this.incoming = new IncomingRequests({
- dlrMerger: this.dlrMerger,
- link: this.link,
+ this.incoming = new IncomingRequests(this.port(), {
log: this.log,
maxOctets: options.maxOctets,
maxReassembly: options.maxReassembly,
onRequest: options.onRequest,
+ onSms: options.onSms,
reassemblyTimeout: options.reassemblyTimeout,
- sendPastDrain: input => this.outgoing.requestPastDrain(input, {}),
- session: this,
smsIdFormat: options.smsIdFormat,
systemId: options.systemId,
});
-
- this.resetTimers();
- }
-
- /** Replaced on reconnect, so hold the session rather than this. */
- get sock(): Socket {
- return this.transport.sock;
+ this.timers.reset();
}
/** The role the ESME bound with, whichever end of the link this is. Undefined before any bind. */
@@ -162,7 +145,12 @@ export class Session extends EventEmitter<SessionEvents> {
return this.bind?.peerVersion;
}
- /** Records a bind this link accepted or had accepted, until the next one. */
+ /** Whether the socket has closed. */
+ get closed(): boolean {
+ return this.over;
+ }
+
+ /** Records the bind this link accepted or had accepted. */
bound(bindType: string, declaredVersion: unknown): VoidResult {
const checked = checkedBind(bindType, declaredVersion);
@@ -183,6 +171,13 @@ export class Session extends EventEmitter<SessionEvents> {
/** Sends a request and resolves with the peer's response. */
send(input: PduObjectInput, options: SendOptions = {}): Promise<Result<{ pduObj: PduObject }>> {
+ // Ahead of the drain, so a misuse is named as one rather than blamed on the shutdown.
+ const wrong = misuse(input);
+
+ if (wrong) return Promise.resolve({ err: wrong });
+ if (this.over) return Promise.resolve({ err: new Error('Session is closed') });
+ if (this.stopping) return Promise.resolve({ err: new Error('Session is shutting down') });
+
return this.outgoing.request(input, options);
}
@@ -196,37 +191,17 @@ export class Session extends EventEmitter<SessionEvents> {
return Promise.resolve(this.answer(pduReturn(pdu, status, params, tlvs), pdu.cmdName, pdu.seqNr));
}
- private answer(built: Result<{ buffer: Buffer }>, cmdName: string, seqNr: number): VoidResult {
- const sent = built.err ? { err: built.err } : this.transport.write(built.buffer);
-
- // A peer that unbinds and drops the link takes our response with it; that is not a failure.
- if (sent.err && this.link.isAttached()) {
- this.log.warn('session - could not answer a request', {
- cmdName,
- message: sent.err.message,
- seqNr,
- });
- this.emit('sessionError', sent.err);
- }
-
- return sent;
- }
-
async sendSms(sms: SendSmsOptions, options: SendOptions = {}): Promise<SendSmsResult> {
if (!this.bindAllows('submit_sm')) {
return unsent(new Error('A receiver-bound session does not carry submit_sm'));
}
- const sent = await submitSms({
+ return submitSms({
log: this.log,
reference: this.concatReference.next(),
respIdNotation: this.options.smsIdFormat?.submitResp,
send: input => this.send(input, options),
}, sms);
-
- if (!sent.err && sms.dlr === true) this.dlrMerger.expect(sent.smsIds);
-
- return sent;
}
/**
@@ -235,25 +210,24 @@ export class Session extends EventEmitter<SessionEvents> {
*/
async unbind(): Promise<VoidResult> {
const drained = await this.drain(undefined);
- const wasOpen = this.link.isAttached();
- const sent = wasOpen
- ? await this.outgoing.requestOnCurrentLink({ cmdName: 'unbind' })
- : { err: new Error('Session is closed') };
- const closedOnUnbind = wasOpen && !this.link.isAttached();
+ const sent = this.over
+ ? { err: new Error('Session is closed') }
+ : await this.outgoing.request({ cmdName: 'unbind' }, {});
+ const droppedOnUnbind = sent.err instanceof UnansweredError && this.over;
- this.end();
+ await this.finish();
- return sent.err && !closedOnUnbind ? { err: sent.err } : drained;
+ return sent.err && !droppedOnUnbind ? { err: sent.err } : drained;
}
/**
- * Closes for good: refuses new sends, waits up to `shutdownTimeout` for the requests already sent
- * and the messages not yet answered, then tears down whatever is left. A session closed this way never reconnects.
+ * Closes for good: refuses new sends, waits up to `shutdownTimeout` for the handlers still
+ * running and the requests already sent, then destroys the socket.
*/
async close(options: CloseOptions = {}): Promise<VoidResult> {
const drained = await this.drain(options.signal);
- this.end();
+ await this.finish();
return drained;
}
@@ -261,138 +235,100 @@ export class Session extends EventEmitter<SessionEvents> {
private transportFor(sock: Socket): PduTransport {
return new PduTransport({
log: this.log,
- onClose: () => { this.onClose(); },
- onData: chunk => { this.onData(chunk); },
+ onClose: () => { this.end(); },
+ onData: chunk => {
+ this.emit('data', chunk);
+ this.timers.reset();
+ },
onError: err => { this.emit('sessionError', err); },
onFramed: pdu => { this.emit('incomingPdu', pdu); },
onPdu: pduObj => { this.dispatch(pduObj); },
onRefused: refused => { this.refuse(refused); },
onUnreadable: err => {
this.emit('sessionError', err);
- this.teardown();
+ this.sock.destroy();
},
}, sock);
}
- private loopFor(reconnect: ReconnectOptions | undefined): ReconnectLoop | undefined {
- if (!reconnect) return undefined;
-
- return new ReconnectLoop({
- connect: reconnect.connect,
- log: this.log,
- maxDelay: reconnect.maxDelay,
- minDelay: reconnect.minDelay,
- onConnected: sock => this.comeBackUp(sock, reconnect.onConnected),
- });
+ /** What the peer's requests may do to this session, and no more. */
+ private port(): SessionPort {
+ return {
+ acceptsOptionalParams: () => this.acceptsOptionalParams(),
+ answer: (pduObj, status, params, tlvs) => this.sendReturn(pduObj, status, params, tlvs),
+ bindAllows: cmdName => this.bindAllows(cmdName),
+ boundAs: () => this.boundAs,
+ closed: () => this.over,
+ end: () => {
+ this.stopping = true;
+ this.sock.destroy();
+ },
+ linkEnd: this.linkEnd,
+ onDlr: (dlr, pduObj) => { this.emit('dlr', dlr, pduObj); },
+ report: err => { this.emit('sessionError', err); },
+ request: input => this.outgoing.request(input, {}),
+ session: this,
+ };
}
- private async comeBackUp(
- sock: Socket,
- bind: (session: Session) => Promise<VoidResult>,
- ): Promise<VoidResult> {
- this.attach(sock);
-
- const bound = await bind(this);
-
- if (bound.err) {
- this.teardown();
-
- return { err: bound.err };
- }
-
- // close() can land while the rebind is in flight.
- if (!this.link.retrying()) {
- this.teardown();
+ private answer(built: Result<{ buffer: Buffer }>, cmdName: string, seqNr: number): VoidResult {
+ const sent = built.err ? { err: built.err } : this.transport.write(built.buffer);
- return { err: new Error('Session closed while it was coming back up') };
+ // A peer that unbinds and drops the link takes our response with it; that is not a failure.
+ if (sent.err && !this.over) {
+ this.log.warn('session - could not answer a request', {
+ cmdName,
+ message: sent.err.message,
+ seqNr,
+ });
+ this.emit('sessionError', sent.err);
}
- this.resetTimers();
- this.link.open();
- this.log.info('session - reconnected');
- this.emit('reconnected');
-
- return {};
- }
-
- private attach(sock: Socket): void {
- this.transport.attach(sock);
- this.link.attach();
+ return sent;
}
- /** Stops new sends and waits out the messages we hold and the requests already issued. */
+ /** Stops new sends and waits out the handlers still running and the requests already issued. */
private async drain(signal: AbortSignal | undefined): Promise<VoidResult> {
- this.stop();
+ this.stopping = true;
- // No bound link, so nothing is on the wire to wait out.
- if (!this.outgoing.canCarry()) return {};
+ if (this.over) return {};
const timeout = this.options.shutdownTimeout ?? defaults.shutdownTimeout;
const deadline = timeout > 0 ? Date.now() + timeout : 0;
// Answering a message can put a receipt on the wire; nothing on the wire produces a message.
- const messages = await this.incoming.drain(this.answering(timeout), signal);
+ const handlers = await this.incoming.drain(timeout, signal);
const requests = await this.outgoing.drain(leftOf(deadline), signal);
// The link went before the drain finished, so an empty window says nothing about the peer.
- if (!this.outgoing.canCarry()) {
- return { err: new Error('The session closed before the drain finished') };
- }
+ if (this.closed) return { err: new Error('The session closed before the drain finished') };
- if (!messages.err) return requests;
+ if (!handlers.err) return requests;
- if (!requests.err) return messages;
+ if (!requests.err) return handlers;
- return { err: new Error(`${messages.err.message}; ${requests.err.message}`) };
+ return { err: new Error(`${handlers.err.message}; ${requests.err.message}`) };
}
- /** The application half's budget, which may never be "forever": nothing else ends that wait. */
- private answering(timeout: number): number {
- if (timeout > 0) return timeout;
+ /** Destroys the socket and waits for its close, which is where the session ends. */
+ private async finish(): Promise<void> {
+ this.sock.destroy();
- const responseTimeout = this.options.responseTimeout ?? defaults.responseTimeout;
+ // A socket that closed before this session watched it never tells it so.
+ if (this.sock.closed) this.end();
- return responseTimeout > 0 ? responseTimeout : defaults.responseTimeout;
+ await this.ended;
}
- /** The session is over now, drained or not. Nothing brings it back. */
+ /** The socket is gone. Runs once, from its close event: nothing else ends a session. */
private end(): void {
- this.stop();
- this.teardown();
- this.dlrMerger.clear();
- this.emitClose();
- }
-
- /** No new sends, and no link after this one. */
- private stop(): void {
- this.link.stop();
- this.reconnectLoop?.stop();
- }
+ if (this.over) return;
- private emitClose(): void {
- if (!this.link.end()) return;
-
- this.outgoing.linkLost();
- this.emit('close');
- }
-
- private teardown(): void {
- const lost = this.link.drop();
-
- if (!lost) return;
-
- this.outgoing.linkLost();
+ this.over = true;
this.timers.clear();
- this.incoming.clear();
- this.sock.destroy();
-
- // `lost` is read before clear(): a listener it reaches may close() the session, and the drop still reports as disconnected.
- if (lost === 'disconnected') this.emit('disconnected');
- else this.emitClose();
- }
-
- private onData(chunk: Buffer): void {
- this.emit('data', chunk);
- this.resetTimers();
+ this.outgoing.end();
+ this.incoming.end();
+ this.emit('close');
+ this.endedResolve();
}
private dispatch(pduObj: PduObject): void {
@@ -405,7 +341,7 @@ export class Session extends EventEmitter<SessionEvents> {
}
this.emit('incomingPduObj', pduObj);
- // Every application hook and listener reached from an incoming PDU funnels through here.
+ // Every application hook reached from an incoming PDU funnels through here.
void this.incoming.handle(pduObj).catch((thrown: unknown) => {
const err = errorFrom(thrown);
@@ -433,21 +369,4 @@ export class Session extends EventEmitter<SessionEvents> {
this.answer(objToPdu({ ...refusalAnswer(refused), seqNr }), cmdName ?? String(cmdId), seqNr);
}
-
- private resetTimers(): void {
- if (!this.link.isAttached()) return;
-
- this.timers.reset();
- }
-
- private onClose(): void {
- if (this.link.retrying()) {
- this.teardown();
- this.reconnectLoop?.schedule();
-
- return;
- }
-
- this.end();
- }
}
diff --git a/src/sms.ts b/src/sms.ts
index 5149ceb..1b45466 100644
--- a/src/sms.ts
+++ b/src/sms.ts
@@ -1,5 +1,6 @@
import type { ErrorName } from './defs/errors.ts';
import type { MessageState } from './defs/constants.ts';
+import type { ParamValue } from './defs/types.ts';
import type { PduObject, PduObjectInput, TlvInputs } from './pdu.ts';
import type { Result, VoidResult } from './result.ts';
import type { Session } from './session.ts';
@@ -21,16 +22,14 @@ export type SendDlrResult = {
unanswered: number;
};
+/** How a message is answered: the id the peer correlates a receipt by, or the status refusing it. */
export type SendRespOptions = {
- /** The id the peer correlates a later delivery receipt by. Defaults to a generated UUID v7. */
+ /** Defaults to a generated UUID v7. */
smsId?: string;
status?: ErrorName;
};
-/**
- * A received SMS, and the handle for answering it. Multipart messages arrive as one Sms carrying
- * every segment's PDU.
- */
+/** A received SMS, and the handle for answering and reporting on it. */
export type Sms = {
/**
* Whether the peer was answered as the message's segments arrived, which is what a concatenated
@@ -43,16 +42,16 @@ export type Sms = {
from: string;
message: string;
pduObjs: PduObject[];
- /** Sends a delivery report back to the sender. Defaults to DELIVERED. */
+ /** Sends a delivery report back to the sender, once the message is answered. Defaults to DELIVERED. */
sendDlr: (status?: MessageState) => Promise<SendDlrResult>;
/**
- * Answers the message, and says the application is done with it. A concatenated message was
- * answered segment by segment as it arrived, so there it only releases a shutdown's wait and
- * refuses an `smsId` or a refusing `status`. Part of the protocol, not optional.
+ * Answers the message now, for a handler that keeps working after the answer. Returning from
+ * `onSms` answers whatever this has not. Writes once; after that an `smsId` or a refusing
+ * `status` is refused and a bare call is a no-op.
*/
sendResp: (options?: SendRespOptions) => Promise<VoidResult>;
session: Session;
- /** The id the segments were answered with, the id `sendResp()` was given, or a generated UUID v7. */
+ /** The id the message is answered with: the segments' base, the one `sendResp()` was given, or a generated UUID v7. */
readonly smsId: string;
submitTime: Date;
to: string;
@@ -65,20 +64,24 @@ export type SmsInput = {
session: Session;
};
-export type SmsHandlers = {
- answered: () => void;
- lostLink: () => boolean;
- send: (input: PduObjectInput) => Promise<Result<{ pduObj: PduObject }>>;
+/** What answering and reporting need from the session the message arrived on. */
+export type SmsDeps = {
+ acceptsOptionalParams: () => boolean;
+ answer: (pduObj: PduObject, status: ErrorName, params: Record<string, ParamValue>) => Promise<VoidResult>;
+ bindAllows: (cmdName: string) => boolean;
+ request: (input: PduObjectInput) => Promise<Result<{ pduObj: PduObject }>>;
};
+type Answer = { answered: boolean; smsId: string };
+
/** GSM 03.38 section 4 gives class 0 immediate display; every other class is stored somewhere. */
const immediateDisplayClass = 0;
-export function createSms(input: SmsInput, handlers: SmsHandlers): Sms {
+export function createSms(input: SmsInput, deps: SmsDeps): Sms {
const first = input.pduObjs[0];
const registered = first?.params.registered_delivery;
const dataCoding = first?.params.data_coding;
- const answered = { smsId: input.answeredAs ?? uuidv7() };
+ const answer: Answer = { answered: input.answeredAs !== undefined, smsId: input.answeredAs ?? uuidv7() };
const sms: Sms = {
answeredOnArrival: input.answeredAs !== undefined,
@@ -87,13 +90,11 @@ export function createSms(input: SmsInput, handlers: SmsHandlers): Sms {
from: paramText(first?.params.source_addr),
message: decodeSegments(input.pduObjs),
pduObjs: input.pduObjs,
- sendDlr: status => sendDlr(sms, input.session, handlers, status),
- sendResp: options => (input.answeredAs === undefined
- ? sendResp(sms, input.session, answered, options ?? {}, handlers)
- : answeredOnArrival(options ?? {}, handlers)),
+ sendDlr: status => sendDlr(sms, answer, deps, status),
+ sendResp: options => sendResp(sms, answer, deps, options ?? {}),
session: input.session,
get smsId(): string {
- return answered.smsId;
+ return answer.smsId;
},
submitTime: new Date(),
to: paramText(first?.params.destination_addr),
@@ -102,63 +103,44 @@ export function createSms(input: SmsInput, handlers: SmsHandlers): Sms {
return sms;
}
-/** Every segment went out answered, so the call is what the shutdown waits for and nothing else. */
-function answeredOnArrival(
- options: SendRespOptions,
- handlers: Pick<SmsHandlers, 'answered'>,
-): Promise<VoidResult> {
+function alreadyAnswered(sms: Sms, options: SendRespOptions): VoidResult {
if (options.smsId !== undefined) {
- return Promise.resolve({
- err: new Error('This message\'s id was fixed when its first segment arrived; read sms.smsId'),
- });
+ return {
+ err: new Error(sms.answeredOnArrival
+ ? 'This message\'s id was fixed when its first segment arrived; read sms.smsId'
+ : 'This message is already answered, under sms.smsId'),
+ };
}
if (options.status !== undefined && options.status !== 'ESME_ROK') {
- return Promise.resolve({
- err: new Error('Its segments were answered as they arrived, so there is nothing left to refuse; refuse a segment from the onRequest option instead'),
- });
+ return {
+ err: new Error(sms.answeredOnArrival
+ ? 'Its segments were answered as they arrived, so there is nothing left to refuse; refuse a segment from the onRequest option instead'
+ : 'This message is already answered, so there is nothing left to refuse'),
+ };
}
- handlers.answered();
-
- return Promise.resolve({});
+ return {};
}
-async function sendResp(
- sms: Sms,
- session: Session,
- answered: { smsId: string },
- options: SendRespOptions,
- handlers: Pick<SmsHandlers, 'answered' | 'lostLink'>,
-): Promise<VoidResult> {
- const total = sms.pduObjs.length;
+async function sendResp(sms: Sms, answer: Answer, deps: SmsDeps, options: SendRespOptions): Promise<VoidResult> {
+ if (answer.answered) return alreadyAnswered(sms, options);
- if (total === 0) {
- return { err: new Error('No PDUs to answer') };
- }
+ if (options.smsId === '') return { err: new Error('smsId must not be empty') };
- if (options.smsId === '') {
- return { err: new Error('smsId must not be empty') };
- }
-
- if (options.smsId !== undefined) answered.smsId = options.smsId;
+ const first = sms.pduObjs[0];
- // A response carries the sequence number it was asked on, which the next link knows nothing about.
- if (handlers.lostLink()) {
- return { err: new Error('The link this message arrived on is gone, so nothing would correlate the response') };
- }
+ if (!first) return { err: new Error('No PDUs to answer') };
- const results = await Promise.all(sms.pduObjs.map((pduObj, index) => session.sendReturn(
- pduObj,
- options.status ?? 'ESME_ROK',
- respIdParams(pduObj.cmdName, segmentId(answered.smsId, index, total)),
- )));
+ const smsId = options.smsId ?? answer.smsId;
+ const sent = await deps.answer(first, options.status ?? 'ESME_ROK', respIdParams(first.cmdName, smsId));
- const failure = results.find(result => result.err);
+ if (sent.err) return sent;
- if (!failure) handlers.answered();
+ answer.answered = true;
+ answer.smsId = smsId;
- return failure ?? {};
+ return {};
}
/** The receipt as text, which is all of it a peer below SMPP 3.4 is allowed to be sent. */
@@ -207,26 +189,31 @@ function collectReceipt(sent: Result<{ pduObj: PduObject }>[]): SendDlrResult {
return failure ? { err: failure, pduObjs, unanswered } : { pduObjs, unanswered };
}
+function unreported(err: Error): SendDlrResult {
+ return { err, pduObjs: [], unanswered: 0 };
+}
+
async function sendDlr(
sms: Sms,
- session: Session,
- handlers: Pick<SmsHandlers, 'send'>,
+ answer: Answer,
+ deps: SmsDeps,
status: MessageState = 'DELIVERED',
): Promise<SendDlrResult> {
- if (!session.bindAllows('deliver_sm')) {
- return {
- err: new Error('A transmitter-bound session does not carry deliver_sm'),
- pduObjs: [],
- unanswered: 0,
- };
+ if (!deps.bindAllows('deliver_sm')) {
+ return unreported(new Error('A transmitter-bound session does not carry deliver_sm'));
+ }
+
+ // A receipt names a message the peer had accepted, so it cannot precede the answer.
+ if (!answer.answered) {
+ return unreported(new Error('Answer the message before reporting on it: sendResp() first, or return from onSms and report later'));
}
const total = sms.pduObjs.length;
// Together, not one after a response: a drain waiting for this message must see the whole receipt.
const sent = await Promise.all(sms.pduObjs.map((_segment, index) => {
- const smsId = segmentId(sms.smsId, index, total);
+ const smsId = segmentId(answer.smsId, index, total);
- return handlers.send({
+ return deps.request({
cmdName: 'deliver_sm',
params: {
destination_addr: sms.from,
@@ -236,8 +223,9 @@ async function sendDlr(
short_message: receiptText(sms, smsId, status),
source_addr: sms.to,
},
- ...(session.acceptsOptionalParams() ? { tlvs: receiptTlvs(smsId, status) } : {}),
+ ...(deps.acceptsOptionalParams() ? { tlvs: receiptTlvs(smsId, status) } : {}),
});
}));
+
return collectReceipt(sent);
}
diff --git a/test/declared-alphabet.test.ts b/test/declared-alphabet.test.ts
index 8132883..089c7ff 100644
--- a/test/declared-alphabet.test.ts
+++ b/test/declared-alphabet.test.ts
@@ -80,8 +80,8 @@ function delivered(body: Buffer | string, dataCoding: number, esmClass: number):
describe('the alphabet a message declares is the one its octets are written in', () => {
test('sends GSM 03.38 under data_coding 0x00, the SMSC default alphabet', async t => {
const smsc = await dummySmsc(t, { messageIds: ['01a08779-de97-7caa-9d26-e6d50f5c4888'] });
- const session = await bindToSmsc(t, smsc.port, { reconnect: false });
- const sent = await session.sendSms({ from, message: bothTables, to });
+ const esme = await bindToSmsc(t, smsc.port, { reconnect: false });
+ const sent = await esme.sendSms({ from, message: bothTables, to });
assert.equal(sent.err, undefined);
assert.deepEqual(submitted(smsc.octets).map(declaredBy), [0x00]);
@@ -89,9 +89,9 @@ describe('the alphabet a message declares is the one its octets are written in',
test('keeps $ and @ for a peer that honours the declaration, where IA5 read STX and NUL', async t => {
const smsc = await dummySmsc(t, { messageIds: ['01a08779-de98-7d24-9542-e652e0d3761c'] });
- const session = await bindToSmsc(t, smsc.port, { reconnect: false });
+ const esme = await bindToSmsc(t, smsc.port, { reconnect: false });
- assert.equal((await session.sendSms({ from, message: bothTables, to })).err, undefined);
+ assert.equal((await esme.sendSms({ from, message: bothTables, to })).err, undefined);
const [pduObj] = submitted(smsc.octets);
@@ -112,38 +112,39 @@ describe('the alphabet a message declares is the one its octets are written in',
const smsc = await dummySmsc(t, {
messageIds: ['01a08779-de99-7fa5-bcac-18feef55aeee', '01a08779-de99-72ec-8dbb-9365a00158c3'],
});
- const session = await bindToSmsc(t, smsc.port, { reconnect: false });
+ const esme = await bindToSmsc(t, smsc.port, { reconnect: false });
- assert.equal((await session.sendSms({ encoding: 'LATIN1', from, message: 'Räksmörgås', to })).err, undefined);
- assert.equal((await session.sendSms({ from, message: 'あいう', to })).err, undefined);
+ assert.equal((await esme.sendSms({ encoding: 'LATIN1', from, message: 'Räksmörgås', to })).err, undefined);
+ assert.equal((await esme.sendSms({ from, message: 'あいう', to })).err, undefined);
assert.deepEqual(submitted(smsc.octets).map(declaredBy), [0x03, 0x08]);
});
// sendDlr() writes its body as a string with no data_coding, so it takes the detected branch too.
test('declares 0x00 on a receipt it writes itself', async t => {
- const { err, server: smpp } = await server({ port: 0 });
+ const { err, server: smpp } = await server({
+ onSms: async sms => {
+ await sms.sendResp();
+ await sms.sendDlr('DELIVERED');
+ },
+ port: 0,
+ });
assert.equal(err, undefined);
assert.ok(smpp);
closeAfter(t, smpp);
- smpp.on('session', peer => peer.on('sms', async sms => {
- await sms.sendResp();
- await sms.sendDlr('DELIVERED');
- }));
-
const connected = await client({ port: smpp.port, reconnect: false });
assert.equal(connected.err, undefined);
- assert.ok(connected.session);
- closeAfter(t, connected.session);
+ assert.ok(connected.client);
+ closeAfter(t, connected.client);
- const session = connected.session;
+ const esme = connected.client;
const reported = once<PduObject>(resolve => {
- session.on('dlr', (_report, pduObj) => { resolve(pduObj); });
+ esme.on('dlr', (_report, pduObj) => { resolve(pduObj); });
});
- assert.equal((await session.sendSms({ dlr: true, from, message: bothTables, to })).err, undefined);
+ assert.equal((await esme.sendSms({ dlr: true, from, message: bothTables, to })).err, undefined);
assert.equal(declaredBy(await reported), 0x00);
});
diff --git a/test/dummy-smsc.ts b/test/dummy-smsc.ts
index 7da2609..c1f0910 100644
--- a/test/dummy-smsc.ts
+++ b/test/dummy-smsc.ts
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import net from 'node:net';
-import type { Session } from '../src/session.ts';
+import type { SmppClient } from '../src/client.ts';
import type { TestContext } from 'node:test';
import { PduFramer } from '../src/pdu-framer.ts';
import { client } from '../src/client.ts';
@@ -93,12 +93,12 @@ export async function bindToSmsc(
t: TestContext,
port: number,
options: Parameters<typeof client>[0] = {},
-): Promise<Session> {
- const { err, session } = await client({ ...options, port });
+): Promise<SmppClient> {
+ const { err, client: esme } = await client({ ...options, port });
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
- return session;
+ return esme;
}
diff --git a/test/interop.test.ts b/test/interop.test.ts
index 2e5d905..c8e9134 100644
--- a/test/interop.test.ts
+++ b/test/interop.test.ts
@@ -230,14 +230,14 @@ describe('a live session against the reference implementation', () => {
await new Promise<void>(resolve => { refServer.listen(0, () => { resolve(); }); });
const port = refServer.address()?.port ?? 0;
- const { err, session } = await client({ port });
+ const { err, client: esme } = await client({ port });
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
t.after(() => new Promise<void>(resolve => { refServer.close(() => { resolve(); }); }));
- const sent = await session.sendSms({
+ const sent = await esme.sendSms({
from: 'MyBrand',
message: 'interop check',
to: '46709771337',
@@ -249,16 +249,14 @@ describe('a live session against the reference implementation', () => {
});
test('a reference client binds to our server and delivers an SMS', async t => {
- const { err: serverErr, server: smpp } = await server({ port: 0 });
+ const incoming: { resolve?: (sms: Sms) => void } = {};
+ const arrived = new Promise<Sms>(resolve => { incoming.resolve = resolve; });
+ const { err: serverErr, server: smpp } = await server({ onSms: sms => { incoming.resolve?.(sms); }, port: 0 });
assert.equal(serverErr, undefined);
assert.ok(smpp);
closeAfter(t, smpp);
- const incoming = new Promise<Sms>(resolve => {
- smpp.on('session', session => session.on('sms', resolve));
- });
-
const refSession = reference.connect({
url: `smpp://localhost:${String(smpp.port)}`,
});
@@ -275,10 +273,9 @@ describe('a live session against the reference implementation', () => {
source_addr: '46701113311',
});
- const sms = await incoming;
+ const sms = await arrived;
assert.equal(sms.from, '46701113311');
assert.equal(sms.message, 'from the reference client');
- await sms.sendResp();
});
});
diff --git a/test/message-class.test.ts b/test/message-class.test.ts
index e613139..c6714f1 100644
--- a/test/message-class.test.ts
+++ b/test/message-class.test.ts
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import test, { describe } from 'node:test';
import type { PduObjectInput } from '../src/pdu.ts';
import type { SendSmsDeps } from '../src/send-sms.ts';
-import type { Session } from '../src/session.ts';
+import type { SmppClient } from '../src/client.ts';
import type { Sms } from '../src/sms.ts';
import type { TestContext } from 'node:test';
import { bindToSmsc, dummySmsc } from './dummy-smsc.ts';
@@ -19,33 +19,27 @@ const from = '46701113311';
const to = '46709771337';
type MessagePeer = {
- /** Every message the server session was handed, in arrival order. */
+ esme: SmppClient;
+ /** Every message the server was handed, in arrival order. */
received: Sms[];
- session: Session;
};
/** A server that answers every message, and a client to write raw submit_sm PDUs at it. */
async function messagesInto(t: TestContext): Promise<MessagePeer> {
const received: Sms[] = [];
- const { err, server: smpp } = await server({ port: 0 });
+ const { err, server: smpp } = await server({ onSms: sms => { received.push(sms); }, port: 0 });
assert.equal(err, undefined);
assert.ok(smpp);
closeAfter(t, smpp);
- smpp.on('session', peer => peer.on('sms', sms => {
- received.push(sms);
-
- return sms.sendResp();
- }));
-
const connected = await client({ port: smpp.port, reconnect: false });
assert.equal(connected.err, undefined);
- assert.ok(connected.session);
- closeAfter(t, connected.session);
+ assert.ok(connected.client);
+ closeAfter(t, connected.client);
- return { received, session: connected.session };
+ return { esme: connected.client, received };
}
function dataCodingsOf(octets: Buffer[]): number[] {
@@ -116,7 +110,7 @@ describe('an inbound message', () => {
];
for (const [dataCoding] of codings) {
- const sent = await peer.session.send({
+ const sent = await peer.esme.send({
cmdName: 'submit_sm',
params: {
data_coding: dataCoding,
@@ -135,7 +129,7 @@ describe('an inbound message', () => {
test('keeps the alphabet its class group declares, so a flash UCS2 message still reads as UCS2', async t => {
const peer = await messagesInto(t);
- const sent = await peer.session.send({
+ const sent = await peer.esme.send({
cmdName: 'submit_sm',
params: {
data_coding: 0x18,
@@ -157,7 +151,7 @@ describe('an inbound message', () => {
describe('sendSms() flash', () => {
test('writes the message class into data_coding beside the alphabet, never over it', async t => {
const smsc = await dummySmsc(t);
- const session = await bindToSmsc(t, smsc.port, { reconnect: false });
+ const esme = await bindToSmsc(t, smsc.port, { reconnect: false });
const sends = [
{ from, message: 'Hello world', to },
{ flash: true, from, message: 'Hello world', to },
@@ -167,7 +161,7 @@ describe('sendSms() flash', () => {
] as const;
for (const send of sends) {
- assert.equal((await session.sendSms(send)).err, undefined, send.message);
+ assert.equal((await esme.sendSms(send)).err, undefined, send.message);
}
// 0.4.0 forced 0x10 whatever the alphabet was, which mangled every non-GSM flash message.
diff --git a/test/messaging-mode.test.ts b/test/messaging-mode.test.ts
index 4ac57ee..bfbe2ee 100644
--- a/test/messaging-mode.test.ts
+++ b/test/messaging-mode.test.ts
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import test, { describe } from 'node:test';
import type { PduObjectInput } from '../src/pdu.ts';
import type { SendSmsDeps } from '../src/send-sms.ts';
-import type { Session } from '../src/session.ts';
+import type { SmppClient } from '../src/client.ts';
import type { SubmitMessagingMode } from '../src/defs/constants.ts';
import type { TestContext } from 'node:test';
import { bindToSmsc, dummySmsc } from './dummy-smsc.ts';
@@ -31,14 +31,14 @@ const threeSegmentOctets = [
type BoundPeer = {
/** Every submit_sm the ESME wrote, exactly as it arrived on the socket. */
octets: Buffer[];
- session: Session;
+ esme: SmppClient;
};
async function boundToPeer(t: TestContext): Promise<BoundPeer> {
const smsc = await dummySmsc(t);
- const session = await bindToSmsc(t, smsc.port, { reconnect: false });
+ const esme = await bindToSmsc(t, smsc.port, { reconnect: false });
- return { octets: smsc.octets, session };
+ return { esme, octets: smsc.octets };
}
function hexOf(octets: Buffer[]): string[] {
@@ -71,7 +71,7 @@ function esmClassesOf(octets: Buffer[]): number[] {
describe('sendSms() with no messagingMode', () => {
test('writes a single-segment message as the octets it has always written', async t => {
const peer = await boundToPeer(t);
- const sent = await peer.session.sendSms({ from, message: 'Hello world', to });
+ const sent = await peer.esme.sendSms({ from, message: 'Hello world', to });
assert.equal(sent.err, undefined);
assert.deepEqual(hexOf(peer.octets), [singleSegmentOctets]);
@@ -79,7 +79,7 @@ describe('sendSms() with no messagingMode', () => {
test('writes every segment of a three-segment message as the octets it has always written', async t => {
const peer = await boundToPeer(t);
- const sent = await peer.session.sendSms({ from, message: longMessage, to });
+ const sent = await peer.esme.sendSms({ from, message: longMessage, to });
assert.equal(sent.err, undefined);
assert.equal(sent.smsIds.length, 3);
@@ -88,13 +88,13 @@ describe('sendSms() with no messagingMode', () => {
test('writes what SMSC_DEFAULT writes, octet for octet, single-segment and multipart alike', async t => {
const one = await boundToPeer(t);
- const single = await one.session.sendSms({ from, message: 'Hello world', messagingMode: 'SMSC_DEFAULT', to });
+ const single = await one.esme.sendSms({ from, message: 'Hello world', messagingMode: 'SMSC_DEFAULT', to });
assert.equal(single.err, undefined);
assert.deepEqual(hexOf(one.octets), [singleSegmentOctets]);
const many = await boundToPeer(t);
- const long = await many.session.sendSms({ from, message: longMessage, messagingMode: 'SMSC_DEFAULT', to });
+ const long = await many.esme.sendSms({ from, message: longMessage, messagingMode: 'SMSC_DEFAULT', to });
assert.equal(long.err, undefined);
assert.deepEqual(hexOf(many.octets), threeSegmentOctets);
@@ -111,8 +111,8 @@ describe('sendSms() messagingMode', () => {
for (const messagingMode of modes) {
const peer = await boundToPeer(t);
const bits = consts.MESSAGING_MODE[messagingMode];
- const single = await peer.session.sendSms({ from, message: 'Hello world', messagingMode, to });
- const long = await peer.session.sendSms({ from, message: longMessage, messagingMode, to });
+ const single = await peer.esme.sendSms({ from, message: 'Hello world', messagingMode, to });
+ const long = await peer.esme.sendSms({ from, message: longMessage, messagingMode, to });
assert.equal(single.err, undefined);
assert.equal(long.err, undefined);
diff --git a/test/operator-receipts.test.ts b/test/operator-receipts.test.ts
index 36dd655..9fefae0 100644
--- a/test/operator-receipts.test.ts
+++ b/test/operator-receipts.test.ts
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test, { describe } from 'node:test';
import type { Dlr, Receipt } from '../src/dlr.ts';
-import type { MessageDlr } from '../src/session.ts';
+import type { MessageDlr } from '../src/dlr-merger.ts';
import type { PduObject, TlvInputs } from '../src/pdu.ts';
import { bindToSmsc, dummySmsc } from './dummy-smsc.ts';
import { consts } from '../src/defs/constants.ts';
diff --git a/test/readme.test.ts b/test/readme.test.ts
index f2198a5..8ba2252 100644
--- a/test/readme.test.ts
+++ b/test/readme.test.ts
@@ -26,21 +26,20 @@ function once<T>(register: (resolve: (value: T) => void) => void): Promise<T> {
}
/** The README's examples listen on the documented default port, so one server runs at a time. */
-async function answeringServer(t: TestContext): Promise<SmppServer> {
- const { err, server: smpp } = await server();
-
- assert.equal(err, undefined);
- assert.ok(smpp);
- closeAfter(t, smpp);
-
- smpp.on('session', session => {
- session.on('sms', async sms => {
+async function answeringServer(t: TestContext, onSms: (sms: Sms) => void = () => undefined): Promise<SmppServer> {
+ const { err, server: smpp } = await server({
+ onSms: async sms => {
+ onSms(sms);
await sms.sendResp();
if (sms.dlr) await sms.sendDlr();
- });
+ },
});
+ assert.equal(err, undefined);
+ assert.ok(smpp);
+ closeAfter(t, smpp);
+
return smpp;
}
@@ -48,18 +47,18 @@ describe('README: Client', () => {
test('the simplest possible client', async t => {
await answeringServer(t);
- const { err, session } = await client();
+ const { err, client: smpp } = await client();
if (err) throw err;
- closeAfter(t, session);
+ closeAfter(t, smpp);
- await session.sendSms({
+ await smpp.sendSms({
from: '46701113311',
message: 'Hello world',
to: '46709771337',
});
- await session.unbind();
+ await smpp.unbind();
});
test('with connection parameters, a delivery report and logging', async t => {
@@ -73,7 +72,7 @@ describe('README: Client', () => {
warn: () => undefined,
};
- const { err, session } = await client({
+ const { err, client: smpp } = await client({
host: 'localhost',
log,
password: 'bar',
@@ -82,10 +81,10 @@ describe('README: Client', () => {
});
if (err) throw err;
- closeAfter(t, session);
+ closeAfter(t, smpp);
- const reported = once<Dlr>(resolve => { session.on('dlr', resolve); });
- const { err: sendErr, smsIds, unanswered } = await session.sendSms({
+ const reported = once<Dlr>(resolve => { smpp.on('dlr', resolve); });
+ const { err: sendErr, smsIds, unanswered } = await smpp.sendSms({
dlr: true,
from: '46701113311',
message: '«baff»',
@@ -101,13 +100,13 @@ describe('README: Client', () => {
test('naming the notation the SMSC writes message ids in', async t => {
await answeringServer(t);
- const { err, session } = await client({ smsIdFormat: { receipt: 'decimal', submitResp: 'hex' } });
+ const { err, client: smpp } = await client({ smsIdFormat: { receipt: 'decimal', submitResp: 'hex' } });
if (err) throw err;
- closeAfter(t, session);
+ closeAfter(t, smpp);
- const reported = once<Dlr>(resolve => { session.on('dlr', resolve); });
- const { err: sendErr, smsIds } = await session.sendSms({
+ const reported = once<Dlr>(resolve => { smpp.on('dlr', resolve); });
+ const { err: sendErr, smsIds } = await smpp.sendSms({
dlr: true,
from: '46701113311',
message: 'Hello world',
@@ -121,48 +120,52 @@ describe('README: Client', () => {
});
test('the documented sending options', async t => {
- const smpp = await answeringServer(t);
- const incoming = once<Sms>(resolve => {
- smpp.on('session', session => session.on('sms', resolve));
- });
- const { err, session } = await client();
+ let received: Sms | undefined;
+
+ await answeringServer(t, sms => { received = sms; });
+
+ const { err, client: smpp } = await client();
if (err) throw err;
- closeAfter(t, session);
+ closeAfter(t, smpp);
const { signal } = new AbortController();
- const [sms, sent] = await Promise.all([
- incoming,
- session.sendSms({
- dlr: true,
- encoding: 'UCS2',
- flash: false,
- from: 'MyBrand',
- message: 'Hello world',
- messagingMode: 'SMSC_DEFAULT',
- scheduleDeliveryTime: new Date(Date.now() + 3600_000),
- to: '46709771337',
- validityPeriod: 3600,
- }, { signal }),
- ]);
+ const sent = await smpp.sendSms({
+ dlr: true,
+ encoding: 'UCS2',
+ flash: false,
+ from: 'MyBrand',
+ message: 'Hello world',
+ messagingMode: 'SMSC_DEFAULT',
+ scheduleDeliveryTime: new Date(Date.now() + 3600_000),
+ to: '46709771337',
+ validityPeriod: 3600,
+ }, { signal });
assert.equal(sent.err, undefined);
- assert.equal(sms.from, 'MyBrand');
- assert.equal(sms.message, 'Hello world');
+ assert.ok(received);
+ assert.equal(received.from, 'MyBrand');
+ assert.equal(received.message, 'Hello world');
});
- test('receiving an inbound message on a client session', async t => {
+ test('receiving an inbound message on a client', async t => {
const smpp = await answeringServer(t);
- const bound = once<Session>(resolve => { smpp.on('session', resolve); });
- const { err, session } = await client();
+ const accepted = once<Session>(resolve => { smpp.on('session', resolve); });
+ const handed: { resolve?: (sms: Sms) => void } = {};
+ const incoming = once<Sms>(resolve => { handed.resolve = resolve; });
+ const { err, client: esme } = await client({
+ onSms: async sms => {
+ // sms.from, sms.to, sms.message
+ handed.resolve?.(sms);
+ await Promise.resolve();
+ },
+ });
if (err) throw err;
- closeAfter(t, session);
+ closeAfter(t, esme);
- const incoming = once<Sms>(resolve => { session.on('sms', resolve); });
- const peer = await bound;
-
- void peer.send({
+ const peer = await accepted;
+ const delivered = peer.send({
cmdName: 'deliver_sm',
params: {
destination_addr: '46709771337',
@@ -170,72 +173,60 @@ describe('README: Client', () => {
source_addr: '46701113311',
},
});
-
const sms = await incoming;
- await sms.sendResp();
-
assert.equal(sms.message, 'inbound hello');
+ assert.equal((await delivered).pduObj?.cmdStatus, 'ESME_ROK', 'returning answered it');
});
});
describe('README: Server', () => {
test('the simplest possible server', async t => {
- const { err, server: smpp } = await server();
- if (err) throw err;
-
- closeAfter(t, smpp);
-
const received: string[] = [];
-
- smpp.on('session', session => {
- session.on('sms', async sms => {
+ const { err, server: smpp } = await server({
+ onSms: async sms => {
+ // sms.from, sms.to, sms.message, sms.dlr; returning answers ESME_ROK
received.push(sms.message);
- await sms.sendResp();
- });
+ await Promise.resolve();
+ },
});
+ if (err) throw err;
- const { err: clientErr, session } = await client();
+ closeAfter(t, smpp);
+
+ const { err: clientErr, client: esme } = await client();
assert.equal(clientErr, undefined);
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
- await session.sendSms({ from: '46701113311', message: 'Hello world', to: '46709771337' });
- await session.unbind();
+ await esme.sendSms({ from: '46701113311', message: 'Hello world', to: '46709771337' });
+ await esme.unbind();
assert.deepEqual(received, ['Hello world']);
});
test('with authentication and delivery reports', async t => {
+ let answeredOnArrival: boolean | undefined;
const { err, server: smpp } = await server({
authenticate: ({ password, systemId }) => {
if (systemId !== 'foo' || password !== 'bar') return false;
return { userData: { userId: 123 } };
},
- });
- if (err) throw err;
-
- closeAfter(t, smpp);
-
- let answeredOnArrival: boolean | undefined;
-
- smpp.on('session', session => {
- session.on('sms', async sms => {
+ onSms: async sms => {
answeredOnArrival = sms.answeredOnArrival;
-
- if (sms.answeredOnArrival) {
- await sms.sendResp(); // multipart: only releases the shutdown drain
- } else {
- await sms.sendResp(); // ESME_ROK with a generated id
- }
+ // Answer now rather than on return, since a receipt can only follow the answer.
+ await sms.sendResp();
if (sms.dlr) {
await sms.sendDlr();
}
- });
+ },
});
+ if (err) throw err;
+
+ closeAfter(t, smpp);
assert.equal(smpp.port, 2775);
@@ -243,14 +234,14 @@ describe('README: Server', () => {
assert.ok(refused.err instanceof Error);
- const { err: clientErr, session } = await client({ password: 'bar', username: 'foo' });
+ const { err: clientErr, client: esme } = await client({ password: 'bar', username: 'foo' });
assert.equal(clientErr, undefined);
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
- const reported = once<Dlr>(resolve => { session.on('dlr', resolve); });
- const sent = await session.sendSms({
+ const reported = once<Dlr>(resolve => { esme.on('dlr', resolve); });
+ const sent = await esme.sendSms({
dlr: true,
from: '46701113311',
message: 'with a receipt',
@@ -260,10 +251,12 @@ describe('README: Server', () => {
assert.equal(sent.err, undefined);
assert.equal((await reported).statusMsg, 'DELIVERED');
assert.equal(answeredOnArrival, false);
+ assert.deepEqual([...smpp.sessions][0]?.userData, { userId: 123 });
});
test('refusing a segment at onRequest, before this library would answer it', async t => {
const knownRecipients = new Set(['46709771337']);
+ let messages = 0;
const { err, server: smpp } = await server({
onRequest: async (session, pduObj) => {
if (!isCommand(pduObj, 'submit_sm') || knownRecipients.has(pduObj.params.destination_addr)) {
@@ -274,21 +267,18 @@ describe('README: Server', () => {
return true;
},
+ onSms: () => { messages++; },
});
if (err) throw err;
closeAfter(t, smpp);
- let messages = 0;
-
- smpp.on('session', bound => bound.on('sms', () => { messages++; }));
-
const connected = await client();
if (connected.err) throw connected.err;
- closeAfter(t, connected.session);
+ closeAfter(t, connected.client);
- const sent = await connected.session.sendSms({
+ const sent = await connected.client.sendSms({
from: '46701113311',
message: 'A submission long enough to need more than one segment. '.repeat(4),
to: '46700000000',
@@ -302,19 +292,19 @@ describe('README: Server', () => {
describe('README: Errors', () => {
test('a refused connection reports err instead of throwing', async () => {
- const { err, session } = await client({ port: 1 });
+ const { err, client: smpp } = await client({ port: 1 });
assert.ok(err instanceof Error);
- assert.equal(session, undefined);
+ assert.equal(smpp, undefined);
});
test('telling a refused PDU from a session that failed', async t => {
const smpp = await answeringServer(t);
- const bound = once<Session>(resolve => { smpp.on('session', resolve); });
- const { err, session } = await client();
+ const accepted = once<Session>(resolve => { smpp.on('session', resolve); });
+ const { err, client: esme } = await client();
if (err) throw err;
- closeAfter(t, session);
+ closeAfter(t, esme);
const warned: Record<string, boolean | number | string>[] = [];
const log: SmppLog = {
@@ -325,7 +315,7 @@ describe('README: Errors', () => {
warn: (msg, metadata) => { warned.push({ msg, ...metadata }); },
};
- session.on('sessionError', err => {
+ esme.on('sessionError', err => {
if (err instanceof PduRefusedError) {
log.warn('the peer sent a PDU that could not be read', {
cmdName: err.header.cmdName ?? err.header.cmdId,
@@ -338,8 +328,8 @@ describe('README: Errors', () => {
log.error('a session failure or lost traffic', { message: err.message });
});
- const reported = once<Error>(resolve => { session.on('sessionError', resolve); });
- const peer = await bound;
+ const reported = once<Error>(resolve => { esme.on('sessionError', resolve); });
+ const peer = await accepted;
const { buffer } = objToPdu({ cmdName: 'enquire_link', seqNr: 5 });
assert.ok(buffer);
diff --git a/test/session-error.test.ts b/test/session-error.test.ts
index af9b3b9..198e6ad 100644
--- a/test/session-error.test.ts
+++ b/test/session-error.test.ts
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test, { describe } from 'node:test';
-import type { PduHeader, Session } from '../src/index.ts';
+import type { OnSms, PduHeader, Session } from '../src/index.ts';
import type { TestContext } from 'node:test';
import { PduRefusedError, client, server } from '../src/index.ts';
import { bareTlvHeader, shortened, truncatedTlv, withUnknownCmdId } from './raw-pdus.ts';
@@ -38,30 +38,30 @@ async function waitFor(condition: () => boolean, budget = 2000): Promise<boolean
}
/** A bound client, and the server session at the far end whose socket the tests write from. */
-async function linked(t: TestContext, options: Parameters<typeof client>[0] = {}) {
- const { err, server: smpp } = await server({ port: 0 });
+async function linked(t: TestContext, options: Parameters<typeof client>[0] = {}, onSms?: OnSms) {
+ const { err, server: smpp } = await server({ ...(onSms ? { onSms } : {}), port: 0 });
assert.equal(err, undefined);
assert.ok(smpp);
closeAfter(t, smpp);
const accepted = once<Session>(resolve => { smpp.on('session', resolve); });
- const { session } = await client({ port: smpp.port, ...options });
+ const { client: esme } = await client({ port: smpp.port, ...options });
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
const peer = await raceWithin(2000, accepted);
assert.ok(peer, 'the server never accepted a session');
- return { peer, session };
+ return { esme, peer };
}
describe('telling a refused PDU from a failed session', () => {
test('narrows a refusal to its class, command and reason, from the entry point alone', async t => {
- const { peer, session } = await linked(t);
- const failed = once<Error>(resolve => { session.on('sessionError', resolve); });
+ const { esme, peer } = await linked(t);
+ const failed = once<Error>(resolve => { esme.on('sessionError', resolve); });
peer.sock.write(truncatedTlv({ cmdName: 'deliver_sm', params: receipt, seqNr: 5 }));
@@ -77,10 +77,10 @@ describe('telling a refused PDU from a failed session', () => {
});
test('names which part of the PDU it could not read, one refusal at a time', async t => {
- const { peer, session } = await linked(t);
+ const { esme, peer } = await linked(t);
const seen: Error[] = [];
- session.on('sessionError', err => { seen.push(err); });
+ esme.on('sessionError', err => { seen.push(err); });
peer.sock.write(withUnknownCmdId({ cmdName: 'enquire_link', seqNr: 9 }));
peer.sock.write(shortened({ cmdName: 'deliver_sm', params: receipt, seqNr: 6 }, 3));
@@ -94,24 +94,24 @@ describe('telling a refused PDU from a failed session', () => {
);
});
- test('reports a listener that threw as an error that is no refusal', async t => {
- const { peer, session } = await linked(t, { responseTimeout: 200 });
+ test('reports a handler that threw as an error that is no refusal', async t => {
+ const { esme, peer } = await linked(t, { responseTimeout: 200 }, () => { throw new Error('handler exploded'); });
const failed = once<Error>(resolve => { peer.on('sessionError', resolve); });
- peer.on('sms', () => { throw new Error('listener exploded'); });
+ const sent = await esme.sendSms({ from: '46701113311', message: 'blows the handler up', to: '46709771337' });
- await session.sendSms({ from: '46701113311', message: 'blows the listener up', to: '46709771337' });
+ assert.match(sent.err?.message ?? '', /ESME_RTHROTTLED/, 'a message the handler failed on is refused');
const reported = await raceWithin(2000, failed);
- assert.ok(reported, 'the listener that threw never reached the session');
+ assert.ok(reported, 'the handler that threw never reached the session');
assert.ok(!(reported instanceof PduRefusedError), 'a session failure is not a refused PDU');
- assert.equal(reported.message, 'listener exploded');
+ assert.equal(reported.message, 'handler exploded');
});
test('reports a socket the peer reset as an error that is no refusal', async t => {
- const { peer, session } = await linked(t, { reconnect: false });
- const failed = once<Error>(resolve => { session.on('sessionError', resolve); });
+ const { esme, peer } = await linked(t, { reconnect: false });
+ const failed = once<Error>(resolve => { esme.on('sessionError', resolve); });
peer.sock.resetAndDestroy();
diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts
index 6b36279..cfafd76 100644
--- a/test/session-extras.test.ts
+++ b/test/session-extras.test.ts
@@ -4,35 +4,37 @@ import test, { describe } from 'node:test';
import type { Collected, LostGroup } from '../src/reassembly.ts';
import type { Dlr } from '../src/dlr.ts';
import type { ErrorName } from '../src/defs/errors.ts';
-import type { IncomingRequestsOptions } from '../src/incoming-requests.ts';
-import type { HeldMessagesOptions, MessageHold } from '../src/held-messages.ts';
+import type { MessageDlr } from '../src/dlr-merger.ts';
import type { MessageState } from '../src/defs/constants.ts';
-import type { MessageDlr } from '../src/session.ts';
+import type { OnSms } from '../src/session-options.ts';
import type { PduObject, PduObjectInput } from '../src/pdu.ts';
import type { Result } from '../src/result.ts';
import type { SendSmsResult } from '../src/send-sms.ts';
+import type { SmppClient } from '../src/client.ts';
import type { SmppLog } from '../src/log.ts';
-import type { Sms } from '../src/sms.ts';
+import type { Sms, SmsDeps } from '../src/sms.ts';
import type { SmppServer } from '../src/server.ts';
import type { TestContext } from 'node:test';
-import { HeldMessages } from '../src/held-messages.ts';
-import { IncomingRequests, refusedSegmentStatus } from '../src/incoming-requests.ts';
+import { ReconnectLoop } from '../src/reconnect-loop.ts';
+import { RunningHandlers } from '../src/running-handlers.ts';
import { UnansweredError } from '../src/unanswered-error.ts';
import { createSms } from '../src/sms.ts';
-import { LinkLife } from '../src/link-life.ts';
+import { refusedSegmentStatus } from '../src/incoming-requests.ts';
import { SendWindow } from '../src/send-window.ts';
import { Reassembler, decodeSegments } from '../src/reassembly.ts';
import { Session } from '../src/session.ts';
import { DlrMerger } from '../src/dlr-merger.ts';
import { PduRefusedError } from '../src/pdu-refusal.ts';
import { objToPdu } from '../src/pdu.ts';
-import { checkSessionOptions, defaults, standsInFor } from '../src/session-options.ts';
+import { checkSessionOptions, standsInFor } from '../src/session-options.ts';
import { client } from '../src/client.ts';
import { closeAfter, closeListenerAfter } from './teardown.ts';
import { concatOf } from '../src/concat.ts';
import { consts } from '../src/defs/constants.ts';
+import { defaults } from '../src/defaults.ts';
import { errors } from '../src/defs/errors.ts';
import { paramNumber, paramText } from '../src/defs/types.ts';
+import { pduBytes } from './raw-pdus.ts';
import { server } from '../src/server.ts';
import { silentLog } from '../src/log.ts';
import { splitMessage } from '../src/message.ts';
@@ -58,11 +60,25 @@ async function connect(
) {
const connected = await client({ port: smpp.port, ...options });
- if (connected.session) closeAfter(t, connected.session);
+ if (connected.client) closeAfter(t, connected.client);
return connected;
}
+/** A bound client, asserted, since nearly every test here starts from one. */
+async function bound(
+ t: TestContext,
+ smpp: SmppServer,
+ options: Parameters<typeof client>[0] = {},
+): Promise<SmppClient> {
+ const { err, client: esme } = await connect(t, smpp, options);
+
+ assert.equal(err, undefined);
+ assert.ok(esme);
+
+ return esme;
+}
+
/** An event that never fires would otherwise block until the CI job limit, asserting nothing. */
function once<T>(register: (resolve: (value: T) => void) => void): Promise<T> {
return new Promise<T>((resolve, reject) => {
@@ -81,6 +97,19 @@ function delay(ms: number): Promise<void> {
return new Promise(resolve => { setTimeout(resolve, ms); });
}
+/** Polls until the condition holds; false means it never did within the budget. */
+async function waitFor(condition: () => boolean, budget = 2000): Promise<boolean> {
+ const deadline = Date.now() + budget;
+
+ while (!condition()) {
+ if (Date.now() > deadline) return false;
+
+ await delay(5);
+ }
+
+ return true;
+}
+
/** Undefined where the promise never settled, which is an assertion rather than a hung run. */
function within<T>(ms: number, promise: Promise<T>): Promise<T | undefined> {
return Promise.race([promise, delay(ms).then((): undefined => undefined)]);
@@ -95,20 +124,9 @@ function abortAfter(
t.after(async () => {
controller.abort();
- const { session } = await connecting;
-
- await session?.close({ signal: AbortSignal.abort() });
- });
-}
+ const { client: esme } = await connecting;
-function incomingOn(session: Session, options: Partial<IncomingRequestsOptions> = {}): IncomingRequests {
- return new IncomingRequests({
- dlrMerger: new DlrMerger({ log: silentLog, max: 10, timeout: 10_000 }),
- link: new LinkLife({ log: silentLog, reconnects: false, timeout: 100 }),
- log: silentLog,
- sendPastDrain: () => Promise.resolve({ err: new Error('never sent') }),
- session,
- ...options,
+ await esme?.close({ signal: AbortSignal.abort() });
});
}
@@ -139,6 +157,15 @@ function peerOf(smpp: SmppServer): Session {
return peer;
}
+/** The client's bound session, asserted. */
+function linkOf(esme: SmppClient): Session {
+ const session = esme.session;
+
+ assert.ok(session, 'the client has no bound session');
+
+ return session;
+}
+
async function sendReceipt(peer: Session, smsId: string, tlvSmsId = smsId): Promise<void> {
const sent = await peer.send({
cmdName: 'deliver_sm',
@@ -167,89 +194,93 @@ function latch(): Latch {
return { open: () => opener.open?.(), passed };
}
-describe('merged delivery reports', () => {
- // 0.4.0 allocated a longSmsDlrs store to do exactly this and then never used it.
- test('reports once on a whole multipart message', async t => {
- const smpp = await startServer(t);
- const incoming = once<Sms>(resolve => {
- smpp.on('session', session => session.on('sms', resolve));
- });
- const { session } = await connect(t, smpp);
-
- assert.ok(session);
-
- const merged = once<MessageDlr>(resolve => { session.on('messageDlr', resolve); });
- const perSegment: string[] = [];
-
- session.on('dlr', dlr => perSegment.push(dlr.smsId ?? ''));
-
- const [sms] = await Promise.all([
- incoming.then(async received => {
- await received.sendResp();
-
- return received;
- }),
- session.sendSms({
- dlr: true,
- from: '46701113311',
- message: 'x'.repeat(400),
- to: '46709771337',
- }),
- ]);
-
- await sms.sendDlr();
-
- const report = await merged;
+/** A handler that never returns: the message stays with the application for the test's life. */
+function neverReturns(): Promise<void> {
+ return new Promise(() => undefined);
+}
- assert.equal(report.smsId, sms.smsId);
- assert.equal(report.segments.length, 3);
- assert.equal(report.statusMsg, 'DELIVERED');
- assert.deepEqual(perSegment, [1, 2, 3].map(part => `${sms.smsId}-${String(part)}`));
+/** A handler that hands the message to the test and keeps running until the test lets it go. */
+function handedOver(): { onSms: OnSms; release: () => void; sms: Promise<Sms> } {
+ const released = latch();
+ const handed: { resolve?: (sms: Sms) => void } = {};
+ const sms = once<Sms>(resolve => {
+ handed.resolve = resolve;
});
- test('reports once, on the final receipts, when the peer reports en route first', async t => {
- const smpp = await startServer(t);
- const incoming = once<Sms>(resolve => {
- smpp.on('session', session => session.on('sms', resolve));
- });
- const { session } = await connect(t, smpp);
+ return {
+ onSms: async received => {
+ handed.resolve?.(received);
+ await released.passed;
+ },
+ release: released.open,
+ sms,
+ };
+}
+
+/** A message reaching a bare session as the socket would deliver it: through its data event. */
+function arrives(session: Session, input: PduObjectInput): void {
+ session.sock.emit('data', pduBytes(input));
+}
- assert.ok(session);
+/** What sendResp() and sendDlr() need, answered by the test. */
+function smsDeps(overrides: Partial<SmsDeps> = {}): SmsDeps {
+ return {
+ acceptsOptionalParams: () => true,
+ answer: () => Promise.resolve({}),
+ bindAllows: () => true,
+ request: () => Promise.resolve({ err: new Error('never sent') }),
+ ...overrides,
+ };
+}
- const merged = once<MessageDlr>(resolve => { session.on('messageDlr', resolve); });
+describe('merged delivery reports', () => {
+ async function reported(t: TestContext, status: MessageState[]): Promise<{ esme: SmppClient; reports: Dlr[]; markers: number[]; merged: Promise<MessageDlr>; sms: Sms }> {
+ const handed = handedOver();
+ const smpp = await startServer(t, { onSms: handed.onSms });
+ const esme = await bound(t, smpp);
+ const merged = once<MessageDlr>(resolve => { esme.on('messageDlr', resolve); });
const reports: Dlr[] = [];
- const markers: (number | undefined)[] = [];
+ const markers: number[] = [];
- session.on('dlr', (dlr, pduObj) => {
+ esme.on('dlr', (dlr, pduObj) => {
reports.push(dlr);
markers.push(paramNumber(pduObj.params.esm_class, 0));
});
- const [sms] = await Promise.all([
- incoming.then(async received => {
- await received.sendResp();
+ const sent = esme.sendSms({ dlr: true, from: '46701113311', message: 'x'.repeat(400), to: '46709771337' });
+ const sms = await handed.sms;
+
+ await sms.sendResp();
+ handed.release();
+ await sent;
- return received;
- }),
- session.sendSms({
- dlr: true,
- from: '46701113311',
- message: 'x'.repeat(400),
- to: '46709771337',
- }),
- ]);
+ for (const one of status) {
+ await sms.sendDlr(one);
+ }
- await sms.sendDlr('ENROUTE');
- await sms.sendDlr('DELIVERED');
+ return { esme, markers, merged, reports, sms };
+ }
+ // 0.4.0 allocated a longSmsDlrs store to do exactly this and then never used it.
+ test('reports once on a whole multipart message', async t => {
+ const { merged, reports, sms } = await reported(t, ['DELIVERED']);
const report = await merged;
assert.equal(report.smsId, sms.smsId);
- assert.equal(report.statusMsg, 'DELIVERED');
assert.equal(report.segments.length, 3);
+ assert.equal(report.statusMsg, 'DELIVERED');
+ assert.deepEqual(reports.map(one => one.smsId), [1, 2, 3].map(part => `${sms.smsId}-${String(part)}`));
+ });
+
+ test('reports once, on the final receipts, when the peer reports en route first', async t => {
+ const { markers, merged, reports, sms } = await reported(t, ['ENROUTE', 'DELIVERED']);
+ const report = await merged;
const notification = consts.ESM_CLASS.INTERMEDIATE_DELIVERY;
const receipt = consts.ESM_CLASS.MC_DELIVERY_RECEIPT;
+ assert.equal(report.smsId, sms.smsId);
+ assert.equal(report.statusMsg, 'DELIVERED');
+ assert.equal(report.segments.length, 3);
assert.deepEqual(reports.map(one => one.intermediate), [true, true, true, false, false, false]);
assert.deepEqual(
markers,
@@ -264,32 +295,7 @@ describe('merged delivery reports', () => {
});
test('reports the worst status across the segments', async t => {
- const smpp = await startServer(t);
- const incoming = once<Sms>(resolve => {
- smpp.on('session', session => session.on('sms', resolve));
- });
- const { session } = await connect(t, smpp);
-
- assert.ok(session);
-
- const merged = once<MessageDlr>(resolve => { session.on('messageDlr', resolve); });
-
- const [sms] = await Promise.all([
- incoming.then(async received => {
- await received.sendResp();
-
- return received;
- }),
- session.sendSms({
- dlr: true,
- from: '46701113311',
- message: 'x'.repeat(400),
- to: '46709771337',
- }),
- ]);
-
- await sms.sendDlr('UNDELIVERABLE');
-
+ const { merged } = await reported(t, ['UNDELIVERABLE']);
const report = await merged;
assert.equal(report.statusMsg, 'UNDELIVERABLE');
@@ -378,18 +384,9 @@ describe('sendSms()', () => {
}
test('reports a submit_sm the peer refused instead of an empty message id', async t => {
- const smpp = await startServer(t);
- const incoming = once<Sms>(resolve => {
- smpp.on('session', session => session.on('sms', resolve));
- });
- const { session } = await connect(t, smpp);
-
- assert.ok(session);
-
- const [, sent] = await Promise.all([
- incoming.then(received => received.sendResp({ status: 'ESME_RMSGQFUL' })),
- session.sendSms({ from: '46701113311', message: 'the queue is full', to: '46709771337' }),
- ]);
+ const smpp = await startServer(t, { onSms: () => ({ status: 'ESME_RMSGQFUL' }) });
+ const esme = await bound(t, smpp);
+ const sent = await esme.sendSms({ from: '46701113311', message: 'the queue is full', to: '46709771337' });
assert.ok(sent.err instanceof Error);
assert.match(sent.err.message, /ESME_RMSGQFUL/);
@@ -471,83 +468,73 @@ describe('sendSms()', () => {
});
describe('reconnect', () => {
- test('re-binds after a drop with nothing asked for, since it is the default', async t => {
+ test('opens a new session after a drop with nothing asked for, since it is the default', async t => {
const smpp = await startServer(t);
- const { session } = await connect(t, smpp);
-
- assert.ok(session);
-
- const reconnected = once<true>(resolve => { session.on('reconnected', () => { resolve(true); }); });
-
- const dropped = session.sock;
+ const esme = await bound(t, smpp);
+ const reconnected = once<true>(resolve => { esme.on('reconnected', () => { resolve(true); }); });
+ const dropped = linkOf(esme);
await peerOf(smpp).close();
await reconnected;
- assert.notEqual(session.sock, dropped, 'the session should be live on a fresh socket');
+ assert.equal(dropped.closed, true, 'a session is one socket\'s life');
+ assert.notEqual(linkOf(esme), dropped, 'the client is live on a fresh session');
});
test('reports a drop it will retry as disconnected, keeping close for the end', async t => {
const smpp = await startServer(t);
- const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } });
-
- assert.ok(session);
-
+ const esme = await bound(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } });
const events: string[] = [];
- session.on('close', () => { events.push('close'); });
- session.on('disconnected', () => { events.push('disconnected'); });
+ esme.on('close', () => { events.push('close'); });
+ esme.on('disconnected', () => { events.push('disconnected'); });
- const reconnected = once<true>(resolve => { session.on('reconnected', () => { resolve(true); }); });
+ const reconnected = once<true>(resolve => { esme.on('reconnected', () => { resolve(true); }); });
await peerOf(smpp).close();
await reconnected;
assert.deepEqual(events, ['disconnected'], 'a link the loop brings back is not the end');
- await session.close();
+ await esme.close();
assert.deepEqual(events, ['disconnected', 'close']);
});
- test('emits close when the session ends while the link is still down', async t => {
+ test('emits close when the client ends while the link is still down', async t => {
const smpp = await startServer(t);
- const { session } = await connect(t, smpp);
-
- assert.ok(session);
-
+ const esme = await bound(t, smpp);
const events: string[] = [];
- session.on('close', () => { events.push('close'); });
- session.on('disconnected', () => { events.push('disconnected'); });
+ esme.on('close', () => { events.push('close'); });
+ esme.on('disconnected', () => { events.push('disconnected'); });
- const down = once<true>(resolve => { session.on('disconnected', () => { resolve(true); }); });
+ const down = once<true>(resolve => { esme.on('disconnected', () => { resolve(true); }); });
await peerOf(smpp).close();
await down;
- await session.close();
+
+ assert.equal(esme.session, undefined, 'no session while the link is down');
+ await esme.close();
assert.deepEqual(events, ['disconnected', 'close']);
- await session.close();
+ await esme.close();
assert.deepEqual(events, ['disconnected', 'close'], 'closing twice is still one close');
});
- test('re-binds after a stream it cannot read, rather than ending the session', async t => {
+ test('re-binds after a stream it cannot read, rather than ending the client', async t => {
const smpp = await startServer(t);
- const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } });
-
- assert.ok(session);
-
+ const esme = await bound(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } });
const events: string[] = [];
- session.on('close', () => { events.push('close'); });
- session.on('sessionError', err => {
+ esme.on('close', () => { events.push('close'); });
+ esme.on('sessionError', err => {
events.push(err instanceof PduRefusedError ? 'refused' : 'sessionError');
});
- const reconnected = once<true>(resolve => { session.on('reconnected', () => { resolve(true); }); });
+ const reconnected = once<true>(resolve => { esme.on('reconnected', () => { resolve(true); }); });
peerOf(smpp).sock.write(unreadablePdu);
await reconnected;
@@ -584,15 +571,12 @@ describe('reconnect', () => {
verbose: noop,
warn: noop,
};
- const { session } = await connect(t, smpp, { log, reconnect: false });
-
- assert.ok(session);
-
+ const esme = await bound(t, smpp, { log, reconnect: false });
let disconnects = 0;
- session.on('disconnected', () => { disconnects++; });
+ esme.on('disconnected', () => { disconnects++; });
- const closed = once<true>(resolve => { session.on('close', () => { resolve(true); }); });
+ const closed = once<true>(resolve => { esme.on('close', () => { resolve(true); }); });
peerOf(smpp).sock.write(unreadablePdu);
await closed;
@@ -601,29 +585,12 @@ describe('reconnect', () => {
assert.ok(!infos.includes('reconnect - retrying'));
});
- test('re-binds after the connection drops, keeping the same session object', async t => {
- const smpp = await startServer(t);
+ test('re-binds after the connection drops, keeping the same client handle', async t => {
+ const smpp = await startServer(t, { onSms: sms => { messages.push(sms.message); } });
const messages: string[] = [];
-
- // Registered up front so the session created by the reconnect is covered too.
- smpp.on('session', bound => {
- bound.on('sms', sms => {
- messages.push(sms.message);
- void sms.sendResp();
- });
- });
-
- const { err, session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } });
-
- assert.equal(err, undefined);
- assert.ok(session);
-
- const reconnected = once<true>(resolve => { session.on('reconnected', () => { resolve(true); }); });
- const halfPdu = once<true>(resolve => { session.on('data', () => { resolve(true); }); });
- const boundBefore = [session.boundAs, session.peerInterfaceVersion];
- const whileDown = once<unknown[]>(resolve => {
- session.on('disconnected', () => { resolve([session.boundAs, session.peerInterfaceVersion]); });
- });
+ const esme = await bound(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } });
+ const reconnected = once<true>(resolve => { esme.on('reconnected', () => { resolve(true); }); });
+ const halfPdu = once<true>(resolve => { linkOf(esme).on('data', () => { resolve(true); }); });
// A PDU header promising 32 octets and sending 8: the next link must not continue it.
peerOf(smpp).sock.write(Buffer.from([0, 0, 0, 32, 0, 0, 0, 4]));
@@ -636,12 +603,10 @@ describe('reconnect', () => {
await reconnected;
- // The loop rebinds as before, so the gap keeps answering bindAllows() for the bind to come.
- assert.deepEqual(await whileDown, boundBefore);
- assert.equal(session.boundAs, 'transceiver');
+ assert.equal(linkOf(esme).boundAs, 'transceiver');
- // The session object survives the drop, so listeners stay attached and it is usable again.
- const sent = await session.sendSms({
+ // The client handle survives the drop, so listeners stay attached and it is usable again.
+ const sent = await esme.sendSms({
from: '46701113311',
message: 'after reconnect',
to: '46709771337',
@@ -652,36 +617,32 @@ describe('reconnect', () => {
});
test('merges the receipts of a multipart message across a drop', async t => {
- const smpp = await startServer(t);
- const incoming = once<Sms>(resolve => {
- smpp.on('session', bound => bound.on('sms', sms => {
- resolve(sms);
- void sms.sendResp();
- }));
- });
- const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } });
-
- assert.ok(session);
-
- const sent = await session.sendSms({
+ const handed = handedOver();
+ const smpp = await startServer(t, { onSms: handed.onSms });
+ const esme = await bound(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } });
+ const sending = esme.sendSms({
dlr: true,
from: '46701113311',
message: 'x'.repeat(400),
to: '46709771337',
});
- const sms = await incoming;
+ const sms = await handed.sms;
+
+ handed.release();
+
+ const sent = await sending;
assert.deepEqual(sent.smsIds, [1, 2, 3].map(part => `${sms.smsId}-${String(part)}`));
// Answered on the link that then drops, so an SMSC has no reason to ever send it again.
await sendReceipt(peerOf(smpp), `${sms.smsId}-1`);
- const reconnected = once<true>(resolve => { session.on('reconnected', () => { resolve(true); }); });
+ const reconnected = once<true>(resolve => { esme.on('reconnected', () => { resolve(true); }); });
await peerOf(smpp).close();
await reconnected;
- const merged = once<MessageDlr>(resolve => { session.on('messageDlr', resolve); });
+ const merged = once<MessageDlr>(resolve => { esme.on('messageDlr', resolve); });
await sendReceipt(peerOf(smpp), `${sms.smsId}-2`);
await sendReceipt(peerOf(smpp), `${sms.smsId}-3`);
@@ -692,19 +653,20 @@ describe('reconnect', () => {
assert.equal(report.segments.length, 3);
});
- test('refuses to answer a message whose link went, held or already answered', async t => {
+ // A message belongs to the session it arrived on, and that session ended with its socket.
+ test('refuses to answer or report on a message whose session went', async t => {
const smpp = await startServer(t);
- const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } });
-
- assert.ok(session);
-
const arrived: Sms[] = [];
- const both = once<true>(resolve => {
- session.on('sms', sms => {
+ const both = latch();
+ const esme = await bound(t, smpp, {
+ onSms: async sms => {
arrived.push(sms);
- if (arrived.length === 2) resolve(true);
- });
+ if (arrived.length === 2) both.open();
+
+ await neverReturns();
+ },
+ reconnect: { maxDelay: 100, minDelay: 20 },
});
for (const text of ['answered before the drop', 'never answered']) {
@@ -718,7 +680,7 @@ describe('reconnect', () => {
});
}
- await both;
+ await both.passed;
const [answered, held] = arrived;
@@ -727,57 +689,49 @@ describe('reconnect', () => {
assert.equal(answered.message, 'answered before the drop');
assert.equal((await answered.sendResp()).err, undefined);
- const reconnected = once<true>(resolve => { session.on('reconnected', () => { resolve(true); }); });
+ const reconnected = once<true>(resolve => { esme.on('reconnected', () => { resolve(true); }); });
await peerOf(smpp).close({ signal: AbortSignal.abort() });
await reconnected;
let taken = 0;
- // A response is dispatched before `incomingPduObj`, so only the raw event sees one arrive.
peerOf(smpp).on('incomingPdu', () => { taken++; });
- assert.match((await answered.sendResp()).err?.message ?? '', /link this message arrived on is gone/);
- assert.match((await held.sendResp()).err?.message ?? '', /link this message arrived on is gone/);
-
- assert.equal((await held.sendDlr('DELIVERED')).err, undefined);
- assert.equal(taken, 1, 'a refused response reached the new link');
+ assert.match((await answered.sendResp({ smsId: 'late' })).err?.message ?? '', /already answered/);
+ assert.match((await held.sendResp()).err?.message ?? '', /Socket is closed/);
+ assert.match((await answered.sendDlr('DELIVERED')).err?.message ?? '', /Session closed|Socket is closed|link went/);
+ assert.equal(held.session.closed, true);
+ assert.equal(taken, 0, 'nothing of it reached the new link');
});
- test('drops a message whose link went while onRequest was still running', async t => {
- const session = new Session({ sock: new net.Socket() });
-
- closeAfter(t, session);
-
- const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 100 });
- const incoming = incomingOn(session, { link, onRequest: async () => { await delay(10); return false; } });
+ test('drops a request whose socket closed while onRequest was still running', async t => {
let messages = 0;
+ const session = new Session({
+ onRequest: async () => {
+ await delay(10);
- session.on('sms', () => { messages++; });
-
- const handled = incoming.handle(submitPdu(1));
-
- link.drop();
+ return false;
+ },
+ onSms: () => { messages++; },
+ sock: new net.Socket(),
+ });
- await handled;
+ closeAfter(t, session);
+ arrives(session, { cmdName: 'submit_sm', params: submitPdu(1).params, seqNr: 1 });
+ session.sock.destroy();
+ await delay(30);
assert.equal(messages, 0);
-
- await incoming.handle(submitPdu(2));
-
- assert.equal(messages, 1, 'the harness delivers a message whose link stayed');
});
test('does not reconnect after an explicit close', async t => {
const smpp = await startServer(t);
- const { session } = await connect(t, smpp, { reconnect: { maxDelay: 50, minDelay: 10 } });
-
- assert.ok(session);
-
+ const esme = await bound(t, smpp, { reconnect: { maxDelay: 50, minDelay: 10 } });
let reconnects = 0;
- session.on('reconnected', () => { reconnects++; });
- await session.close();
+ esme.on('reconnected', () => { reconnects++; });
+ await esme.close();
await delay(150);
@@ -830,10 +784,10 @@ describe('reconnect from the first bind', () => {
const port = await closedPort();
const spy = logSpy();
const started = Date.now();
- const { err, session } = await client({ log: spy.log, port, reconnect: { maxDelay: 40, minDelay: 10 } });
+ const { err, client: esme } = await client({ log: spy.log, port, reconnect: { maxDelay: 40, minDelay: 10 } });
assert.ok(err instanceof Error);
- assert.equal(session, undefined);
+ assert.equal(esme, undefined);
assert.ok(Date.now() - started < 1000, 'the default answers the caller rather than retrying');
assert.ok(!spy.messages.includes('reconnect - retrying'), 'nothing may retry what the default gave up on');
});
@@ -858,15 +812,15 @@ describe('reconnect from the first bind', () => {
assert.ok(listening.server);
closeAfter(t, listening.server);
- const { err, session } = await connecting;
+ const { err, client: esme } = await connecting;
assert.equal(err, undefined);
- assert.ok(session);
- assert.equal(session.boundAs, 'transceiver');
+ assert.ok(esme);
+ assert.equal(linkOf(esme).boundAs, 'transceiver');
assert.ok(spy.delays.length >= 3, 'the SMSC was down for several attempts');
assert.deepEqual(spy.delays.slice(0, 3), [10, 20, 40], 'each wait doubles, up to maxDelay');
assert.ok(
- !spy.messages.includes('session - reconnected'),
+ !spy.messages.includes('client - reconnected'),
'a first link is not a link coming back',
);
});
@@ -886,10 +840,10 @@ describe('reconnect from the first bind', () => {
abortAfter(t, controller, connecting);
- const { err, session } = await connecting;
+ const { err, client: esme } = await connecting;
assert.equal(err, undefined);
- assert.ok(session);
+ assert.ok(esme);
assert.equal(binds, 3, 'the two refusals were retried, not reported');
assert.deepEqual(spy.delays, [10, 20], 'a link the SMSC refused a bind on does not reset the backoff');
});
@@ -919,14 +873,14 @@ describe('reconnect from the first bind', () => {
abortAfter(t, controller, connecting);
- const { err, session } = await connecting;
+ const { err, client: esme } = await connecting;
assert.equal(err, undefined);
- assert.ok(session);
+ assert.ok(esme);
assert.deepEqual(spy.delays, [10, 20], 'only the loop that owns the retry announces one');
});
- test('hands back a session that reported nothing and reconnects like any other', async t => {
+ test('hands back a client that reported nothing and reconnects like any other', async t => {
const port = await closedPort();
const controller = new AbortController();
const connecting = client({
@@ -944,29 +898,29 @@ describe('reconnect from the first bind', () => {
assert.ok(listening.server);
closeAfter(t, listening.server);
- const { session } = await connecting;
+ const { client: esme } = await connecting;
- assert.ok(session);
+ assert.ok(esme);
const events: string[] = [];
- session.on('close', () => { events.push('close'); });
- session.on('disconnected', () => { events.push('disconnected'); });
+ esme.on('close', () => { events.push('close'); });
+ esme.on('disconnected', () => { events.push('disconnected'); });
- const reconnected = once<true>(resolve => { session.on('reconnected', () => { resolve(true); }); });
+ const reconnected = once<true>(resolve => { esme.on('reconnected', () => { resolve(true); }); });
await peerOf(listening.server).close();
await reconnected;
assert.deepEqual(events, ['disconnected'], 'the attempts before the first link reported nothing');
- const closed = once<true>(resolve => { session.on('close', () => { resolve(true); }); });
+ const closed = once<true>(resolve => { esme.on('close', () => { resolve(true); }); });
controller.abort();
await closed;
// Which is why a deadline for the wait may not be a signal that goes on arming afterwards.
- assert.deepEqual(events, ['disconnected', 'close'], 'the signal that bounded the wait ends the session');
+ assert.deepEqual(events, ['disconnected', 'close'], 'the signal that bounded the wait ends the client');
});
test('lets an abort out of the initial retries', async () => {
@@ -986,7 +940,7 @@ describe('reconnect from the first bind', () => {
assert.ok(settled, 'an aborted signal is the way out of a wait nothing else ends');
assert.ok(settled.err instanceof Error);
assert.ok(settled.err.cause instanceof Error, 'an abort carries what the attempts kept failing with');
- assert.equal(settled.session, undefined);
+ assert.equal(settled.client, undefined);
});
test('holds the process open between the initial attempts', async t => {
@@ -1025,7 +979,7 @@ describe('reconnect from the first bind', () => {
const refused = await client(misspelled);
assert.ok(refused.err instanceof Error);
- assert.equal(refused.session, undefined);
+ assert.equal(refused.client, undefined);
assert.match(checkSessionOptions({ reconnect: { fromStart: 'yes' } }).err?.message ?? '', /fromStart/);
assert.equal(checkSessionOptions({ reconnect: { fromStart: true, minDelay: 10 } }).err, undefined);
});
@@ -1065,7 +1019,7 @@ describe('connectTimeout', () => {
new RegExp(`Timed out completing the TLS handshake with 127\\.0\\.0\\.1:${String(port)} after 150 ms; raise connectTimeout`),
'a firewall and a peer that accepts then stalls need different answers, and whoever reads this has never heard of the option',
);
- assert.equal(settled.session, undefined);
+ assert.equal(settled.client, undefined);
});
// Waits the default out for real: node:test mock timers land in Node 20.4, and the floor is 18.
@@ -1099,15 +1053,14 @@ describe('connectTimeout', () => {
test('disarms on the connect that completed, rather than on the socket that follows it', async t => {
const smpp = await startServer(t);
- const { session } = await connect(t, smpp, { connectTimeout: 200 });
+ const esme = await bound(t, smpp, { connectTimeout: 200 });
- assert.ok(session);
await delay(300);
- const probe = await session.send({ cmdName: 'enquire_link' });
+ const probe = await esme.send({ cmdName: 'enquire_link' });
assert.equal(probe.err, undefined);
- assert.equal(session.sock.destroyed, false);
+ assert.equal(linkOf(esme).sock.destroyed, false);
});
test('refuses a connect timeout that would turn itself off', async () => {
@@ -1137,7 +1090,7 @@ describe('connectTimeout', () => {
assert.ok(refused.err instanceof Error);
assert.match(refused.err.message, /false waits/, 'the socket may not be opened before the option is refused');
- assert.equal(refused.session, undefined);
+ assert.equal(refused.client, undefined);
const off = await client({ connectTimeout: false, port: 1 });
@@ -1148,37 +1101,32 @@ describe('connectTimeout', () => {
describe('sends across a reconnect', () => {
/** Answers every message after the first, which is left to hold the send window open. */
- function answerAfterTheFirst(smpp: SmppServer, arrived: string[]): Latch {
+ function answerAfterTheFirst(arrived: string[]): { first: Latch; onSms: OnSms } {
const first = latch();
- smpp.on('session', peer => {
- peer.on('sms', async sms => {
+ return {
+ first,
+ onSms: async sms => {
arrived.push(sms.message);
- if (arrived.length === 1) first.open();
- else await sms.sendResp();
- });
- });
-
- return first;
+ if (arrived.length === 1) {
+ first.open();
+ await neverReturns();
+ }
+ },
+ };
}
test('holds a send issued while the link is down and puts it on the new link', async t => {
- const smpp = await startServer(t);
const arrived: string[] = [];
-
- smpp.on('session', peer => { peer.on('sms', async sms => { arrived.push(sms.message); await sms.sendResp(); }); });
-
- const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } });
-
- assert.ok(session);
-
- const down = once<true>(resolve => { session.on('disconnected', () => { resolve(true); }); });
+ const smpp = await startServer(t, { onSms: sms => { arrived.push(sms.message); } });
+ const esme = await bound(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } });
+ const down = once<true>(resolve => { esme.on('disconnected', () => { resolve(true); }); });
await peerOf(smpp).close();
await down;
- const sent = await session.sendSms({ from: '46701113311', message: 'held', to: '46709771337' });
+ const sent = await esme.sendSms({ from: '46701113311', message: 'held', to: '46709771337' });
assert.equal(sent.err, undefined);
assert.equal(sent.smsIds.length, 1);
@@ -1187,21 +1135,18 @@ describe('sends across a reconnect', () => {
});
test('puts a segment still queued behind a full window on the new link', async t => {
- const smpp = await startServer(t);
const arrived: string[] = [];
- const first = answerAfterTheFirst(smpp, arrived);
- const { session } = await connect(t, smpp, {
+ const { first, onSms } = answerAfterTheFirst(arrived);
+ const smpp = await startServer(t, { onSms });
+ const esme = await bound(t, smpp, {
maxOutstanding: 1,
reconnect: { maxDelay: 100, minDelay: 20 },
});
-
- assert.ok(session);
-
- const holding = session.sendSms({ from: '46701113311', message: 'first', to: '46709771337' });
+ const holding = esme.sendSms({ from: '46701113311', message: 'first', to: '46709771337' });
await first.passed;
- const queued = session.sendSms({ from: '46701113311', message: 'second', to: '46709771337' });
+ const queued = esme.sendSms({ from: '46701113311', message: 'second', to: '46709771337' });
peerOf(smpp).sock.destroy();
@@ -1228,20 +1173,16 @@ describe('sends across a reconnect', () => {
return true;
},
+ onSms: () => undefined,
});
-
- smpp.on('session', peer => { peer.on('sms', async sms => { await sms.sendResp(); }); });
-
- const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } });
-
- assert.ok(session);
+ const esme = await bound(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } });
peerOf(smpp).sock.destroy();
- // The fresh socket is attached and the bind is in flight, so the link exists but carries nothing.
+ // The fresh socket is open and the bind is in flight, so the link exists but carries nothing.
await binding.passed;
- const sending = session.sendSms({ from: '46701113311', message: 'mid-bind', to: '46709771337' });
+ const sending = esme.sendSms({ from: '46701113311', message: 'mid-bind', to: '46709771337' });
let settled = false;
void sending.then(() => { settled = true; });
@@ -1258,34 +1199,25 @@ describe('sends across a reconnect', () => {
});
test('counts a segment the peer never answered in time as unanswered', async t => {
- const smpp = await startServer(t);
-
- smpp.on('session', peer => { peer.on('sms', () => undefined); });
-
- const { session } = await connect(t, smpp, { responseTimeout: 200 });
-
- assert.ok(session);
-
- const sent = await session.sendSms({ from: '46701113311', message: 'no answer', to: '46709771337' });
+ const smpp = await startServer(t, { onSms: neverReturns });
+ const esme = await bound(t, smpp, { responseTimeout: 200 });
+ const sent = await esme.sendSms({ from: '46701113311', message: 'no answer', to: '46709771337' });
assert.match(sent.err?.message ?? '', /may have accepted/);
assert.equal(sent.unanswered, 1, 'a slow SMSC may still have taken it');
});
test('counts a segment aborted after it went out as unanswered', async t => {
- const smpp = await startServer(t);
- const arrived = once<Sms>(resolve => { smpp.on('session', peer => peer.on('sms', resolve)); });
- const { session } = await connect(t, smpp);
-
- assert.ok(session);
-
+ const handed = handedOver();
+ const smpp = await startServer(t, { onSms: handed.onSms });
+ const esme = await bound(t, smpp);
const controller = new AbortController();
- const sending = session.sendSms(
+ const sending = esme.sendSms(
{ from: '46701113311', message: 'aborted mid-flight', to: '46709771337' },
{ signal: controller.signal },
);
- await arrived;
+ await handed.sms;
controller.abort();
const sent = await sending;
@@ -1295,15 +1227,12 @@ describe('sends across a reconnect', () => {
});
test('reports a segment the link dropped under as unanswered, not as never sent', async t => {
- const smpp = await startServer(t);
- const arrived = once<Sms>(resolve => { smpp.on('session', peer => peer.on('sms', resolve)); });
- const { session } = await connect(t, smpp, { reconnect: false });
-
- assert.ok(session);
+ const handed = handedOver();
+ const smpp = await startServer(t, { onSms: handed.onSms });
+ const esme = await bound(t, smpp, { reconnect: false });
+ const sending = esme.sendSms({ from: '46701113311', message: 'in flight', to: '46709771337' });
- const sending = session.sendSms({ from: '46701113311', message: 'in flight', to: '46709771337' });
-
- await arrived;
+ await handed.sms;
peerOf(smpp).sock.destroy();
const sent = await sending;
@@ -1315,38 +1244,32 @@ describe('sends across a reconnect', () => {
test('gives up a held send after responseTimeout, with nothing put on the wire', async t => {
const smpp = await startServer(t);
- const { session } = await connect(t, smpp, {
+ const esme = await bound(t, smpp, {
reconnect: { maxDelay: 10_000, minDelay: 10_000 },
responseTimeout: 200,
});
-
- assert.ok(session);
-
- const down = once<true>(resolve => { session.on('disconnected', () => { resolve(true); }); });
+ const down = once<true>(resolve => { esme.on('disconnected', () => { resolve(true); }); });
await peerOf(smpp).close();
await down;
- const sent = await session.sendSms({ from: '46701113311', message: 'held', to: '46709771337' });
+ const sent = await esme.sendSms({ from: '46701113311', message: 'held', to: '46709771337' });
assert.match(sent.err?.message ?? '', /did not come back/);
assert.equal(sent.unanswered, 0, 'nothing reached the peer, so the message can be sent again');
});
- test('fails a held send when the session closes rather than leaving it waiting', async t => {
+ test('fails a held send when the client closes rather than leaving it waiting', async t => {
const smpp = await startServer(t);
- const { session } = await connect(t, smpp, {
+ const esme = await bound(t, smpp, {
reconnect: { maxDelay: 10_000, minDelay: 10_000 },
});
-
- assert.ok(session);
-
- const down = once<true>(resolve => { session.on('disconnected', () => { resolve(true); }); });
+ const down = once<true>(resolve => { esme.on('disconnected', () => { resolve(true); }); });
await peerOf(smpp).close();
await down;
- const sending = session.sendSms({ from: '46701113311', message: 'held', to: '46709771337' });
+ const sending = esme.sendSms({ from: '46701113311', message: 'held', to: '46709771337' });
let settled = false;
void sending.then(() => { settled = true; });
@@ -1354,7 +1277,7 @@ describe('sends across a reconnect', () => {
assert.equal(settled, false, 'the send waits for a link rather than failing on the spot');
- await session.close();
+ await esme.close();
const sent = await sending;
@@ -1364,19 +1287,16 @@ describe('sends across a reconnect', () => {
test('aborts a held send instead of making it wait out the link', async t => {
const smpp = await startServer(t);
- const { session } = await connect(t, smpp, {
+ const esme = await bound(t, smpp, {
reconnect: { maxDelay: 10_000, minDelay: 10_000 },
});
-
- assert.ok(session);
-
- const down = once<true>(resolve => { session.on('disconnected', () => { resolve(true); }); });
+ const down = once<true>(resolve => { esme.on('disconnected', () => { resolve(true); }); });
await peerOf(smpp).close();
await down;
const controller = new AbortController();
- const sending = session.sendSms(
+ const sending = esme.sendSms(
{ from: '46701113311', message: 'held', to: '46709771337' },
{ signal: controller.signal },
);
@@ -1389,106 +1309,89 @@ describe('sends across a reconnect', () => {
assert.equal(sent.unanswered, 0);
});
- test('refuses a send outright once the session is over', async t => {
+ test('refuses a send outright once the client is over', async t => {
const smpp = await startServer(t);
- const { session } = await connect(t, smpp, { reconnect: false });
+ const esme = await bound(t, smpp, { reconnect: false });
- assert.ok(session);
- await session.close();
+ await esme.close();
- const sent = await session.sendSms({ from: '46701113311', message: 'too late', to: '46709771337' });
+ const sent = await esme.sendSms({ from: '46701113311', message: 'too late', to: '46709771337' });
assert.match(sent.err?.message ?? '', /closed/);
assert.equal(sent.unanswered, 0);
});
});
-describe('LinkLife', () => {
- test('refuses a hold whose deadline has already passed', async () => {
- let now = 0;
- const link = new LinkLife({ log: silentLog, now: () => now, reconnects: true, timeout: 100 });
- const waitForLink = link.hold(undefined);
-
- link.drop();
- now = 101;
-
- const held = await waitForLink();
-
- assert.match(held.err?.message ?? '', /did not come back in time/);
- });
+describe('ReconnectLoop', () => {
+ function loop(options: Partial<ConstructorParameters<typeof ReconnectLoop>[0]> = {}): ReconnectLoop {
+ return new ReconnectLoop({
+ log: silentLog,
+ onDown: () => undefined,
+ onUp: () => undefined,
+ open: () => Promise.resolve({ err: new Error('nothing to open') }),
+ ...options,
+ });
+ }
- test('holds on a timer that keeps the process alive', async () => {
- const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 10_000 });
+ test('holds on a timer that keeps the process alive', async t => {
+ const links = loop();
const timers = (): number => process.getActiveResourcesInfo().filter(name => name === 'Timeout').length;
-
- link.drop();
-
const before = timers();
- const held = link.hold(undefined)();
+ const held = links.bound(10_000, undefined);
assert.equal(timers(), before + 1, 'an unref\'d timer is not counted here, which is the point');
- link.open();
+ const session = new Session({ sock: new net.Socket() });
+
+ closeAfter(t, session);
+ links.adopt(session);
+
+ const link = await held;
- assert.deepEqual(await held, {});
+ assert.equal(link.session, session);
+ assert.equal(links.current(), session);
});
// addEventListener never fires for a signal that already aborted, so it would wait out the timeout.
- test('gives up at once on a signal that was already aborted', async () => {
- const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 100 });
+ test('gives up at once on a signal that was already aborted, and on a deadline that passed', async () => {
+ const links = loop();
- link.drop();
+ assert.match((await links.bound(100, AbortSignal.abort())).err?.message ?? '', /Aborted while waiting for a link/);
- const held = await link.hold(AbortSignal.abort())();
+ const expired = await links.bound(1, undefined);
- assert.match(held.err?.message ?? '', /Aborted while waiting for a link/);
+ assert.match(expired.err?.message ?? '', /did not come back in time/);
});
- test('awaits the next link only while down with one on its way', () => {
- const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 100 });
-
- assert.equal(link.awaitsNextLink(), false, 'up');
- link.drop();
- assert.equal(link.awaitsNextLink(), true, 'down, returning');
- link.attach();
- assert.equal(link.awaitsNextLink(), true, 'attached, not yet bound');
- link.open();
- assert.equal(link.awaitsNextLink(), false, 'reopened');
- link.drop();
- link.stop();
- assert.equal(link.awaitsNextLink(), false, 'down, stopped');
- assert.match(link.refusal()?.message ?? '', /closed/, 'stopped while down');
- link.end();
- assert.equal(link.awaitsNextLink(), false, 'ended');
- });
+ test('releases a held request with the reason once it stops', async () => {
+ const links = loop();
+ const held = links.bound(0, undefined);
- test('drops an attached link once, counts each drop, and names the event it warrants', () => {
- const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 100 });
- const generation = link.generation();
+ links.stop();
- assert.equal(link.drop(), 'disconnected');
- assert.equal(link.drop(), undefined, 'already down');
- assert.equal(link.generation(), generation + 1);
- link.attach();
- link.stop();
- assert.equal(link.drop(), 'close', 'a new link drops again, with none to follow it');
- assert.equal(link.generation(), generation + 2);
- assert.equal(new LinkLife({ log: silentLog, reconnects: false, timeout: 100 }).drop(), 'close');
+ assert.match((await held).err?.message ?? '', /Session is closed/);
+ assert.match((await links.bound(0, undefined)).err?.message ?? '', /Session is closed/);
});
- test('releases a held request with the reason once the link ends', async () => {
- const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 0 });
+ test('lets go of a session that ended, and of none other', async t => {
+ const events: string[] = [];
+ const links = loop({ onDown: retrying => { events.push(retrying ? 'down' : 'over'); } });
+ const first = new Session({ sock: new net.Socket() });
+ const second = new Session({ sock: new net.Socket() });
- link.drop();
+ closeAfter(t, first);
+ closeAfter(t, second);
+ links.adopt(first);
+ links.stop();
+ links.adopt(second);
+ await first.close();
- const held = link.hold(undefined)();
+ assert.equal(links.current(), second, 'a session that is not the current one ending changes nothing');
- link.end();
+ await second.close();
- assert.match((await held).err?.message ?? '', /Session is closed/);
- link.attach();
- assert.equal(link.isAttached(), false, 'ended is final');
- assert.equal(link.end(), false);
+ assert.equal(links.current(), undefined);
+ assert.deepEqual(events, ['over']);
});
});
@@ -1534,206 +1437,192 @@ describe('SendWindow', () => {
assert.match(refused.err?.message ?? '', /Aborted while waiting for a send window slot/);
assert.equal(window.unfinished(), 1);
});
-});
-// Goal 4: an application that answers nothing must not grow this for the life of the link.
-describe('held message bounds', () => {
- function message(seqNr: number): PduObject[] {
- return [submitPdu(seqNr)];
- }
-
- function offer(held: HeldMessages, seqNr: number): MessageHold {
- const hold = held.offer(message(seqNr));
+ test('settles everything queued with the reason on close()', async () => {
+ const window = new SendWindow({ limit: 1, log: silentLog });
- assert.ok(hold);
+ await window.acquire(undefined);
- return hold;
- }
+ const queued = window.acquire(undefined);
- /** Offers to a session with a listener, so an offer is held rather than released as untaken. */
- function heldOn(
- t: TestContext,
- options: Pick<HeldMessagesOptions, 'max' | 'maxOctets' | 'now' | 'timeout'>,
- ): HeldMessages {
- const session = new Session({ sock: new net.Socket() });
+ window.close(new Error('the link went'));
- closeAfter(t, session);
- session.on('sms', () => undefined);
+ assert.match((await queued).err?.message ?? '', /the link went/);
+ assert.equal(window.unfinished(), 1, 'only the slot on the wire is still owed');
+ });
+});
- return new HeldMessages({
- ...options,
- link: new LinkLife({ log: silentLog, reconnects: false, timeout: 100 }),
- log: silentLog,
- sendPastDrain: () => Promise.resolve({ err: new Error('never sent') }),
- session,
- });
- }
+// Goal 4: an application that never returns from its handler must not grow this for the life of the link.
+describe('running handler bounds', () => {
+ test('is full at its count, until a handler returns', () => {
+ const running = new RunningHandlers({ log: silentLog, max: 2, maxOctets: 1_000_000, timeout: 10_000 });
+ const first = running.start(10);
- test('is full at its count, and a re-used sequence number replaces rather than adding', t => {
- const held = heldOn(t, { max: 2, maxOctets: 1_000_000, timeout: 10_000 });
- const first = offer(held, 1);
- const replaced = offer(held, 2);
+ running.start(10);
- offer(held, 2);
+ assert.equal(running.size, 2);
+ assert.equal(running.octets, 20);
+ assert.equal(running.full(), true);
- assert.equal(held.size, 2);
- assert.equal(held.octetsHeld, 2 * 1026, 'the replaced message leaves its octets with it');
- assert.equal(held.full(), true);
- assert.equal(first.isHeld(), true);
- assert.equal(replaced.isHeld(), false);
+ first();
- held.clear();
+ assert.equal(running.full(), false);
+ running.clear();
});
- // submitPdu() holds 1026 octets by the maxOctets charge: its object, and the three text fields.
- test('is full at its octet cap, until a message leaves by any way out', t => {
+ test('is full at its octet cap, until a handler leaves by any way out', async () => {
let now = 0;
- const held = heldOn(t, { max: 10, maxOctets: 2000, now: () => now, timeout: 10_000 });
- const answered = offer(held, 1);
+ const warned: string[] = [];
+ const running = new RunningHandlers({
+ log: { ...silentLog, warn: msg => { warned.push(msg); } },
+ max: 10,
+ maxOctets: 2000,
+ now: () => now,
+ timeout: 60,
+ });
+ const done = running.start(1000);
+
+ assert.equal(running.full(), false);
+ running.start(1000);
+ assert.equal(running.full(), true);
- assert.equal(held.full(), false);
- offer(held, 2);
- assert.equal(held.full(), true);
+ done();
+ assert.equal(running.full(), false, 'after a return');
- answered.release();
- assert.equal(held.full(), false, 'after a release');
- offer(held, 3);
+ const waiting = running.idle(1000, undefined);
- now = 20_000;
- held.sweep();
- now = 0;
- assert.equal(held.full(), false, 'after a sweep');
- offer(held, 4);
- offer(held, 5);
+ now = 61;
- held.clear();
- assert.equal(held.full(), false, 'after a clear');
+ assert.equal(await waiting, 0, 'a handler past its timeout is no longer waited for');
+ assert.equal(running.full(), false, 'after an expiry');
+ assert.deepEqual(warned, ['runningHandlers - giving up on a handler that never returned']);
});
- // Dropping one the application still holds frees nothing, and the drain stops waiting for it.
+ // Refusing leaves the message with the peer, which sends it again once handlers return.
test('refuses what arrives past the bound with a status that asks the peer to retry', async t => {
- const session = new Session({ sock: new net.Socket() });
-
- closeAfter(t, session);
-
const warnings: string[] = [];
- const incoming = incomingOn(session, { log: { ...silentLog, warn: message => { warnings.push(message); } } });
const answers: (ErrorName | undefined)[] = [];
const received: Sms[] = [];
+ const session = new Session({
+ log: { ...silentLog, warn: message => { warnings.push(message); } },
+ onSms: sms => {
+ received.push(sms);
+
+ return neverReturns();
+ },
+ sock: new net.Socket(),
+ });
+ closeAfter(t, session);
session.sendReturn = (_pdu, status) => {
answers.push(status);
return Promise.resolve({});
};
- session.on('sms', sms => { received.push(sms); });
- for (let seqNr = 1; seqNr <= defaults.maxHeldMessages; seqNr++) {
- await incoming.handle(submitPdu(seqNr));
+ for (let seqNr = 1; seqNr <= defaults.maxRunningHandlers; seqNr++) {
+ arrives(session, { cmdName: 'submit_sm', params: submitPdu(seqNr).params, seqNr });
}
- assert.equal(received.length, defaults.maxHeldMessages);
+ assert.ok(await waitFor(() => received.length === defaults.maxRunningHandlers));
- await incoming.handle(submitPdu(defaults.maxHeldMessages + 1));
- await incoming.handle(segment(7, 1, 2));
+ arrives(session, { cmdName: 'submit_sm', params: submitPdu(defaults.maxRunningHandlers + 1).params, seqNr: defaults.maxRunningHandlers + 1 });
+ arrives(session, segmentInput(7, 1, 2));
- assert.equal(received.length, defaults.maxHeldMessages);
+ assert.ok(await waitFor(() => answers.length === 2));
+ assert.equal(received.length, defaults.maxRunningHandlers);
assert.deepEqual(answers, ['ESME_RTHROTTLED', 'ESME_RTHROTTLED']);
assert.equal(warnings.length, 1, 'reaching the bound warns once, not per refusal');
- // The refused first segment joined no group, so the second one is taken and completes nothing.
+ // Answering does not free the count: the handler is still running.
await received[0]?.sendResp();
- await new Promise(resolve => { setImmediate(resolve); });
- await incoming.handle(segment(7, 2, 2));
-
- assert.equal(answers.at(-1), 'ESME_ROK');
- assert.equal(received.length, defaults.maxHeldMessages);
-
- // A peer keeping its window full crosses the bound on every answer, and that is still one warning.
- await received[1]?.sendResp();
- await new Promise(resolve => { setImmediate(resolve); });
- await incoming.handle(submitPdu(defaults.maxHeldMessages + 2));
- await incoming.handle(submitPdu(defaults.maxHeldMessages + 3));
- await incoming.handle(submitPdu(defaults.maxHeldMessages + 4));
+ arrives(session, segmentInput(7, 2, 2));
+ assert.ok(await waitFor(() => answers.length === 4));
assert.equal(answers.at(-1), 'ESME_RTHROTTLED');
assert.equal(warnings.length, 1);
- incoming.clear();
});
test('holds a message detached from the chunk it was read from', async t => {
- const session = new Session({ sock: new net.Socket() });
+ let received: Sms | undefined;
+ const session = new Session({ onSms: sms => { received = sms; }, sock: new net.Socket() });
closeAfter(t, session);
- const incoming = incomingOn(session);
const chunk = Buffer.alloc(64 * 1024);
const carried = submitPdu(1);
- let received: Sms | undefined;
- session.on('sms', sms => { received = sms; });
- await incoming.handle({ ...carried, params: { ...carried.params, short_message: chunk.subarray(16, 20) } });
+ pduBytes({ cmdName: 'submit_sm', params: { ...carried.params, short_message: 'held' }, seqNr: 1 }).copy(chunk);
+ session.sock.emit('data', chunk.subarray(0, 60));
+
+ assert.ok(await waitFor(() => received !== undefined));
- const retained = received?.pduObjs[0]?.params.short_message;
+ const retained = received?.pduObjs[0]?.shortMessageOctets;
assert.ok(Buffer.isBuffer(retained));
assert.notEqual(retained.buffer, chunk.buffer);
- incoming.clear();
});
+});
- test('gives up on a message the application never answers', t => {
- let now = 0;
- const held = heldOn(t, { max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 });
-
- offer(held, 1);
- now = 61;
+describe('sendResp()', () => {
+ // A response the wire never carried leaves the peer owed one, so nothing may count it answered.
+ test('does not count a response that never reached the wire as an answer', async t => {
+ const session = new Session({ sock: new net.Socket() });
- // The next message sweeps the one that expired, so only the new one is still waited for.
- offer(held, 2);
+ closeAfter(t, session);
- assert.equal(held.size, 1);
+ let call = 0;
+ const sms = createSms({ pduObjs: [submitPdu(1)], session }, smsDeps({
+ answer: () => Promise.resolve(++call === 1 ? { err: new Error('Socket is closed') } : {}),
+ }));
- held.clear();
+ assert.match((await sms.sendResp({ smsId: 'lost' })).err?.message ?? '', /Socket is closed/);
+ assert.notEqual(sms.smsId, 'lost', 'a failed answer leaves the id as it was');
+ assert.deepEqual(await sms.sendResp({ smsId: 'carried' }), {});
+ assert.equal(sms.smsId, 'carried');
});
- // Without this the drain sits out its whole budget before returning what a sweep already settled.
- test('wakes a waiting drain when the last message expires', async t => {
- let now = 0;
- const held = heldOn(t, { max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 });
+ test('answers once: a later id or refusal is an error, a bare call a no-op', async t => {
+ const session = new Session({ sock: new net.Socket() });
- offer(held, 1);
+ closeAfter(t, session);
- const waiting = held.idle(1000, undefined);
+ const answers: ErrorName[] = [];
+ const sms = createSms({ pduObjs: [submitPdu(1)], session }, smsDeps({
+ answer: (_pdu, status) => {
+ answers.push(status);
- now = 61;
- held.sweep();
+ return Promise.resolve({});
+ },
+ }));
- assert.equal(await waiting, 0);
+ assert.deepEqual(await sms.sendResp(), {});
+ assert.deepEqual(await sms.sendResp(), {});
+ assert.match((await sms.sendResp({ smsId: 'other' })).err?.message ?? '', /already answered/);
+ assert.match((await sms.sendResp({ status: 'ESME_RMSGQFUL' })).err?.message ?? '', /nothing left to refuse/);
+ assert.deepEqual(answers, ['ESME_ROK']);
});
-});
-describe('sendResp()', () => {
- // A response the wire never carried leaves the peer owed one, so nothing may count it answered.
- test('does not count a response that never reached the wire as an answer', async t => {
+ // A receipt names a message the peer had accepted, so nothing may report on one before its answer.
+ test('refuses sendDlr() until the message is answered', async t => {
const session = new Session({ sock: new net.Socket() });
closeAfter(t, session);
- let answered = 0;
-
- session.sendReturn = () => Promise.resolve({ err: new Error('Socket is closed') });
+ const requests: string[] = [];
+ const sms = createSms({ pduObjs: [submitPdu(1)], session }, smsDeps({
+ request: input => {
+ requests.push(input.cmdName);
- const sms = createSms({
- pduObjs: [submitPdu(1)],
- session,
- }, {
- answered: () => { answered++; },
- lostLink: () => false,
- send: () => Promise.resolve({ err: new Error('never sent') }),
- });
+ return Promise.resolve({ pduObj: submitPdu(1) });
+ },
+ }));
- assert.match((await sms.sendResp()).err?.message ?? '', /Socket is closed/);
- assert.equal(answered, 0);
+ assert.match((await sms.sendDlr()).err?.message ?? '', /Answer the message before reporting on it/);
+ assert.deepEqual(requests, []);
+ await sms.sendResp();
+ assert.equal((await sms.sendDlr()).err, undefined);
+ assert.deepEqual(requests, ['deliver_sm']);
});
});
@@ -1746,12 +1635,11 @@ describe('sendDlr()', () => {
let call = 0;
const sms = createSms({
+ answeredAs: '0199e0eb-4c11-7a02-9f31-2b6d80c4e517',
pduObjs: [submitPdu(1), submitPdu(2), submitPdu(3)],
session,
- }, {
- answered: () => undefined,
- lostLink: () => false,
- send: () => {
+ }, smsDeps({
+ request: () => {
call++;
if (call === 1) return Promise.resolve({ pduObj: submitPdu(1, 'ESME_RX_T_APPN') });
@@ -1762,7 +1650,7 @@ describe('sendDlr()', () => {
return Promise.resolve({ pduObj: submitPdu(3) });
},
- });
+ }));
const report = await sms.sendDlr('DELIVERED');
assert.ok(report.err instanceof Error);
@@ -1772,11 +1660,16 @@ describe('sendDlr()', () => {
});
});
-function segment(reference: number, part: number, total: number, width: 8 | 16 = 8): PduObject {
+function segmentBody(reference: number, part: number, total: number, width: 8 | 16 = 8): Buffer {
const udh = width === 8
? Buffer.from([0x05, 0x00, 0x03, reference, total, part])
: Buffer.from([0x06, 0x08, 0x04, reference >>> 8, reference & 0xff, total, part]);
- const body = Buffer.concat([udh, Buffer.from('fragment')]);
+
+ return Buffer.concat([udh, Buffer.from('fragment')]);
+}
+
+function segment(reference: number, part: number, total: number, width: 8 | 16 = 8): PduObject {
+ const body = segmentBody(reference, part, total, width);
return {
cmdId: 0x00000004,
@@ -1797,6 +1690,21 @@ function segment(reference: number, part: number, total: number, width: 8 | 16 =
};
}
+/** The same segment as the octets a peer writes. */
+function segmentInput(reference: number, part: number, total: number): PduObjectInput {
+ return {
+ cmdName: 'submit_sm',
+ params: {
+ data_coding: 0,
+ destination_addr: '46709771337',
+ esm_class: 0x40,
+ short_message: segmentBody(reference, part, total),
+ source_addr: '46701113311',
+ },
+ seqNr: 1000 + part,
+ };
+}
+
/** The same segment with its body where SMPP 3.4 5.3.2.32 allows it instead. */
function payloadSegment(reference: number, part: number, total: number): PduObject {
const carried = segment(reference, part, total);
@@ -1894,46 +1802,44 @@ describe('where a segment says it is concatenated', () => {
});
describe('reassembly bounds', () => {
- function collect(
- reassembler: Reassembler,
- reference: number,
- part: number,
- total: number,
- ): Collected {
- return collectPdu(reassembler, segment(reference, part, total));
+ type ReassemblerTuning = { max?: number; maxOctets?: number; newId?: () => string; now?: () => number; onLost?: (lost: LostGroup) => void; timeout?: number };
+
+ function reassembler(tuning: ReassemblerTuning = {}): Reassembler {
+ return new Reassembler({
+ log: silentLog,
+ max: tuning.max ?? 10,
+ maxOctets: tuning.maxOctets,
+ newId: tuning.newId,
+ now: tuning.now ?? (() => 0),
+ onLost: tuning.onLost ?? (() => undefined),
+ timeout: tuning.timeout ?? 60_000,
+ });
}
- function collectSar(
- reassembler: Reassembler,
- reference: number,
- part: number,
- total: number,
- ): Collected {
- return collectPdu(reassembler, sarSegment(reference, part, total));
+ function collect(store: Reassembler, reference: number, part: number, total: number): Collected {
+ return collectPdu(store, segment(reference, part, total));
+ }
+
+ function collectSar(store: Reassembler, reference: number, part: number, total: number): Collected {
+ return collectPdu(store, sarSegment(reference, part, total));
}
test('hands back every segment in order once the last one arrives', () => {
- const reassembler = new Reassembler({
- log: silentLog,
- max: 10,
- now: () => 0,
- onLost: () => undefined,
- timeout: 60_000,
- });
- const second = collect(reassembler, 4, 2, 3);
- const third = collect(reassembler, 4, 3, 3);
+ const store = reassembler();
+ const second = collect(store, 4, 2, 3);
+ const third = collect(store, 4, 3, 3);
assert.ok(second.kept);
assert.ok(third.kept);
assert.equal(second.whole, undefined);
assert.equal(third.whole, undefined);
- const collected = collect(reassembler, 4, 1, 3);
+ const collected = collect(store, 4, 1, 3);
assert.ok(collected.kept);
assert.ok(collected.whole);
assert.deepEqual(collected.whole.map(pduObj => pduObj.seqNr), [1, 2, 3]);
- assert.equal(reassembler.size, 0);
+ assert.equal(store.size, 0);
// One id base per group: every segment of it was answered with a part of that base.
assert.equal(second.smsId, collected.smsId);
@@ -1942,20 +1848,13 @@ describe('reassembly bounds', () => {
// The header is stripped by its own declared length, so the wider element assembles identically.
test('assembles a message numbered by a 16-bit UDH reference', () => {
- const reassembler = new Reassembler({
- log: silentLog,
- max: 10,
- now: () => 0,
- onLost: () => undefined,
- timeout: 60_000,
- });
-
- const first = collectPdu(reassembler, segment(0x2af1, 1, 2, 16));
+ const store = reassembler();
+ const first = collectPdu(store, segment(0x2af1, 1, 2, 16));
assert.ok(first.kept);
assert.equal(first.whole, undefined);
- const collected = collectPdu(reassembler, segment(0x2af1, 2, 2, 16));
+ const collected = collectPdu(store, segment(0x2af1, 2, 2, 16));
assert.ok(collected.kept);
assert.ok(collected.whole);
@@ -1965,56 +1864,27 @@ describe('reassembly bounds', () => {
// A group the store cannot hold at all is refused, not accepted and then thrown away.
test('refuses a lone segment whose own arrival overruns the octet cap', () => {
const lost: LostGroup[] = [];
- const reassembler = new Reassembler({
- log: silentLog,
- max: 10,
- maxOctets: 10,
- now: () => 0,
- onLost: one => { lost.push(one); },
- timeout: 60_000,
- });
- const refused = collect(reassembler, 8, 1, 2);
+ const store = reassembler({ maxOctets: 10, onLost: one => { lost.push(one); } });
+ const refused = collect(store, 8, 1, 2);
assert.equal(refused.kept, false);
- assert.equal(reassembler.size, 0);
+ assert.equal(store.size, 0);
assert.deepEqual(lost, [], 'the peer holds the only segment there was, so nothing was lost');
});
// The two addresses and the segment's and TLV's objects are 1322 octets, so only the 14 it carries can overrun 1330.
test('counts a body carried in message_payload against the octet cap', () => {
- function collectPayload(maxOctets: number): Collected {
- const reassembler = new Reassembler({
- log: silentLog,
- max: 10,
- maxOctets,
- now: () => 0,
- onLost: () => undefined,
- timeout: 60_000,
- });
-
- return collectPdu(reassembler, payloadSegment(9, 1, 2));
- }
+ const collectPayload = (maxOctets: number): Collected => collectPdu(reassembler({ maxOctets }), payloadSegment(9, 1, 2));
assert.equal(collectPayload(1330).kept, false, 'a TLV body the cap cannot hold is refused, not dropped later');
assert.equal(collectPayload(1340).kept, true);
});
test('counts the objects a segment and each of its TLVs hold against the octet cap, empty ones included', () => {
- function collectTlvs(maxOctets: number, tlvs: PduObject['tlvs']): Collected {
- const reassembler = new Reassembler({
- log: silentLog,
- max: 10,
- maxOctets,
- now: () => 0,
- onLost: () => undefined,
- timeout: 60_000,
- });
-
- return collectPdu(reassembler, { ...segment(9, 1, 2), tlvs });
- }
- function collectCallbacks(maxOctets: number, tagValue: Buffer[]): Collected {
- return collectTlvs(maxOctets, { callback_num: { tagId: 0x0381, tagName: 'callback_num', tagValue } });
- }
+ const collectTlvs = (maxOctets: number, tlvs: PduObject['tlvs']): Collected =>
+ collectPdu(reassembler({ maxOctets }), { ...segment(9, 1, 2), tlvs });
+ const collectCallbacks = (maxOctets: number, tagValue: Buffer[]): Collected =>
+ collectTlvs(maxOctets, { callback_num: { tagId: 0x0381, tagName: 'callback_num', tagValue } });
const unknownTags = Object.fromEntries(Array.from({ length: 10_000 }, (_, i) => {
const tagId = 0x4000 + i;
@@ -2039,19 +1909,12 @@ describe('reassembly bounds', () => {
// The segments before it were answered ESME_ROK, so dropping those is not the same as refusing one.
test('reports the answered segments of a group that overruns the cap mid-message', () => {
const lost: LostGroup[] = [];
- const reassembler = new Reassembler({
- log: silentLog,
- max: 10,
- // One segment is 1036 octets, so the second overruns a group already holding the first.
- maxOctets: 1050,
- now: () => 0,
- onLost: one => { lost.push(one); },
- timeout: 60_000,
- });
+ // One segment is 1036 octets, so the second overruns a group already holding the first.
+ const store = reassembler({ maxOctets: 1050, onLost: one => { lost.push(one); } });
- assert.equal(collect(reassembler, 8, 1, 3).kept, true);
- assert.equal(collect(reassembler, 8, 2, 3).kept, false);
- assert.equal(reassembler.size, 0);
+ assert.equal(collect(store, 8, 1, 3).kept, true);
+ assert.equal(collect(store, 8, 2, 3).kept, false);
+ assert.equal(store.size, 0);
// One of the two the group held is the refused segment, which the peer still has.
assert.deepEqual(
lost.map(one => ({ parts: one.parts, reason: one.reason, total: one.total })),
@@ -2061,13 +1924,7 @@ describe('reassembly bounds', () => {
// An alphanumeric sender may carry the separator the key is built with, and two of them are two peers.
test('keeps two address pairs that differ only in where a separator sits apart', () => {
- const reassembler = new Reassembler({
- log: silentLog,
- max: 10,
- now: () => 0,
- onLost: () => undefined,
- timeout: 60_000,
- });
+ const store = reassembler();
const addressed = (source: string, destination: string): PduObject => {
const carried = segment(3, 1, 2);
@@ -2077,30 +1934,16 @@ describe('reassembly bounds', () => {
};
};
- assert.equal(collectPdu(reassembler, addressed('A_B', 'C')).kept, true);
- assert.equal(collectPdu(reassembler, addressed('A', 'B_C')).kept, true);
- assert.equal(reassembler.size, 2, 'two senders, so two groups, and neither completes the other');
+ assert.equal(collectPdu(store, addressed('A_B', 'C')).kept, true);
+ assert.equal(collectPdu(store, addressed('A', 'B_C')).kept, true);
+ assert.equal(store.size, 2, 'two senders, so two groups, and neither completes the other');
- reassembler.clear();
+ store.clear();
});
// Nothing about the bounds reads a UDH, and a group the TLVs numbered is bounded the same way.
test('bounds a sar_* group by the same count and octet caps', () => {
- const counted = new Reassembler({
- log: silentLog,
- max: 1,
- now: () => 0,
- onLost: () => undefined,
- timeout: 60_000,
- });
- const capped = (maxOctets: number): Reassembler => new Reassembler({
- log: silentLog,
- max: 10,
- maxOctets,
- now: () => 0,
- onLost: () => undefined,
- timeout: 60_000,
- });
+ const counted = reassembler({ max: 1 });
assert.equal(collectSar(counted, 1, 1, 2).kept, true);
assert.equal(collectSar(counted, 2, 1, 2).kept, true);
@@ -2108,8 +1951,8 @@ describe('reassembly bounds', () => {
counted.clear();
// A sar_* segment is the two 11-octet addresses, an 8-octet body, its own object and three TLVs'.
- assert.equal(collectSar(capped(1920), 3, 1, 2).kept, false);
- assert.equal(collectSar(capped(1930), 3, 1, 2).kept, true);
+ assert.equal(collectSar(reassembler({ maxOctets: 1920 }), 3, 1, 2).kept, false);
+ assert.equal(collectSar(reassembler({ maxOctets: 1930 }), 3, 1, 2).kept, true);
});
// Nothing else says a message the peer has already been answered for was thrown away.
@@ -2122,8 +1965,7 @@ describe('reassembly bounds', () => {
'0199e0ec-0e73-7d18-bb29-7c04ea51f3a9',
];
const lost: LostGroup[] = [];
- const reassembler = new Reassembler({
- log: silentLog,
+ const store = reassembler({
max: 1,
newId: () => ids[issued++] ?? '',
now: () => now,
@@ -2131,14 +1973,14 @@ describe('reassembly bounds', () => {
timeout: 60,
});
- collect(reassembler, 1, 1, 2);
+ collect(store, 1, 1, 2);
// A group numbered by the TLVs is given up on, and reported, exactly as a UDH group is.
- collectSar(reassembler, 2, 1, 3);
+ collectSar(store, 2, 1, 3);
+ // The expired group goes on the next access, before the new one is stored.
now = 61;
- reassembler.sweep();
- collect(reassembler, 3, 1, 2);
- reassembler.clear();
+ collect(store, 3, 1, 2);
+ store.clear();
assert.deepEqual(lost.map(one => one.reason), ['evicted', 'expired', 'linkGone']);
assert.deepEqual(lost.map(one => one.parts), [1, 1, 1]);
@@ -2149,16 +1991,8 @@ describe('reassembly bounds', () => {
// The UDH is peer-controlled, and the default authenticate() accepts every peer.
test('refuses a segment whose concatenation metadata cannot be honoured', () => {
const warnings: string[] = [];
- const noop = (): void => undefined;
- const log: SmppLog = {
- debug: noop,
- error: noop,
- info: noop,
- verbose: noop,
- warn: msg => { warnings.push(msg); },
- };
- const reassembler = new Reassembler({
- log,
+ const store = new Reassembler({
+ log: { ...silentLog, warn: msg => { warnings.push(msg); } },
max: 10,
now: () => 0,
onLost: () => undefined,
@@ -2166,114 +2000,88 @@ describe('reassembly bounds', () => {
});
assert.deepEqual(
- [collect(reassembler, 1, 1, 0), collect(reassembler, 2, 0, 3), collect(reassembler, 3, 4, 3)]
+ [collect(store, 1, 1, 0), collect(store, 2, 0, 3), collect(store, 3, 4, 3)]
.map(one => (one.kept ? undefined : one.refusal)),
Array(3).fill('unplaceable'),
);
- assert.equal(reassembler.size, 0);
+ assert.equal(store.size, 0);
assert.deepEqual(warnings, Array(3).fill('reassembler - dropping an impossibly numbered segment'));
});
// Parts 1/2 then 2/3 would otherwise complete the stored two-part group, truncating the message.
test('refuses a segment that renumbers how many parts the message has', () => {
- const reassembler = new Reassembler({
- log: silentLog,
- max: 10,
- now: () => 0,
- onLost: () => undefined,
- timeout: 60_000,
- });
-
- const first = collect(reassembler, 9, 1, 2);
+ const store = reassembler();
+ const first = collect(store, 9, 1, 2);
assert.ok(first.kept);
assert.equal(first.whole, undefined);
- assert.equal(collect(reassembler, 9, 2, 3).kept, false);
- assert.equal(reassembler.size, 1);
+ assert.equal(collect(store, 9, 2, 3).kept, false);
+ assert.equal(store.size, 1);
- reassembler.clear();
+ store.clear();
});
// 0.4.0 held incomplete groups without limit and swept them only when other traffic arrived.
test('drops the oldest incomplete message once the cap is reached', () => {
- const reassembler = new Reassembler({
- log: silentLog,
- max: 2,
- now: () => 0,
- onLost: () => undefined,
- timeout: 60_000,
- });
+ const store = reassembler({ max: 2 });
for (const reference of [1, 2, 3]) {
- const collected = collect(reassembler, reference, 1, 2);
+ const collected = collect(store, reference, 1, 2);
assert.ok(collected.kept);
assert.equal(collected.whole, undefined);
}
// Completing the first one must not produce a message: it was evicted.
- const reopened = collect(reassembler, 1, 2, 2);
+ const reopened = collect(store, 1, 2, 2);
assert.ok(reopened.kept);
assert.equal(reopened.whole, undefined);
- assert.equal(reassembler.size, 2);
+ assert.equal(store.size, 2);
- reassembler.clear();
+ store.clear();
});
test('drops the oldest incomplete message once the retained octets exceed the cap', () => {
- const reassembler = new Reassembler({
- log: silentLog,
- max: 10,
- // One segment is 1036 octets: 14 of short_message, the two 11-octet addresses and its object.
- maxOctets: 2100,
- now: () => 0,
- onLost: () => undefined,
- timeout: 60_000,
- });
+ // One segment is 1036 octets: 14 of short_message, the two 11-octet addresses and its object.
+ const store = reassembler({ maxOctets: 2100 });
for (const reference of [1, 2, 3]) {
- const collected = collect(reassembler, reference, 1, 2);
+ const collected = collect(store, reference, 1, 2);
assert.ok(collected.kept);
assert.equal(collected.whole, undefined);
}
- assert.equal(reassembler.size, 2);
+ assert.equal(store.size, 2);
- const reopened = collect(reassembler, 1, 2, 2);
+ const reopened = collect(store, 1, 2, 2);
assert.ok(reopened.kept);
assert.equal(reopened.whole, undefined);
- assert.ok(collect(reassembler, 3, 1, 2).kept);
- assert.equal(reassembler.size, 2, 'a segment sent again replaces its octets rather than adding them');
+ assert.ok(collect(store, 3, 1, 2).kept);
+ assert.equal(store.size, 2, 'a segment sent again replaces its octets rather than adding them');
- reassembler.clear();
+ store.clear();
});
// A retained subarray keeps its whole framed PDU alive, up to maxPduLength per segment.
test('copies a segment out of the buffer it arrived in', () => {
- const reassembler = new Reassembler({
- log: silentLog,
- max: 10,
- now: () => 0,
- onLost: () => undefined,
- timeout: 60_000,
- });
+ const store = reassembler();
const framed = Buffer.alloc(1024);
const first = segment(6, 1, 2);
Buffer.concat([Buffer.from([0x05, 0x00, 0x03, 6, 2, 1]), Buffer.from('fragment')]).copy(framed);
first.params.short_message = framed.subarray(0, 14);
- const held = collectPdu(reassembler, first);
+ const held = collectPdu(store, first);
assert.ok(held.kept);
assert.equal(held.whole, undefined);
framed.fill(0x00);
- const collected = collect(reassembler, 6, 2, 2);
+ const collected = collect(store, 6, 2, 2);
assert.ok(collected.kept);
assert.ok(collected.whole);
@@ -2282,14 +2090,8 @@ describe('reassembly bounds', () => {
test('expires an incomplete message once its timeout has passed', () => {
let now = 0;
- const reassembler = new Reassembler({
- log: silentLog,
- max: 10,
- now: () => now,
- onLost: () => undefined,
- timeout: 60,
- });
- const first = collect(reassembler, 9, 1, 2);
+ const store = reassembler({ now: () => now, timeout: 60 });
+ const first = collect(store, 9, 1, 2);
assert.ok(first.kept);
assert.equal(first.whole, undefined);
@@ -2297,14 +2099,14 @@ describe('reassembly bounds', () => {
now = 61;
// The other half arrives after the group expired, so it starts a new, still-incomplete one.
- const late = collect(reassembler, 9, 2, 2);
+ const late = collect(store, 9, 2, 2);
assert.ok(late.kept);
assert.equal(late.whole, undefined);
assert.notEqual(late.smsId, first.smsId);
- assert.equal(reassembler.size, 1);
+ assert.equal(store.size, 1);
- reassembler.clear();
+ store.clear();
});
});
@@ -2345,12 +2147,12 @@ describe('a peer that sends the next segment only once the last one is answered'
return segments;
}
- async function submit(session: Session, segment: Buffer): Promise<PduObject> {
- const answered = await session.send({
+ async function submit(esme: SmppClient, part: Buffer): Promise<PduObject> {
+ const answered = await esme.send({
cmdName: 'submit_sm',
params: submitSmParams(
{ from: '46701113311', message: text, to: '46709771337' },
- segment,
+ part,
{ encoding: 'ASCII', multipart: true },
),
});
@@ -2361,31 +2163,29 @@ describe('a peer that sends the next segment only once the last one is answered'
return answered.pduObj;
}
- async function submitSerially(session: Session, reference: number): Promise<PduObject[]> {
+ async function submitSerially(esme: SmppClient, reference: number): Promise<PduObject[]> {
const answers: PduObject[] = [];
- for (const segment of segmentsOf(reference)) {
- answers.push(await submit(session, segment));
+ for (const part of segmentsOf(reference)) {
+ answers.push(await submit(esme, part));
}
return answers;
}
test('gets every segment answered as it arrives, and the application one whole message', async t => {
- const smpp = await startServer(t);
const messages: Sms[] = [];
- const incoming = once<Sms>(resolve => {
- smpp.on('session', bound => bound.on('sms', sms => {
+ const handed = handedOver();
+ const smpp = await startServer(t, {
+ onSms: sms => {
messages.push(sms);
- resolve(sms);
- }));
- });
- const { session } = await connect(t, smpp, { responseTimeout: 1000 });
- assert.ok(session);
-
- const answers = await submitSerially(session, 0x2A);
- const sms = await incoming;
+ return handed.onSms(sms);
+ },
+ });
+ const esme = await bound(t, smpp, { responseTimeout: 1000 });
+ const answers = await submitSerially(esme, 0x2A);
+ const sms = await handed.sms;
assert.equal(sms.message, text);
assert.equal(sms.pduObjs.length, answers.length);
@@ -2397,19 +2197,15 @@ describe('a peer that sends the next segment only once the last one is answered'
answers.map((_answer, index) => `${sms.smsId}-${String(index + 1)}`),
);
assert.deepEqual(await sms.sendResp(), {});
+ handed.release();
});
// The documented single-segment contract, which the segment-by-segment answer must not touch.
test('answers a single-segment message only once the application does, with the id it chose', async t => {
- const smpp = await startServer(t);
- const incoming = once<Sms>(resolve => {
- smpp.on('session', bound => bound.on('sms', resolve));
- });
- const { session } = await connect(t, smpp);
-
- assert.ok(session);
-
- const submitted = session.send({
+ const handed = handedOver();
+ const smpp = await startServer(t, { onSms: handed.onSms });
+ const esme = await bound(t, smpp);
+ const submitted = esme.send({
cmdName: 'submit_sm',
params: {
destination_addr: '46709771337',
@@ -2417,7 +2213,7 @@ describe('a peer that sends the next segment only once the last one is answered'
source_addr: '46701113311',
},
});
- const sms = await incoming;
+ const sms = await handed.sms;
assert.equal(await within(150, submitted), undefined, 'nothing may answer for the application');
assert.equal(sms.answeredOnArrival, false);
@@ -2430,20 +2226,17 @@ describe('a peer that sends the next segment only once the last one is answered'
paramText(answered.pduObj.params.message_id),
'0199e0e9-4a3e-7c62-9a4b-1f0c5d7e8a21',
);
+ handed.release();
});
test('refuses an id and a refusing status for segments already on the wire', async t => {
- const smpp = await startServer(t);
- const incoming = once<Sms>(resolve => {
- smpp.on('session', bound => bound.on('sms', resolve));
- });
- const { session } = await connect(t, smpp, { responseTimeout: 1000 });
+ const handed = handedOver();
+ const smpp = await startServer(t, { onSms: handed.onSms });
+ const esme = await bound(t, smpp, { responseTimeout: 1000 });
- assert.ok(session);
+ await submitSerially(esme, 0x2B);
- await submitSerially(session, 0x2B);
-
- const sms = await incoming;
+ const sms = await handed.sms;
const named = await sms.sendResp({ smsId: '0199e0ea-1f3d-7ab4-8c21-6d4e5f0a9b73' });
const refused = await sms.sendResp({ status: 'ESME_RMSGQFUL' });
@@ -2451,26 +2244,42 @@ describe('a peer that sends the next segment only once the last one is answered'
assert.match(refused.err?.message ?? '', /onRequest/);
assert.deepEqual(await sms.sendResp({ status: 'ESME_ROK' }), {});
assert.equal(sms.answeredOnArrival, true);
+ handed.release();
});
- test('reports a half-arrived message it has already answered, and holds nothing after', async t => {
- const smpp = await startServer(t, { reassemblyTimeout: 60 });
- const messages: Sms[] = [];
- const lost = once<Error>(resolve => {
- smpp.on('session', bound => {
- bound.on('sessionError', resolve);
- bound.on('sms', sms => { messages.push(sms); });
+ // The handler's return is the answer, and a segment answered on arrival has none left to give.
+ test('reports a handler that returns an answer for a message answered on arrival', async t => {
+ const reported = latch();
+ const failures: string[] = [];
+ const smpp = await startServer(t, { onSms: () => ({ status: 'ESME_RMSGQFUL' }) });
+
+ smpp.on('session', session => {
+ session.on('sessionError', err => {
+ failures.push(err.message);
+ reported.open();
});
});
- const { session } = await connect(t, smpp, { responseTimeout: 1000 });
- assert.ok(session);
+ const esme = await bound(t, smpp, { responseTimeout: 1000 });
+
+ await submitSerially(esme, 0x2E);
+ await reported.passed;
+ assert.match(failures[0] ?? '', /nothing left to refuse/);
+ });
+
+ test('reports a half-arrived message it has already answered, and holds nothing after', async t => {
+ const messages: Sms[] = [];
+ const smpp = await startServer(t, { onSms: sms => { messages.push(sms); }, reassemblyTimeout: 60 });
+ const lost = once<Error>(resolve => {
+ smpp.on('session', session => { session.on('sessionError', resolve); });
+ });
+ const esme = await bound(t, smpp, { responseTimeout: 1000 });
const [first] = segmentsOf(0x2C);
assert.ok(first);
- const answered = await submit(session, first);
+ const answered = await submit(esme, first);
assert.equal(answered.cmdStatus, 'ESME_ROK');
assert.match((await lost).message, /Gave up 1 of \d+ segments/);
@@ -2478,18 +2287,16 @@ describe('a peer that sends the next segment only once the last one is answered'
assert.deepEqual(await peerOf(smpp).close(), {}, 'a group nothing completed is not held');
});
- test('reports a drop once as disconnected when a listener closes the session over the segments it lost', async t => {
+ // The application closed over the drop, so no retry follows it and the drop is the end.
+ test('reports a drop the application closes over as close alone', async t => {
const smpp = await startServer(t);
- const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } });
-
- assert.ok(session);
-
+ const esme = await bound(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } });
const events: string[] = [];
- const closed = once<true>(resolve => { session.on('close', () => { resolve(true); }); });
+ const closed = once<true>(resolve => { esme.on('close', () => { resolve(true); }); });
- session.on('close', () => { events.push('close'); });
- session.on('disconnected', () => { events.push('disconnected'); });
- session.on('sessionError', () => { void session.close(); });
+ esme.on('close', () => { events.push('close'); });
+ esme.on('disconnected', () => { events.push('disconnected'); });
+ esme.on('sessionError', () => { void esme.close(); });
const [first] = segmentsOf(0x2D);
@@ -2508,20 +2315,16 @@ describe('a peer that sends the next segment only once the last one is answered'
await peerOf(smpp).close();
await closed;
- assert.deepEqual(events, ['disconnected', 'close']);
+ assert.deepEqual(events, ['close']);
});
- test('close() still waits for a concatenated message the application has not answered', async t => {
- const smpp = await startServer(t, { shutdownTimeout: 50 });
- const incoming = once<Sms>(resolve => {
- smpp.on('session', bound => bound.on('sms', resolve));
- });
- const { session } = await connect(t, smpp, { responseTimeout: 1000 });
-
- assert.ok(session);
+ test('close() still waits for a concatenated message the application has not returned from', async t => {
+ const handed = handedOver();
+ const smpp = await startServer(t, { onSms: handed.onSms, shutdownTimeout: 50 });
+ const esme = await bound(t, smpp, { responseTimeout: 1000 });
- await submitSerially(session, 0x2D);
- await incoming;
+ await submitSerially(esme, 0x2D);
+ await handed.sms;
const closed = await peerOf(smpp).close();
@@ -2531,15 +2334,10 @@ describe('a peer that sends the next segment only once the last one is answered'
// pduObjs.length is 1 either way here, so answeredOnArrival is the only thing that can say.
test('marks a one-part concatenated message answered, as its segment count cannot', async t => {
- const smpp = await startServer(t);
- const incoming = once<Sms>(resolve => {
- smpp.on('session', bound => bound.on('sms', resolve));
- });
- const { session } = await connect(t, smpp, { responseTimeout: 1000 });
-
- assert.ok(session);
-
- const answered = await session.send({
+ const handed = handedOver();
+ const smpp = await startServer(t, { onSms: handed.onSms });
+ const esme = await bound(t, smpp, { responseTimeout: 1000 });
+ const answered = await esme.send({
cmdName: 'submit_sm',
params: {
destination_addr: '46709771337',
@@ -2551,7 +2349,7 @@ describe('a peer that sends the next segment only once the last one is answered'
source_addr: '46701113311',
},
});
- const sms = await incoming;
+ const sms = await handed.sms;
assert.equal(answered.err, undefined);
assert.ok(answered.pduObj);
@@ -2560,20 +2358,15 @@ describe('a peer that sends the next segment only once the last one is answered'
assert.equal(sms.pduObjs.length, 1);
assert.equal(sms.answeredOnArrival, true);
assert.deepEqual(await sms.sendResp(), {});
+ handed.release();
});
// esm_class said there was a UDH, and there is no group its concatenation fields can join.
test('answers a segment whose UDH cannot be honoured rather than leaving the peer waiting', async t => {
- const smpp = await startServer(t);
const messages: Sms[] = [];
-
- smpp.on('session', bound => bound.on('sms', sms => { messages.push(sms); }));
-
- const { session } = await connect(t, smpp, { responseTimeout: 1000 });
-
- assert.ok(session);
-
- const answered = await session.send({
+ const smpp = await startServer(t, { onSms: sms => { messages.push(sms); } });
+ const esme = await bound(t, smpp, { responseTimeout: 1000 });
+ const answered = await esme.send({
cmdName: 'submit_sm',
params: {
destination_addr: '46709771337',
@@ -2594,16 +2387,10 @@ describe('a peer that sends the next segment only once the last one is answered'
// Its esm_class is 0x00 and correct, so the refusal names the optional parameters instead.
test('refuses a sar_* segment the TLVs number impossibly by naming those TLVs', async t => {
- const smpp = await startServer(t);
const messages: Sms[] = [];
-
- smpp.on('session', bound => bound.on('sms', sms => { messages.push(sms); }));
-
- const { session } = await connect(t, smpp, { responseTimeout: 1000 });
-
- assert.ok(session);
-
- const answered = await session.send({
+ const smpp = await startServer(t, { onSms: sms => { messages.push(sms); } });
+ const esme = await bound(t, smpp, { responseTimeout: 1000 });
+ const answered = await esme.send({
cmdName: 'submit_sm',
params: {
destination_addr: '46709771337',
@@ -2639,17 +2426,17 @@ describe('AbortSignal on a send', () => {
const address = silent.address();
const port = typeof address === 'object' && address !== null ? address.port : 0;
- const { err, session } = await client({ port, responseTimeout: 10_000 });
+ const { err, client: esme } = await client({ port, responseTimeout: 10_000 });
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
const controller = new AbortController();
setTimeout(() => { controller.abort(); }, 50);
- const sent = await session.sendSms(
+ const sent = await esme.sendSms(
{ from: '46701113311', message: 'never answered', to: '46709771337' },
{ signal: controller.signal },
);
@@ -2657,32 +2444,32 @@ describe('AbortSignal on a send', () => {
assert.ok(sent.err instanceof Error);
});
- /** The peer answers nothing, so the single slot stays taken for the life of the test. */
+ /** The peer never returns from its handler, so the single slot stays taken for the life of the test. */
async function oneSlotHeld(
t: TestContext,
options: Parameters<typeof client>[0] = {},
- ): Promise<Session> {
- const smpp = await startServer(t);
- const onWire = once<PduObject>(resolve => {
- smpp.on('session', bound => bound.on('incomingPduObj', resolve));
- });
-
- smpp.on('session', bound => bound.on('sms', () => undefined));
+ ): Promise<SmppClient> {
+ const onWire = latch();
+ const smpp = await startServer(t, {
+ onSms: () => {
+ onWire.open();
- const { session } = await connect(t, smpp, { maxOutstanding: 1, ...options });
+ return neverReturns();
+ },
+ });
+ const esme = await bound(t, smpp, { maxOutstanding: 1, ...options });
- assert.ok(session);
- void session.sendSms({ from: '46701113311', message: 'holds the only slot', to: '46709771337' });
+ void esme.sendSms({ from: '46701113311', message: 'holds the only slot', to: '46709771337' });
- await onWire;
+ await onWire.passed;
- return session;
+ return esme;
}
test('gives up on a send still queued behind a full window', async t => {
- const session = await oneSlotHeld(t, { responseTimeout: 10_000 });
+ const esme = await oneSlotHeld(t, { responseTimeout: 10_000 });
const controller = new AbortController();
- const queued = session.sendSms(
+ const queued = esme.sendSms(
{ from: '46701113311', message: 'queued behind the held slot', to: '46709771337' },
{ signal: controller.signal },
);
@@ -2698,9 +2485,9 @@ describe('AbortSignal on a send', () => {
});
test('gives up on a queued send where responseTimeout: 0 never would', async t => {
- const session = await oneSlotHeld(t, { responseTimeout: 0 });
+ const esme = await oneSlotHeld(t, { responseTimeout: 0 });
const controller = new AbortController();
- const queued = session.sendSms(
+ const queued = esme.sendSms(
{ from: '46701113311', message: 'queued with nothing else to end the wait', to: '46709771337' },
{ signal: controller.signal },
);
@@ -2716,26 +2503,24 @@ describe('AbortSignal on a send', () => {
});
test('leaves the freed slot to the next send rather than to the waiter that gave up', async t => {
- const smpp = await startServer(t);
- const holding = once<Sms>(resolve => { smpp.on('session', bound => bound.on('sms', resolve)); });
+ const handed = handedOver();
let firstTaken = false;
-
- smpp.on('session', bound => {
- bound.on('sms', async sms => {
- if (firstTaken) await sms.sendResp();
+ const smpp = await startServer(t, {
+ onSms: sms => {
+ if (firstTaken) return undefined;
firstTaken = true;
- });
- });
- const { session } = await connect(t, smpp, { maxOutstanding: 1, responseTimeout: 10_000 });
+ return handed.onSms(sms);
+ },
+ });
+ const esme = await bound(t, smpp, { maxOutstanding: 1, responseTimeout: 10_000 });
- assert.ok(session);
- void session.sendSms({ from: '46701113311', message: 'holds the only slot', to: '46709771337' });
+ void esme.sendSms({ from: '46701113311', message: 'holds the only slot', to: '46709771337' });
- const held = await holding;
+ const held = await handed.sms;
const controller = new AbortController();
- const abandoned = session.sendSms(
+ const abandoned = esme.sendSms(
{ from: '46701113311', message: 'abandoned in the queue', to: '46709771337' },
{ signal: controller.signal },
);
@@ -2749,8 +2534,9 @@ describe('AbortSignal on a send', () => {
// Any other error means it never reached the queue, so there was no waiter to strand.
assert.match(gaveUp.err?.message ?? '', /Aborted while waiting for a send window slot/);
await held.sendResp();
+ handed.release();
- const following = await within(1000, session.sendSms({
+ const following = await within(1000, esme.sendSms({
from: '46701113311',
message: 'takes the freed slot',
to: '46709771337',
@@ -2762,29 +2548,25 @@ describe('AbortSignal on a send', () => {
});
describe('graceful shutdown', () => {
+ /** A submit whose handler is running, and stays running until the test releases it. */
async function submitInFlight(
t: TestContext,
options: Parameters<typeof client>[0] = {},
serverOptions: Parameters<typeof server>[0] = {},
message = 'answer me',
) {
- const smpp = await startServer(t, serverOptions);
- const incoming = once<Sms>(resolve => {
- smpp.on('session', bound => bound.on('sms', resolve));
- });
- const { session } = await connect(t, smpp, options);
+ const handed = handedOver();
+ const smpp = await startServer(t, { onSms: handed.onSms, ...serverOptions });
+ const esme = await bound(t, smpp, options);
+ const sent = esme.sendSms({ from: '46701113311', message, to: '46709771337' });
- assert.ok(session);
-
- const sent = session.sendSms({ from: '46701113311', message, to: '46709771337' });
-
- return { sent, session, sms: await incoming, smpp };
+ return { esme, release: handed.release, sent, sms: await handed.sms, smpp };
}
test('close() waits out a submit already on the wire and refuses new ones', async t => {
- const { sent, session, sms } = await submitInFlight(t);
- const closed = session.close();
- const refused = await session.sendSms({
+ const { esme, release, sent, sms } = await submitInFlight(t);
+ const closed = esme.close();
+ const refused = await esme.sendSms({
from: '46701113311',
message: 'too late',
to: '46709771337',
@@ -2794,6 +2576,7 @@ describe('graceful shutdown', () => {
assert.equal(refused.err.message, 'Session is shutting down');
await sms.sendResp({ smsId: 'answered-while-draining' });
+ release();
assert.deepEqual((await sent).smsIds, ['answered-while-draining']);
assert.deepEqual(await closed, {});
@@ -2801,41 +2584,44 @@ describe('graceful shutdown', () => {
// The drain refuses sends; a response was never a send, and saying so is the more useful answer.
test('names a response put through send() as the misuse it is, even mid-shutdown', async t => {
- const { sent, session, sms } = await submitInFlight(t);
- const closing = session.close();
- const refused = await session.send({ cmdName: 'submit_sm_resp' });
+ const { esme, release, sent, sms } = await submitInFlight(t);
+ const closing = esme.close();
+ const refused = await esme.send({ cmdName: 'submit_sm_resp' });
assert.ok(refused.err instanceof Error);
assert.match(refused.err.message, /Use sendReturn\(\)/);
await sms.sendResp({ smsId: 'answered-after-the-misuse' });
+ release();
assert.deepEqual((await sent).smsIds, ['answered-after-the-misuse']);
assert.deepEqual(await closing, {});
});
test('unbind() waits out a submit already on the wire before it unbinds', async t => {
- const { sent, session, sms } = await submitInFlight(t);
- const unbound = session.unbind();
+ const { esme, release, sent, sms } = await submitInFlight(t);
+ const unbound = esme.unbind();
await sms.sendResp({ smsId: 'answered-before-unbind' });
+ release();
assert.deepEqual((await sent).smsIds, ['answered-before-unbind']);
assert.deepEqual(await unbound, {});
});
- test('close() waits for a message the application has not answered yet', async t => {
- const { sent, smpp, sms } = await submitInFlight(t);
+ test('close() waits for a handler that has not returned yet', async t => {
+ const { release, sent, smpp, sms } = await submitInFlight(t);
const closing = peerOf(smpp).close();
await delay(50);
await sms.sendResp({ smsId: 'answered-during-the-inbound-drain' });
+ release();
assert.deepEqual(await closing, {});
assert.deepEqual((await sent).smsIds, ['answered-during-the-inbound-drain']);
});
- test('gives up on a message the application never answers', async t => {
+ test('gives up on a handler that never returns', async t => {
const { smpp } = await submitInFlight(t, {}, { shutdownTimeout: 50 });
const closed = await peerOf(smpp).close();
@@ -2843,50 +2629,36 @@ describe('graceful shutdown', () => {
assert.match(closed.err.message, /1 message\(s\) unanswered/);
});
- // Waiting forever is safe for the peer, which every request times out on. The application is not.
- test('falls back to responseTimeout for a held message when the shutdown waits forever', async t => {
- const { smpp } = await submitInFlight(t, {}, { responseTimeout: 200, shutdownTimeout: 0 });
- const started = Date.now();
- const closed = await peerOf(smpp).close();
- const waited = Date.now() - started;
-
- assert.ok(closed.err instanceof Error);
- assert.match(closed.err.message, /1 message\(s\) unanswered/);
- assert.ok(waited >= 190, `waited ${String(waited)} ms, so the fallback was not what bounded it`);
- assert.ok(waited < 2000);
- });
-
// leftOf() floors what is left at 1 ms: at 0 the request half would read "wait forever" instead.
- test('still ends when the message half has spent the whole shutdown budget', async t => {
- const { smpp } = await submitInFlight(t, {}, { shutdownTimeout: 100 });
- const bound = peerOf(smpp);
- // The client listens for no 'sms', so this one is never answered and stays in the window.
- const unanswered = bound.send({
- cmdName: 'submit_sm',
+ test('still ends when the handlers have spent the whole shutdown budget', async t => {
+ const { esme, smpp } = await submitInFlight(t, { onSms: neverReturns }, { shutdownTimeout: 100 });
+ const peer = peerOf(smpp);
+ // The client's handler never returns, so this one stays in the server's window.
+ const unanswered = peer.send({
+ cmdName: 'deliver_sm',
params: {
destination_addr: '46701113311',
short_message: 'nothing answers this',
source_addr: '46709771337',
},
});
- const closed = await Promise.race([
- bound.close(),
- new Promise<{ err?: Error }>(resolve => {
- setTimeout(() => { resolve({ err: new Error('close() never returned') }); }, 2000).unref();
- }),
- ]);
+ await delay(20);
+
+ const closed = await within(2000, peer.close());
+
+ assert.ok(closed, 'close() never returned');
assert.match(closed.err?.message ?? '', /1 message\(s\) unanswered; .*1 request\(s\) unfinished/);
assert.ok((await unanswered).err instanceof Error);
+ assert.ok(esme);
});
- // The README's own listener answers and then sends its receipt, one turn later. Multipart, because
- // a receipt sent one-after-a-response outruns that turn on every segment past the first.
+ // Multipart, because a receipt sent one-after-a-response outruns a drain on every segment past the first.
test('a receipt sent right after the response still goes out mid-drain', async t => {
- const { sent, session, smpp, sms } = await submitInFlight(t, {}, {}, 'x'.repeat(400));
+ const { esme, release, sent, smpp, sms } = await submitInFlight(t, {}, {}, 'x'.repeat(400));
const received: Dlr[] = [];
const receipts = once<Dlr[]>(resolve => {
- session.on('dlr', dlr => {
+ esme.on('dlr', dlr => {
received.push(dlr);
if (received.length === 3) resolve(received);
@@ -2899,93 +2671,52 @@ describe('graceful shutdown', () => {
const receiptSent = await sms.sendDlr('DELIVERED');
const ids = [1, 2, 3].map(part => `${sms.smsId}-${String(part)}`);
+ release();
assert.equal(receiptSent.err, undefined);
assert.deepEqual((await receipts).map(dlr => dlr.smsId), ids);
assert.deepEqual(await closing, {});
assert.deepEqual((await sent).smsIds, ids);
});
- test('a message no listener took does not hold the shutdown up', async t => {
+ test('a message no handler took is refused at once and holds the shutdown up for nothing', async t => {
const smpp = await startServer(t, { shutdownTimeout: 30_000 });
- const { session } = await connect(t, smpp);
-
- assert.ok(session);
-
- const bound = peerOf(smpp);
- const arrived = once<PduObject>(resolve => {
- bound.on('incomingPduObj', pduObj => {
- if (pduObj.cmdName === 'submit_sm') resolve(pduObj);
- });
- });
- const sent = session.sendSms({
+ const esme = await bound(t, smpp);
+ const sent = await esme.sendSms({
from: '46701113311',
message: 'nobody is listening',
to: '46709771337',
});
- await arrived;
- await delay(50);
+ assert.match(sent.err?.message ?? '', /ESME_RTHROTTLED/, 'the peer keeps a message nothing took');
const started = Date.now();
- assert.deepEqual(await bound.close(), {});
+ assert.deepEqual(await peerOf(smpp).close(), {});
assert.ok(Date.now() - started < 1000);
- assert.ok((await sent).err instanceof Error);
});
- // emit() releases the hold of a listener that throws; one that rejects may cost no more than that.
- test('a listener that rejected before answering does not hold the shutdown up', async t => {
- const smpp = await startServer(t, { shutdownTimeout: 30_000 });
+ test('a handler that rejected before answering refuses the message and does not hold the shutdown up', async t => {
+ const smpp = await startServer(t, {
+ onSms: () => Promise.reject(new Error('the handler gave up')),
+ shutdownTimeout: 30_000,
+ });
const failed = once<Error>(resolve => {
- smpp.on('session', bound => {
- bound.on('sessionError', resolve);
- bound.on('sms', () => Promise.reject(new Error('the listener gave up')));
- });
+ smpp.on('session', session => { session.on('sessionError', resolve); });
});
- const { session } = await connect(t, smpp);
-
- assert.ok(session);
-
- const sent = session.sendSms({
+ const esme = await bound(t, smpp);
+ const sent = await esme.sendSms({
from: '46701113311',
- message: 'the listener rejects',
+ message: 'the handler rejects',
to: '46709771337',
});
- assert.equal((await failed).message, 'the listener gave up');
+ assert.equal((await failed).message, 'the handler gave up');
+ assert.match(sent.err?.message ?? '', /ESME_RTHROTTLED/);
const started = Date.now();
assert.deepEqual(await peerOf(smpp).close(), {});
assert.ok(Date.now() - started < 1000);
- assert.ok((await sent).err instanceof Error);
- });
-
- test('waits for the listener still working when another one rejected', async t => {
- const smpp = await startServer(t, { shutdownTimeout: 30_000 });
- const failed = once<Error>(resolve => {
- smpp.on('session', bound => {
- bound.on('sessionError', resolve);
- bound.on('sms', async sms => {
- await delay(100);
- await sms.sendResp({ smsId: 'answered-after-the-other-gave-up' });
- });
- bound.on('sms', () => Promise.reject(new Error('the audit listener gave up')));
- });
- });
- const { session } = await connect(t, smpp);
-
- assert.ok(session);
-
- const sent = session.sendSms({
- from: '46701113311',
- message: 'two listeners, one gives up',
- to: '46709771337',
- });
-
- assert.equal((await failed).message, 'the audit listener gave up');
- assert.deepEqual(await peerOf(smpp).close(), {});
- assert.deepEqual((await sent).smsIds, ['answered-after-the-other-gave-up']);
});
// Nothing reached the peer, so a drain counting this answered would report an outcome that never was.
@@ -3000,8 +2731,8 @@ describe('graceful shutdown', () => {
});
test('gives up on a request that outlasts shutdownTimeout', async t => {
- const { sent, session } = await submitInFlight(t, { shutdownTimeout: 50 });
- const closed = await session.close();
+ const { esme, sent } = await submitInFlight(t, { shutdownTimeout: 50 });
+ const closed = await esme.close();
assert.ok(closed.err instanceof Error);
assert.match(closed.err.message, /unfinished/);
@@ -3015,11 +2746,11 @@ describe('graceful shutdown', () => {
// The window empties on a drop as well as on an answer, so it cannot be what the result reads.
test('reports a link that dropped mid-drain rather than calling it a clean shutdown', async t => {
- const { sent, session, smpp } = await submitInFlight(t);
- const closing = session.close();
+ const { esme, sent, smpp } = await submitInFlight(t);
+ const closing = esme.close();
- for (const bound of smpp.sessions) {
- bound.sock.destroy();
+ for (const peer of smpp.sessions) {
+ peer.sock.destroy();
}
const closed = await closing;
@@ -3031,29 +2762,29 @@ describe('graceful shutdown', () => {
// The queued segments are the whole reason the drain waits on the window and not on the pending map.
test('counts the segments still queued behind a full window', async t => {
- // No 'sms' listener, so the single-segment message holding the only slot is never answered.
- const smpp = await startServer(t);
- const onWire = once<PduObject>(resolve => {
- smpp.on('session', bound => bound.on('incomingPduObj', resolve));
- });
- const { session } = await connect(t, smpp, { maxOutstanding: 1, shutdownTimeout: 50 });
-
- assert.ok(session);
+ const onWire = latch();
+ const smpp = await startServer(t, {
+ onSms: () => {
+ onWire.open();
- const holding = session.sendSms({
+ return neverReturns();
+ },
+ });
+ const esme = await bound(t, smpp, { maxOutstanding: 1, shutdownTimeout: 50 });
+ const holding = esme.sendSms({
from: '46701113311',
message: 'holds the only slot',
to: '46709771337',
});
- const queued = session.sendSms({
+ const queued = esme.sendSms({
from: '46701113311',
message: 'x'.repeat(400),
to: '46709771337',
});
- await onWire;
+ await onWire.passed;
- const closed = await session.close();
+ const closed = await esme.close();
assert.ok(closed.err instanceof Error);
assert.match(closed.err.message, /4 request\(s\)/);
@@ -3062,17 +2793,18 @@ describe('graceful shutdown', () => {
});
test('an aborted close tears down at once instead of waiting out the drain', async t => {
- const { sent, session } = await submitInFlight(t, { shutdownTimeout: 30_000 });
+ const { esme, sent } = await submitInFlight(t, { shutdownTimeout: 30_000 });
const controller = new AbortController();
+ const sock = linkOf(esme).sock;
const started = Date.now();
controller.abort();
- const closed = await session.close({ signal: controller.signal });
+ const closed = await esme.close({ signal: controller.signal });
assert.ok(Date.now() - started < 1000);
assert.ok(closed.err instanceof Error);
- assert.ok(session.sock.destroyed);
+ assert.ok(sock.destroyed);
assert.ok((await sent).err instanceof Error);
});
@@ -3084,8 +2816,8 @@ describe('graceful shutdown', () => {
t.after(() => { silent.destroy(); });
silent.resume();
- const bound = await arrived;
- const unanswered = bound.send({ cmdName: 'enquire_link' });
+ const peer = await arrived;
+ const unanswered = peer.send({ cmdName: 'enquire_link' });
const closing = smpp.close();
const late = await new Promise<boolean>(resolve => {
const sock = net.connect({ port: smpp.port });
@@ -3111,9 +2843,9 @@ describe('graceful shutdown', () => {
t.after(() => { peer.destroy(); });
peer.resume();
- const bound = await arrived;
- const ended = once<true>(resolve => { bound.on('close', () => { resolve(true); }); });
- const unanswered = bound.send({ cmdName: 'enquire_link' });
+ const session = await arrived;
+ const ended = once<true>(resolve => { session.on('close', () => { resolve(true); }); });
+ const unanswered = session.send({ cmdName: 'enquire_link' });
const { buffer } = objToPdu({ cmdName: 'unbind', seqNr: 1 });
assert.ok(buffer);
@@ -3123,107 +2855,74 @@ describe('graceful shutdown', () => {
assert.ok((await unanswered).err instanceof Error);
});
- test('does not report a reconnect on a session closed while it was coming back up', async t => {
- const accepted: net.Socket[] = [];
- const listener = net.createServer(sock => { accepted.push(sock); sock.resume(); });
-
- await new Promise<void>(resolve => { listener.listen(0, resolve); });
-
- const address = listener.address();
- const port = typeof address === 'object' && address !== null ? address.port : 0;
- const opened: net.Socket[] = [];
- const open = (): Promise<Result<{ sock: net.Socket }>> => new Promise(resolve => {
- const sock = net.connect({ port }, () => { resolve({ sock }); });
-
- opened.push(sock);
- });
- const first = await open();
-
- assert.ok(first.sock);
-
+ test('does not report a reconnect on a client closed while it was coming back up', async t => {
const rebinding = latch();
const release = latch();
- const session = new Session({
- reconnect: {
- connect: open,
- maxDelay: 20,
- minDelay: 10,
- onConnected: async () => {
+ let binds = 0;
+ const smpp = await startServer(t, {
+ authenticate: async () => {
+ binds++;
+
+ if (binds > 1) {
rebinding.open();
await release.passed;
+ }
- return {};
- },
+ return true;
},
- sock: first.sock,
});
+ const esme = await bound(t, smpp, { reconnect: { maxDelay: 20, minDelay: 10 } });
const reported: string[] = [];
- closeAfter(t, session);
- t.after(() => { for (const sock of opened) sock.destroy(); });
- closeListenerAfter(t, listener, accepted);
- session.on('reconnected', () => reported.push('reconnected'));
- first.sock.destroy();
+ esme.on('reconnected', () => reported.push('reconnected'));
+ peerOf(smpp).sock.destroy();
assert.ok(await rebinding.passed);
- const closing = session.close();
+ const closing = esme.close();
release.open();
await closing;
await delay(50);
assert.deepEqual(reported, []);
+ assert.ok(await waitFor(() => smpp.sessions.size === 0), 'the session that came up too late was closed');
});
- test('answers a peer\'s unbind before asking the session to end', async t => {
+ test('answers a peer\'s unbind before the session ends', async t => {
const session = new Session({ sock: new net.Socket() });
closeAfter(t, session);
const calls: string[] = [];
- const incoming = incomingOn(session);
- const close = session.close.bind(session);
session.sendReturn = pduObj => {
calls.push(pduObj.cmdName);
+ calls.push(session.sock.destroyed ? 'destroyed' : 'open');
return Promise.resolve({});
};
- session.close = options => {
- calls.push('close');
+ arrives(session, { cmdName: 'unbind', seqNr: 1 });
- return close(options);
- };
-
- await incoming.handle({ ...submitPdu(1), cmdId: 0x00000006, cmdName: 'unbind', params: {} });
-
- assert.deepEqual(calls, ['unbind', 'close']);
+ assert.ok(await waitFor(() => session.sock.destroyed));
+ assert.deepEqual(calls, ['unbind', 'open']);
});
});
describe('message id notation', () => {
- async function sendOne(session: Session, message: string): Promise<SendSmsResult> {
- return session.sendSms({ dlr: true, from: '46701113311', message, to: '46709771337' });
+ async function sendOne(esme: SmppClient, message: string): Promise<SendSmsResult> {
+ return esme.sendSms({ dlr: true, from: '46701113311', message, to: '46709771337' });
}
test('correlates a hex submit_sm_resp against a decimal receipt', async t => {
- const smpp = await startServer(t);
-
- smpp.on('session', bound => {
- bound.on('sms', sms => { void sms.sendResp({ smsId: '1a2b' }); });
- });
-
- const { session } = await connect(t, smpp, {
+ const smpp = await startServer(t, { onSms: () => ({ smsId: '1a2b' }) });
+ const esme = await bound(t, smpp, {
smsIdFormat: { receipt: 'decimal', submitResp: 'hex' },
});
-
- assert.ok(session);
-
const reported = once<[Dlr, PduObject]>(resolve => {
- session.on('dlr', (dlr, pduObj) => { resolve([dlr, pduObj]); });
+ esme.on('dlr', (dlr, pduObj) => { resolve([dlr, pduObj]); });
});
- const sent = await sendOne(session, 'one segment');
+ const sent = await sendOne(esme, 'one segment');
assert.deepEqual(sent.smsIds, ['6699']);
assert.equal(paramText(sent.pduObjs[0]?.params.message_id), '1a2b', 'the PDU keeps the id it carried');
@@ -3238,22 +2937,18 @@ describe('message id notation', () => {
});
test('leaves the segment ids of a multipart send to merge as they are', async t => {
- const smpp = await startServer(t);
- const incoming = once<Sms>(resolve => {
- smpp.on('session', bound => bound.on('sms', sms => {
- resolve(sms);
- void sms.sendResp();
- }));
- });
- const { session } = await connect(t, smpp, {
+ const handed = handedOver();
+ const smpp = await startServer(t, { onSms: handed.onSms });
+ const esme = await bound(t, smpp, {
smsIdFormat: { receipt: 'decimal', submitResp: 'hex' },
});
+ const merged = once<MessageDlr>(resolve => { esme.on('messageDlr', resolve); });
+ const sending = sendOne(esme, 'x'.repeat(200));
+ const sms = await handed.sms;
- assert.ok(session);
+ handed.release();
- const merged = once<MessageDlr>(resolve => { session.on('messageDlr', resolve); });
- const sent = await sendOne(session, 'x'.repeat(200));
- const sms = await incoming;
+ const sent = await sending;
assert.deepEqual(sent.smsIds, [1, 2].map(part => `${sms.smsId}-${String(part)}`));
@@ -3279,3 +2974,79 @@ describe('message id notation', () => {
assert.equal(checkSessionOptions({ smsIdFormat: { submitResp: 'hex' } }).err, undefined);
});
});
+
+describe('the onSms contract', () => {
+ async function submitted(t: TestContext, onSms: OnSms | undefined): Promise<{ esme: SmppClient; sent: SendSmsResult; smpp: SmppServer }> {
+ const smpp = await startServer(t, onSms ? { onSms } : {});
+ const esme = await bound(t, smpp);
+ const sent = await esme.sendSms({ from: '46701113311', message: 'answered by the handler', to: '46709771337' });
+
+ return { esme, sent, smpp };
+ }
+
+ test('answers ESME_ROK under a generated id when the handler returns nothing', async t => {
+ let seen: Sms | undefined;
+ const { sent } = await submitted(t, sms => { seen = sms; });
+
+ assert.equal(sent.err, undefined);
+ assert.ok(seen);
+ assert.deepEqual(sent.smsIds, [seen.smsId]);
+ assert.match(seen.smsId, /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
+ });
+
+ test('answers with the id or the status the handler returns', async t => {
+ const named = await submitted(t, () => ({ smsId: '0199e0eb-9d42-7bc6-8e70-51af3c92d6b8' }));
+
+ assert.deepEqual(named.sent.smsIds, ['0199e0eb-9d42-7bc6-8e70-51af3c92d6b8']);
+
+ const refused = await submitted(t, () => Promise.resolve({ status: 'ESME_RINVDSTADR' }));
+
+ assert.match(refused.sent.err?.message ?? '', /ESME_RINVDSTADR/);
+ });
+
+ test('refuses with the retry status when the handler throws, and reports it', async t => {
+ const failures: string[] = [];
+ const smpp = await startServer(t, { onSms: () => { throw new Error('the handler exploded'); } });
+
+ smpp.on('session', session => { session.on('sessionError', err => { failures.push(err.message); }); });
+
+ const esme = await bound(t, smpp);
+ const sent = await esme.sendSms({ from: '46701113311', message: 'throws', to: '46709771337' });
+
+ assert.match(sent.err?.message ?? '', /ESME_RTHROTTLED/);
+ assert.deepEqual(failures, ['the handler exploded']);
+ });
+
+ test('keeps the answer sendResp() gave, and reports a contradicting return', async t => {
+ const failures: string[] = [];
+ const smpp = await startServer(t, {
+ onSms: async sms => {
+ await sms.sendResp({ smsId: 'answered-early' });
+
+ return { smsId: 'answered-late' };
+ },
+ });
+
+ smpp.on('session', session => { session.on('sessionError', err => { failures.push(err.message); }); });
+
+ const esme = await bound(t, smpp);
+ const sent = await esme.sendSms({ from: '46701113311', message: 'answered twice', to: '46709771337' });
+
+ assert.deepEqual(sent.smsIds, ['answered-early']);
+ assert.ok(await waitFor(() => failures.length === 1));
+ assert.match(failures[0] ?? '', /already answered/);
+ });
+
+ test('refuses a delivery with the receiver\'s retry status when a client has no handler', async t => {
+ const smpp = await startServer(t);
+ const esme = await bound(t, smpp);
+ const delivered = await peerOf(smpp).send({
+ cmdName: 'deliver_sm',
+ params: { destination_addr: '46701113311', short_message: 'nobody home', source_addr: '46709771337' },
+ });
+
+ assert.ok(delivered.pduObj);
+ assert.equal(delivered.pduObj.cmdStatus, 'ESME_RX_T_APPN');
+ assert.ok(esme);
+ });
+});
diff --git a/test/session.test.ts b/test/session.test.ts
index 1f31f0b..713115e 100644
--- a/test/session.test.ts
+++ b/test/session.test.ts
@@ -3,16 +3,17 @@ import net from 'node:net';
import test, { describe } from 'node:test';
import type { Dlr } from '../src/dlr.ts';
import type { PduObject, PduObjectInput } from '../src/pdu.ts';
+import type { OnSms } from '../src/session-options.ts';
import type { Sms } from '../src/sms.ts';
+import type { SmppClient } from '../src/client.ts';
import type { ServerOptions, SmppServer } from '../src/server.ts';
import type { SmppLog } from '../src/log.ts';
import type { TestContext } from 'node:test';
-import type { VoidResult } from '../src/result.ts';
import { DlrMerger } from '../src/dlr-merger.ts';
import { PduFramer } from '../src/pdu-framer.ts';
import { ReconnectLoop } from '../src/reconnect-loop.ts';
-import { Session, bindCommands } from '../src/session.ts';
-import { checkSessionOptions } from '../src/session-options.ts';
+import { Session } from '../src/session.ts';
+import { bindCommands, checkSessionOptions } from '../src/session-options.ts';
import { client } from '../src/client.ts';
import { closeAfter, closeListenerAfter } from './teardown.ts';
import { consts } from '../src/defs/constants.ts';
@@ -44,7 +45,7 @@ async function connect(
) {
const connected = await client({ port: smpp.port, ...options });
- if (connected.session) closeAfter(t, connected.session);
+ if (connected.client) closeAfter(t, connected.client);
return connected;
}
@@ -53,6 +54,43 @@ function once<T>(register: (resolve: (value: T) => void) => void): Promise<T> {
return new Promise<T>(resolve => { register(resolve); });
}
+/** The bound client, asserted. */
+async function bound(
+ t: TestContext,
+ smpp: SmppServer,
+ options: Parameters<typeof client>[0] = {},
+): Promise<SmppClient> {
+ const { err, client: esme } = await connect(t, smpp, options);
+
+ assert.equal(err, undefined);
+ assert.ok(esme);
+
+ return esme;
+}
+
+/** The client's bound session, asserted. */
+function linkOf(esme: SmppClient): Session {
+ const session = esme.session;
+
+ assert.ok(session, 'the client has no bound session');
+
+ return session;
+}
+
+/** A handler that hands the message to the test and answers ESME_ROK once the test lets it. */
+function handedOver(answer: (sms: Sms) => Promise<unknown> = () => Promise.resolve()): { onSms: OnSms; sms: Promise<Sms> } {
+ const handed: { resolve?: (sms: Sms) => void } = {};
+ const sms = once<Sms>(resolve => { handed.resolve = resolve; });
+
+ return {
+ onSms: async received => {
+ handed.resolve?.(received);
+ await answer(received);
+ },
+ sms,
+ };
+}
+
function delay(ms: number): Promise<void> {
return new Promise(resolve => { setTimeout(resolve, ms); });
}
@@ -225,33 +263,32 @@ async function bindRaw(t: TestContext, smpp: SmppServer, interfaceVersion: numbe
describe('bind', () => {
test('binds and unbinds against a server with no auth', async t => {
const smpp = await startServer(t);
- const { err, session } = await connect(t, smpp);
+ const { err, client: esme } = await connect(t, smpp);
assert.equal(err, undefined);
- assert.ok(session);
- assert.equal(session.boundAs, 'transceiver');
+ assert.ok(esme);
+ assert.equal(linkOf(esme).boundAs, 'transceiver');
- assert.deepEqual(await session.unbind(), {});
+ assert.deepEqual(await esme.unbind(), {});
});
- // Plenty of SMSCs drop the connection on unbind instead of answering it.
test('takes a close that follows our unbind as a clean unbind', async t => {
const peer = await smscPeer(t, { dropOn: 'unbind' });
- const { session } = await client({ port: peer.port, responseTimeout: 2000 });
+ const { client: esme } = await client({ port: peer.port, responseTimeout: 2000 });
- assert.ok(session);
- closeAfter(t, session);
- assert.deepEqual(await session.unbind(), {});
+ assert.ok(esme);
+ closeAfter(t, esme);
+ assert.deepEqual(await esme.unbind(), {});
});
test('still reports a close that lands on another in-flight request', async t => {
const peer = await smscPeer(t, { dropOn: 'enquire_link' });
- const { session } = await client({ port: peer.port, responseTimeout: 2000 });
+ const { client: esme } = await client({ port: peer.port, responseTimeout: 2000 });
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
- const sent = await session.send({ cmdName: 'enquire_link' });
+ const sent = await esme.send({ cmdName: 'enquire_link' });
assert.ok(sent.err instanceof Error);
assert.match(sent.err.message, /may have accepted.*Session closed before a response arrived/);
@@ -259,11 +296,11 @@ describe('bind', () => {
test('reports an unbind the peer left unanswered on a link that stays up', async t => {
const peer = await smscPeer(t);
- const { session } = await client({ port: peer.port, responseTimeout: 150 });
+ const { client: esme } = await client({ port: peer.port, responseTimeout: 150 });
- assert.ok(session);
- closeAfter(t, session);
- assert.ok((await session.unbind()).err instanceof Error);
+ assert.ok(esme);
+ closeAfter(t, esme);
+ assert.ok((await esme.unbind()).err instanceof Error);
});
test('reports the resolved port when 0 was requested', async t => {
@@ -276,10 +313,10 @@ describe('bind', () => {
const smpp = await startServer(t, {
authenticate: ({ password, systemId }) => systemId === 'foo' && password === 'bar',
});
- const { err, session } = await connect(t, smpp);
+ const { err, client: esme } = await connect(t, smpp);
assert.ok(err instanceof Error);
- assert.equal(session, undefined);
+ assert.equal(esme, undefined);
});
test('accepts the right credentials and attaches userData', async t => {
@@ -289,14 +326,14 @@ describe('bind', () => {
: false,
});
const serverSession = once<Session>(resolve => smpp.on('session', resolve));
- const { err, session } = await connect(t, smpp, { password: 'bar', username: 'foo' });
+ const { err, client: esme } = await connect(t, smpp, { password: 'bar', username: 'foo' });
assert.equal(err, undefined);
- assert.ok(session);
+ assert.ok(esme);
- const bound = await serverSession;
+ const accepted = await serverSession;
- assert.deepEqual(bound.userData, { userId: 123 });
+ assert.deepEqual(accepted.userData, { userId: 123 });
});
test('answers a non-bind command from an unbound peer with ESME_RINVBNDSTS', async t => {
@@ -363,8 +400,8 @@ describe('bind', () => {
});
});
- const { session: byDefault } = await connect(t, smpp);
- const { session: asFive } = await connect(t, smpp, { interfaceVersion: 0x50 });
+ const { client: byDefault } = await connect(t, smpp);
+ const { client: asFive } = await connect(t, smpp, { interfaceVersion: 0x50 });
assert.ok(byDefault);
assert.ok(asFive);
@@ -441,11 +478,11 @@ describe('bind', () => {
test('records the version the SMSC declared in its bind response', async t => {
const smpp = await startServer(t, { interfaceVersion: 0x50 });
- const { session } = await connect(t, smpp);
+ const { client: esme } = await connect(t, smpp);
- assert.ok(session);
- assert.equal(session.peerInterfaceVersion, 0x50);
- assert.ok(session.acceptsOptionalParams());
+ assert.ok(esme);
+ assert.equal(linkOf(esme).peerInterfaceVersion, 0x50);
+ assert.ok(linkOf(esme).acceptsOptionalParams());
});
test('records a hand-wired bind through bound(), and nothing else writes it', t => {
@@ -480,12 +517,12 @@ describe('bind', () => {
// The spec: an absent sc_interface_version means the SMSC supports no optional parameters.
test('takes an SMSC that declares no version as older than 3.4', async t => {
const peer = await smscPeer(t);
- const { session } = await client({ port: peer.port });
+ const { client: esme } = await client({ port: peer.port });
- assert.ok(session);
- closeAfter(t, session);
- assert.equal(session.peerInterfaceVersion, 0x00);
- assert.equal(session.acceptsOptionalParams(), false);
+ assert.ok(esme);
+ closeAfter(t, esme);
+ assert.equal(linkOf(esme).peerInterfaceVersion, 0x00);
+ assert.equal(linkOf(esme).acceptsOptionalParams(), false);
});
});
@@ -493,11 +530,11 @@ describe('bind direction', () => {
// A receiver-bound ESME sends no submit_sm and a transmitter-bound one is sent no deliver_sm.
test('refuses a submit_sm from a peer that bound as a receiver', async t => {
const smpp = await startServer(t);
- const { session } = await connect(t, smpp, { bindType: 'receiver' });
+ const { client: esme } = await connect(t, smpp, { bindType: 'receiver' });
- assert.ok(session);
+ assert.ok(esme);
- const sent = await session.send({
+ const sent = await esme.send({
cmdName: 'submit_sm',
params: { destination_addr: '46709771337', short_message: 'nope', source_addr: '46701113311' },
});
@@ -506,17 +543,11 @@ describe('bind direction', () => {
assert.equal(sent.pduObj.cmdStatus, 'ESME_RINVBNDSTS');
});
- test('refuses sendSms() on a receiver-bound session before it reaches the wire', async t => {
- const smpp = await startServer(t);
+ test('refuses sendSms() on a receiver-bound client before it reaches the wire', async t => {
const arrived: Sms[] = [];
-
- smpp.on('session', peer => peer.on('sms', sms => arrived.push(sms)));
-
- const { session } = await connect(t, smpp, { bindType: 'receiver' });
-
- assert.ok(session);
-
- const sent = await session.sendSms({ from: '46701113311', message: 'nope', to: '46709771337' });
+ const smpp = await startServer(t, { onSms: sms => { arrived.push(sms); } });
+ const esme = await bound(t, smpp, { bindType: 'receiver' });
+ const sent = await esme.sendSms({ from: '46701113311', message: 'nope', to: '46709771337' });
assert.ok(sent.err instanceof Error);
assert.match(sent.err.message, /receiver-bound/);
@@ -527,9 +558,9 @@ describe('bind direction', () => {
test('refuses a deliver_sm sent to a peer that bound as a transmitter', async t => {
const smpp = await startServer(t);
const bound = once<Session>(resolve => { smpp.on('session', resolve); });
- const { session } = await connect(t, smpp, { bindType: 'transmitter' });
+ const { client: esme } = await connect(t, smpp, { bindType: 'transmitter' });
- assert.ok(session);
+ assert.ok(esme);
const peer = await bound;
const sent = await peer.send({
@@ -542,21 +573,12 @@ describe('bind direction', () => {
});
test('refuses sendDlr() to a transmitter-bound peer before it reaches the wire', async t => {
- const smpp = await startServer(t);
- const incoming = once<Sms>(resolve => {
- smpp.on('session', peer => peer.on('sms', resolve));
- });
- const { session } = await connect(t, smpp, { bindType: 'transmitter' });
-
- assert.ok(session);
-
+ const handed = handedOver();
+ const smpp = await startServer(t, { onSms: handed.onSms });
+ const esme = await bound(t, smpp, { bindType: 'transmitter' });
const [sms] = await Promise.all([
- incoming.then(async received => {
- await received.sendResp();
-
- return received;
- }),
- session.sendSms({ dlr: true, from: '46701113311', message: 'one way', to: '46709771337' }),
+ handed.sms,
+ esme.sendSms({ dlr: true, from: '46701113311', message: 'one way', to: '46709771337' }),
]);
const report = await sms.sendDlr();
@@ -564,13 +586,12 @@ describe('bind direction', () => {
assert.match(report.err.message, /transmitter-bound/);
});
- // data_sm carries a message either way, so which end this is decides which way it may travel.
test('refuses a data_sm sent to a peer that bound as a transmitter', async t => {
const smpp = await startServer(t);
const bound = once<Session>(resolve => { smpp.on('session', resolve); });
- const { session } = await connect(t, smpp, { bindType: 'transmitter' });
+ const { client: esme } = await connect(t, smpp, { bindType: 'transmitter' });
- assert.ok(session);
+ assert.ok(esme);
const peer = await bound;
const sent = await peer.send({
@@ -585,15 +606,13 @@ describe('bind direction', () => {
});
test('refuses a data_sm from a receiver-bound peer, and carries one from a transmitter', async t => {
- const smpp = await startServer(t);
-
- smpp.on('session', peer => peer.on('sms', sms => void sms.sendResp()));
+ const smpp = await startServer(t, { onSms: () => undefined });
const receiving = await connect(t, smpp, { bindType: 'receiver' });
- assert.ok(receiving.session);
+ assert.ok(receiving.client);
- const refused = await receiving.session.send({
+ const refused = await receiving.client.send({
cmdName: 'data_sm',
params: { destination_addr: '46709771337', source_addr: '46701113311' },
tlvs: { message_payload: { tagValue: Buffer.from('nope') } },
@@ -604,9 +623,9 @@ describe('bind direction', () => {
const sending = await connect(t, smpp, { bindType: 'transmitter' });
- assert.ok(sending.session);
+ assert.ok(sending.client);
- const carried = await sending.session.send({
+ const carried = await sending.client.send({
cmdName: 'data_sm',
params: { destination_addr: '46709771337', source_addr: '46701113311' },
tlvs: { message_payload: { tagValue: Buffer.from('a submission the bind carries') } },
@@ -619,24 +638,17 @@ describe('bind direction', () => {
describe('sending', () => {
test('delivers a simple SMS with the sender TON derived from the address', async t => {
- const smpp = await startServer(t);
- const incoming = once<Sms>(resolve => {
- smpp.on('session', session => session.on('sms', resolve));
- });
- const { session } = await connect(t, smpp);
-
- assert.ok(session);
+ const handed = handedOver(async received => {
+ const refused = await received.sendResp({ smsId: '' });
+ assert.ok(refused.err instanceof Error);
+ await received.sendResp({ smsId: 'fixed-id' });
+ });
+ const smpp = await startServer(t, { onSms: handed.onSms });
+ const esme = await bound(t, smpp);
const [sms, sent] = await Promise.all([
- incoming.then(async received => {
- const refused = await received.sendResp({ smsId: '' });
-
- assert.ok(refused.err instanceof Error);
- await received.sendResp({ smsId: 'fixed-id' });
-
- return received;
- }),
- session.sendSms({ from: 'MyBrand', message: 'hello world', to: '46709771337' }),
+ handed.sms,
+ esme.sendSms({ from: 'MyBrand', message: 'hello world', to: '46709771337' }),
]);
assert.equal(sms.from, 'MyBrand');
@@ -656,22 +668,13 @@ describe('sending', () => {
});
test('reassembles a long SMS and answers every segment', async t => {
- const smpp = await startServer(t);
+ const handed = handedOver();
+ const smpp = await startServer(t, { onSms: handed.onSms });
const message = 'Lorem ipsum dolor sit amet, '.repeat(20);
- const incoming = once<Sms>(resolve => {
- smpp.on('session', session => session.on('sms', resolve));
- });
- const { session } = await connect(t, smpp);
-
- assert.ok(session);
-
+ const esme = await bound(t, smpp);
const [sms, sent] = await Promise.all([
- incoming.then(async received => {
- await received.sendResp();
-
- return received;
- }),
- session.sendSms({ from: '46701113311', message, to: '46709771337' }),
+ handed.sms,
+ esme.sendSms({ from: '46701113311', message, to: '46709771337' }),
]);
assert.equal(sms.message, message);
@@ -682,22 +685,13 @@ describe('sending', () => {
});
test('carries a UCS2 message through unchanged', async t => {
- const smpp = await startServer(t);
+ const handed = handedOver();
+ const smpp = await startServer(t, { onSms: handed.onSms });
const message = 'räksmörgås تست 一';
- const incoming = once<Sms>(resolve => {
- smpp.on('session', session => session.on('sms', resolve));
- });
- const { session } = await connect(t, smpp);
-
- assert.ok(session);
-
+ const esme = await bound(t, smpp);
const [sms] = await Promise.all([
- incoming.then(async received => {
- await received.sendResp();
-
- return received;
- }),
- session.sendSms({ from: '46701113311', message, to: '46709771337' }),
+ handed.sms,
+ esme.sendSms({ from: '46701113311', message, to: '46709771337' }),
]);
assert.equal(sms.message, message);
@@ -705,21 +699,12 @@ describe('sending', () => {
});
test('marks a flash message without losing the UCS2 alphabet', async t => {
- const smpp = await startServer(t);
- const incoming = once<Sms>(resolve => {
- smpp.on('session', session => session.on('sms', resolve));
- });
- const { session } = await connect(t, smpp);
-
- assert.ok(session);
-
+ const handed = handedOver();
+ const smpp = await startServer(t, { onSms: handed.onSms });
+ const esme = await bound(t, smpp);
const [sms] = await Promise.all([
- incoming.then(async received => {
- await received.sendResp();
-
- return received;
- }),
- session.sendSms({ flash: true, from: '46701113311', message: 'تست', to: '46709771337' }),
+ handed.sms,
+ esme.sendSms({ flash: true, from: '46701113311', message: 'تست', to: '46709771337' }),
]);
// 0.4.0 forced data_coding to 0x10, which discards UCS2 and mangles the message.
@@ -729,21 +714,12 @@ describe('sending', () => {
});
test('puts the address TON and NPI the caller chose on the wire', async t => {
- const smpp = await startServer(t);
- const incoming = once<Sms>(resolve => {
- smpp.on('session', peer => peer.on('sms', resolve));
- });
- const { session } = await connect(t, smpp);
-
- assert.ok(session);
-
+ const handed = handedOver();
+ const smpp = await startServer(t, { onSms: handed.onSms });
+ const esme = await bound(t, smpp);
const [sms] = await Promise.all([
- incoming.then(async received => {
- await received.sendResp();
-
- return received;
- }),
- session.sendSms({
+ handed.sms,
+ esme.sendSms({
destinationAddrNpi: consts.NPI.ISDN,
destinationAddrTon: consts.TON.NATIONAL,
from: '46701113311',
@@ -753,7 +729,6 @@ describe('sending', () => {
to: '46709771337',
}),
]);
-
const params = sms.pduObjs[0]?.params;
assert.ok(params);
@@ -762,26 +737,40 @@ describe('sending', () => {
assert.equal(params.source_addr_npi, consts.NPI.PRIVATE);
assert.equal(params.source_addr_ton, consts.TON.ABBREVIATED);
});
+
});
describe('receiving', () => {
+ type Inbound = { esme: SmppClient; peer: Session; sms: Promise<Sms> };
+
+ /** A client whose handler hands each message to the test, answering on the test's terms. */
async function inbound(
t: TestContext,
options: ServerOptions = {},
- ): Promise<{ peer: Session; session: Session }> {
+ answer: (sms: Sms) => Promise<unknown> = () => Promise.resolve(),
+ ): Promise<Inbound> {
const smpp = await startServer(t, options);
+ const handed = handedOver(answer);
+ const accepted = once<Session>(resolve => { smpp.on('session', resolve); });
+ const { err, client: esme } = await connect(t, smpp, { onSms: handed.onSms });
- const bound = once<Session>(resolve => { smpp.on('session', resolve); });
- const { session } = await connect(t, smpp);
+ assert.equal(err, undefined);
+ assert.ok(esme);
+
+ return { esme, peer: await accepted, sms: handed.sms };
+ }
- assert.ok(session);
+ /** The same with no handler on the client at all. */
+ async function inboundUnhandled(t: TestContext): Promise<{ esme: SmppClient; peer: Session }> {
+ const smpp = await startServer(t);
+ const accepted = once<Session>(resolve => { smpp.on('session', resolve); });
+ const esme = await bound(t, smpp);
- return { peer: await bound, session };
+ return { esme, peer: await accepted };
}
test('hands a client a deliver_sm that is not a delivery receipt', async t => {
- const { peer, session } = await inbound(t);
- const incoming = once<Sms>(resolve => { session.on('sms', resolve); });
+ const { peer, sms: incoming } = await inbound(t, {}, received => received.sendResp({ smsId: 'inbound-id' }));
const delivered = peer.send({
cmdName: 'deliver_sm',
params: {
@@ -797,8 +786,6 @@ describe('receiving', () => {
assert.equal(sms.to, '46709771337');
assert.equal(sms.message, 'inbound hello');
- await sms.sendResp({ smsId: 'inbound-id' });
-
const answered = await delivered;
assert.ok(answered.pduObj);
@@ -811,10 +798,8 @@ describe('receiving', () => {
assert.equal(sms.smsId, 'inbound-id', 'the id the application chose is still its own handle');
});
- // SMPP 3.4 5.3.2.32: up to 64 KB of body in a TLV, with sm_length 0 and short_message empty.
test('reads an inbound message the peer carried in message_payload', async t => {
- const { peer, session } = await inbound(t);
- const incoming = once<Sms>(resolve => { session.on('sms', resolve); });
+ const { peer, sms: incoming } = await inbound(t);
const text = 'the whole body, carried in the TLV';
const delivered = peer.send({
cmdName: 'deliver_sm',
@@ -832,8 +817,6 @@ describe('receiving', () => {
assert.equal(sms.from, '46701113311');
assert.equal(sms.to, '46709771337');
- await sms.sendResp();
-
const answered = await delivered;
assert.ok(answered.pduObj);
@@ -841,9 +824,8 @@ describe('receiving', () => {
});
test('hands a client a data_sm carrying a message as an sms, answered data_sm_resp', async t => {
- const { peer, session } = await inbound(t);
- const incoming = once<Sms>(resolve => { session.on('sms', resolve); });
const smsId = '0199e0f1-6c31-7a44-9d02-4b7e51c3a806';
+ const { peer, sms: incoming } = await inbound(t, {}, received => received.sendResp({ smsId }));
const delivered = peer.send({
cmdName: 'data_sm',
params: { destination_addr: '46709771337', source_addr: '46701113311' },
@@ -855,8 +837,6 @@ describe('receiving', () => {
assert.equal(sms.message, 'a message carried on the data command');
assert.equal(sms.from, '46701113311');
- await sms.sendResp({ smsId });
-
const answered = await delivered;
assert.ok(answered.pduObj);
@@ -867,13 +847,10 @@ describe('receiving', () => {
});
test('hands a client a receipt carried on data_sm as a dlr', async t => {
- const { peer, session } = await inbound(t);
- const reported = once<Dlr>(resolve => { session.on('dlr', resolve); });
- const smsId = '0199e0f1-b8a2-7f19-8c63-2d5041fb9e77';
let messages = 0;
-
- session.on('sms', () => { messages++; });
-
+ const { esme, peer } = await inbound(t, {}, () => { messages++; return Promise.resolve(); });
+ const reported = once<Dlr>(resolve => { esme.on('dlr', resolve); });
+ const smsId = '0199e0f1-b8a2-7f19-8c63-2d5041fb9e77';
const delivered = peer.send({
cmdName: 'data_sm',
params: {
@@ -901,26 +878,16 @@ describe('receiving', () => {
assert.equal(answered.pduObj.cmdStatus, 'ESME_ROK');
});
- // At the SMSC end an inbound data_sm is a submission, so nothing in one reports on our own sends.
test('reads a receipt-shaped data_sm submitted to a server as the message it is', async t => {
- const smpp = await startServer(t);
const body = 'id:0199e0f2-2d15-7b83-a4c1-6e90b7d2f345 stat:DELIVRD err:000 text:';
const messages: Sms[] = [];
const reports: Dlr[] = [];
+ const smpp = await startServer(t, { onSms: sms => { messages.push(sms); } });
- smpp.on('session', peer => {
- peer.on('dlr', dlr => { reports.push(dlr); });
- peer.on('sms', sms => {
- messages.push(sms);
- void sms.sendResp();
- });
- });
-
- const { session } = await connect(t, smpp, { bindType: 'transmitter' });
+ smpp.on('session', peer => { peer.on('dlr', dlr => { reports.push(dlr); }); });
- assert.ok(session);
-
- const submitted = await session.send({
+ const esme = await bound(t, smpp, { bindType: 'transmitter' });
+ const submitted = await esme.send({
cmdName: 'data_sm',
params: {
destination_addr: '46709771337',
@@ -937,19 +904,15 @@ describe('receiving', () => {
assert.deepEqual(reports, [], 'an ESME submitting is never the network reporting');
});
- // The refusal a submission gets is the one submit_sm_resp defines, whichever command carried it.
test('refuses a data_sm segment a server has no room for with the submit code', async t => {
// The two addresses and the objects are 1322 octets, so the 6-octet UDH and its text are what overrun 1330.
const smpp = await startServer(t, { maxOctets: 1330 });
- const { session } = await connect(t, smpp, { bindType: 'transmitter' });
-
- assert.ok(session);
-
+ const esme = await bound(t, smpp, { bindType: 'transmitter' });
const segment = splitMessage('one of two, too big to hold. '.repeat(12), { reference: 0x5C })[0];
assert.ok(segment);
- const refused = await session.send({
+ const refused = await esme.send({
cmdName: 'data_sm',
params: {
destination_addr: '46709771337',
@@ -965,8 +928,7 @@ describe('receiving', () => {
});
test('reassembles a concatenated message whose segments arrived in message_payload', async t => {
- const { peer, session } = await inbound(t);
- const incoming = once<Sms>(resolve => { session.on('sms', resolve); });
+ const { peer, sms: incoming } = await inbound(t);
const text = 'A body in the TLV is still numbered by its UDH. '.repeat(6);
const segments = splitMessage(text, { reference: 0x3B });
@@ -1005,12 +967,9 @@ describe('receiving', () => {
});
test('hands a client a report as a dlr rather than as an sms', async t => {
- const { peer, session } = await inbound(t);
- const reported = once<Dlr>(resolve => { session.on('dlr', resolve); });
let messages = 0;
-
- session.on('sms', () => { messages++; });
-
+ const { esme, peer } = await inbound(t, {}, () => { messages++; return Promise.resolve(); });
+ const reported = once<Dlr>(resolve => { esme.on('dlr', resolve); });
const delivered = peer.send({
cmdName: 'deliver_sm',
params: {
@@ -1031,7 +990,7 @@ describe('receiving', () => {
assert.ok(answered.pduObj);
assert.equal(answered.pduObj.cmdName, 'deliver_sm_resp');
- const notified = once<Dlr>(resolve => { session.on('dlr', resolve); });
+ const notified = once<Dlr>(resolve => { esme.on('dlr', resolve); });
const notification = peer.send({
cmdName: 'deliver_sm',
params: {
@@ -1054,11 +1013,10 @@ describe('receiving', () => {
assert.equal(answeredNotification.pduObj.cmdName, 'deliver_sm_resp');
});
- // SMPPSim's receipt for a UCS2 message inherits its data_coding and writes the body as text.
test('parses a receipt written as text under a data_coding that says UCS2', async t => {
- const { peer, session } = await inbound(t);
+ const { esme, peer } = await inboundUnhandled(t);
const reported = once<{ dlr: Dlr; pduObj: PduObject }>(resolve => {
- session.on('dlr', (dlr, pduObj) => { resolve({ dlr, pduObj }); });
+ esme.on('dlr', (dlr, pduObj) => { resolve({ dlr, pduObj }); });
});
const smsId = '01a072f9-30f2-71b0-87cd-f5032df3a8e0';
const body = `id:${smsId} sub:001 dlvrd:001 submit date:2509051430 done date:2509051431 stat:DELIVRD err:000 text:`;
@@ -1083,10 +1041,9 @@ describe('receiving', () => {
assert.ok((await delivered).pduObj);
});
- test('reassembles a multipart inbound SMS before the sms event', async t => {
+ test('reassembles a multipart inbound SMS before it reaches the handler', async t => {
const message = 'Inbound lorem ipsum dolor sit amet consectetur, '.repeat(6);
- const { peer, session } = await inbound(t);
- const incoming = once<Sms>(resolve => { session.on('sms', resolve); });
+ const { peer, sms: incoming } = await inbound(t);
const segments = splitMessage(message, { reference: 42 });
assert.equal(segments.length, 2);
@@ -1124,18 +1081,15 @@ describe('receiving', () => {
}
test('answers every sar_* segment on arrival and hands the application one message', async t => {
- const smpp = await startServer(t);
- const incoming = once<Sms>(resolve => { smpp.on('session', peer => peer.on('sms', resolve)); });
- const { session } = await connect(t, smpp, { bindType: 'transmitter', responseTimeout: 1000 });
-
- assert.ok(session);
-
+ const handed = handedOver();
+ const smpp = await startServer(t, { onSms: handed.onSms });
+ const esme = await bound(t, smpp, { bindType: 'transmitter', responseTimeout: 1000 });
const parts = ['sar one, ', 'sar two, ', 'sar three'];
const answers: PduObject[] = [];
// A 16-bit reference no 8-bit UDH could carry, which is the width the TLV exists for.
for (const [index, part] of parts.entries()) {
- const sent = await session.send({
+ const sent = await esme.send({
cmdName: 'submit_sm',
params: {
destination_addr: '46709771337',
@@ -1149,7 +1103,7 @@ describe('receiving', () => {
answers.push(sent.pduObj);
}
- const sms = await raceWithin(2000, incoming);
+ const sms = await raceWithin(2000, handed.sms);
assert.ok(sms, 'the sar_* TLVs tie the three submissions into one message');
assert.equal(sms.message, parts.join(''));
@@ -1161,8 +1115,7 @@ describe('receiving', () => {
});
test('joins sar_* segments in the order they number themselves', async t => {
- const { peer, session } = await inbound(t, { responseTimeout: 1000 });
- const incoming = once<Sms>(resolve => { session.on('sms', resolve); });
+ const { peer, sms: incoming } = await inbound(t, { responseTimeout: 1000 });
const parts = ['first ', 'second ', 'third'];
for (const index of [2, 0, 1]) {
@@ -1187,8 +1140,7 @@ describe('receiving', () => {
});
test('reassembles a sar_* segment whose body is in message_payload', async t => {
- const { peer, session } = await inbound(t, { responseTimeout: 1000 });
- const incoming = once<Sms>(resolve => { session.on('sms', resolve); });
+ const { peer, sms: incoming } = await inbound(t, { responseTimeout: 1000 });
const parts = ['in the mandatory field, ', 'and in the TLV'];
for (const [index, part] of parts.entries()) {
@@ -1223,11 +1175,8 @@ describe('receiving', () => {
// The UDH reference is 8 bits and sar_msg_ref_num is 16, so the same number is two messages.
test('keeps a UDH group and a sar_* group sharing a reference apart', async t => {
- const { peer, session } = await inbound(t, { responseTimeout: 1000 });
const messages: Sms[] = [];
-
- session.on('sms', sms => { messages.push(sms); });
-
+ const { peer } = await inbound(t, { responseTimeout: 1000 }, sms => { messages.push(sms); return Promise.resolve(); });
const udhText = 'the message numbered by its user data header. '.repeat(5);
const udhSegments = splitMessage(udhText, { reference: 5 });
const sarParts = ['the message numbered by ', 'its optional parameters'];
@@ -1271,8 +1220,7 @@ describe('receiving', () => {
// Nothing compares the two references: each spelling counts in a space of its own.
test('groups a segment carrying both spellings by its UDH', async t => {
- const { peer, session } = await inbound(t, { responseTimeout: 1000 });
- const incoming = once<Sms>(resolve => { session.on('sms', resolve); });
+ const { peer, sms: incoming } = await inbound(t, { responseTimeout: 1000 });
const message = 'both spellings on every segment of it, and the UDH decides. '.repeat(4);
const segments = splitMessage(message, { reference: 7 });
@@ -1301,12 +1249,11 @@ describe('receiving', () => {
});
test('reads a receipt carrying sar_* fields as a dlr, never as a segment', async t => {
- const { peer, session } = await inbound(t, { responseTimeout: 1000 });
const reports: Dlr[] = [];
let messages = 0;
+ const { esme, peer } = await inbound(t, { responseTimeout: 1000 }, () => { messages++; return Promise.resolve(); });
- session.on('dlr', dlr => { reports.push(dlr); });
- session.on('sms', () => { messages++; });
+ esme.on('dlr', dlr => { reports.push(dlr); });
const marked = '0199e1a4-6c3f-7d21-9a80-5b1e2f7c4d63';
const unmarked = '0199e1a4-b70e-7c55-8f42-9d3a1c86e70b';
@@ -1350,25 +1297,15 @@ describe('receiving', () => {
describe('delivery reports', () => {
test('reaches the sender as a dlr event', async t => {
- const smpp = await startServer(t);
- const incoming = once<Sms>(resolve => {
- smpp.on('session', session => session.on('sms', resolve));
- });
- const { session } = await connect(t, smpp);
-
- assert.ok(session);
-
+ const handed = handedOver(received => received.sendResp({ smsId: 'dlr-id' }));
+ const smpp = await startServer(t, { onSms: handed.onSms });
+ const esme = await bound(t, smpp);
const dlr = once<[Dlr, PduObject]>(resolve => {
- session.on('dlr', (report, pduObj) => { resolve([report, pduObj]); });
+ esme.on('dlr', (report, pduObj) => { resolve([report, pduObj]); });
});
-
const [sms] = await Promise.all([
- incoming.then(async received => {
- await received.sendResp({ smsId: 'dlr-id' });
-
- return received;
- }),
- session.sendSms({ dlr: true, from: '46701113311', message: 'hi', to: '46709771337' }),
+ handed.sms,
+ esme.sendSms({ dlr: true, from: '46701113311', message: 'hi', to: '46709771337' }),
]);
assert.ok(sms.dlr);
@@ -1383,30 +1320,20 @@ describe('delivery reports', () => {
});
test('reports a failure with the spec status code', async t => {
- const smpp = await startServer(t);
- const incoming = once<Sms>(resolve => {
- smpp.on('session', session => session.on('sms', resolve));
- });
- const { session } = await connect(t, smpp);
-
- assert.ok(session);
-
- const dlr = once<{ statusMsg: string }>(resolve => { session.on('dlr', resolve); });
+ const handed = handedOver(received => received.sendResp({ smsId: 'fail-id' }));
+ const smpp = await startServer(t, { onSms: handed.onSms });
+ const esme = await bound(t, smpp);
+ const dlr = once<{ statusMsg: string }>(resolve => { esme.on('dlr', resolve); });
const raw = once<string>(resolve => {
- session.on('incomingPduObj', pduObj => {
+ linkOf(esme).on('incomingPduObj', pduObj => {
const message = pduObj.params.short_message;
if (typeof message === 'string') resolve(message);
});
});
-
const [sms] = await Promise.all([
- incoming.then(async received => {
- await received.sendResp({ smsId: 'fail-id' });
-
- return received;
- }),
- session.sendSms({ dlr: true, from: '46701113311', message: 'hi', to: '46709771337' }),
+ handed.sms,
+ esme.sendSms({ dlr: true, from: '46701113311', message: 'hi', to: '46709771337' }),
]);
await sms.sendDlr('UNDELIVERABLE');
@@ -1417,10 +1344,8 @@ describe('delivery reports', () => {
});
test('sends a text-only receipt to a peer that declared less than 3.4', async t => {
- const smpp = await startServer(t);
- const incoming = once<Sms>(resolve => {
- smpp.on('session', session => session.on('sms', resolve));
- });
+ const handed = handedOver();
+ const smpp = await startServer(t, { onSms: handed.onSms });
const peer = rawPeer(t, smpp.port);
peer.write(bindOf(0x33));
@@ -1438,9 +1363,8 @@ describe('delivery reports', () => {
seqNr: 2,
});
- const sms = await incoming;
+ const sms = await handed.sms;
- await sms.sendResp();
await peer.next();
// A raw peer answers no deliver_sm, so this only settles once the session closes.
@@ -1454,37 +1378,30 @@ describe('delivery reports', () => {
});
test('merges nothing for a message that asked for no receipt', async t => {
- const smpp = await startServer(t);
- const incoming = once<Sms>(resolve => {
- smpp.on('session', session => session.on('sms', resolve));
- });
- const { session } = await connect(t, smpp);
-
- assert.ok(session);
-
+ const handed = handedOver();
+ const smpp = await startServer(t, { onSms: handed.onSms });
+ const esme = await bound(t, smpp);
const perSegment: string[] = [];
let merged = 0;
- session.on('dlr', dlr => perSegment.push(dlr.smsId ?? ''));
- session.on('messageDlr', () => { merged++; });
+ esme.on('dlr', dlr => perSegment.push(dlr.smsId ?? ''));
+ esme.on('messageDlr', () => { merged++; });
const [sms] = await Promise.all([
- incoming.then(async received => {
- await received.sendResp();
-
- return received;
- }),
- session.sendSms({ from: '46701113311', message: 'x'.repeat(400), to: '46709771337' }),
+ handed.sms,
+ esme.sendSms({ from: '46701113311', message: 'x'.repeat(400), to: '46709771337' }),
]);
await sms.sendDlr();
+ assert.ok(await waitFor(() => perSegment.length === 3));
assert.deepEqual(perSegment, [1, 2, 3].map(part => `${sms.smsId}-${String(part)}`));
assert.equal(merged, 0);
});
+
});
-describe('a session captured from Kannel', () => {
+describe('a esme captured from Kannel', () => {
// Four parts of one message, esm_class 0x43 — the UDH indicator combined with store-and-forward,
// which 0.4.0 originally compared with === 0x40 and missed.
const parts = [
@@ -1497,10 +1414,9 @@ describe('a session captured from Kannel', () => {
const expected = 'Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum';
async function replay(t: TestContext, order: number[]): Promise<Sms> {
- const smpp = await startServer(t);
- const incoming = once<Sms>(resolve => {
- smpp.on('session', session => session.on('sms', resolve));
- });
+ const handed = handedOver();
+ const smpp = await startServer(t, { onSms: handed.onSms });
+ const incoming = handed.sms;
const sock = net.connect({ port: smpp.port }, () => {
sock.write(Buffer.from('0000002100000009000000000000002f666f6f0062617200736d70700034000000', 'hex'));
@@ -1522,11 +1438,7 @@ describe('a session captured from Kannel', () => {
}
});
- const sms = await incoming;
-
- await sms.sendResp();
-
- return sms;
+ return incoming;
}
test('reassembles four segments arriving in order', async t => {
@@ -1558,12 +1470,12 @@ describe('robustness', () => {
test('leaves an alert_notification and an outbind unanswered, since SMPP names no response', async t => {
const peer = await smscPeer(t);
- const { session } = await client({ port: peer.port });
+ const { client: esme } = await client({ port: peer.port });
const errors: Error[] = [];
- assert.ok(session);
- closeAfter(t, session);
- session.on('sessionError', err => { errors.push(err); });
+ assert.ok(esme);
+ closeAfter(t, esme);
+ esme.on('sessionError', err => { errors.push(err); });
peer.writeRaw(pduBytes({
cmdName: 'alert_notification',
params: { esme_addr: '46709771337', source_addr: '46701113311' },
@@ -1591,39 +1503,32 @@ describe('robustness', () => {
});
test('reports a refused connection rather than throwing', async () => {
- const { err, session } = await client({ port: 1 });
+ const { err, client: esme } = await client({ port: 1 });
assert.ok(err instanceof Error);
- assert.equal(session, undefined);
+ assert.equal(esme, undefined);
});
test('keeps at most maxOutstanding requests on the wire', async t => {
- const smpp = await startServer(t);
let concurrent = 0;
let peak = 0;
-
- smpp.on('session', session => {
- session.on('sms', sms => {
+ const smpp = await startServer(t, {
+ onSms: async () => {
concurrent++;
peak = Math.max(peak, concurrent);
- setTimeout(() => {
- concurrent--;
- void sms.sendResp();
- }, 10);
- });
+ await delay(10);
+ concurrent--;
+ },
});
+ const esme = await bound(t, smpp, { maxOutstanding: 2 });
- const { session } = await connect(t, smpp, { maxOutstanding: 2 });
-
- assert.ok(session);
-
- await Promise.all(Array.from({ length: 8 }, (_, index) => session.sendSms({
+ await Promise.all(Array.from({ length: 8 }, (_, index) => esme.sendSms({
from: '46701113311',
message: `message ${String(index)}`,
to: '46709771337',
})));
- const long = await session.sendSms({
+ const long = await esme.sendSms({
from: '46701113311',
message: 'x'.repeat(500),
to: '46709771337',
@@ -1652,19 +1557,11 @@ describe('robustness', () => {
assert.equal(reported.message, sent.err.message);
});
- test('ignores events from the socket it left behind on a reconnect', async t => {
- const smpp = await startServer(t);
-
- smpp.on('session', bound => {
- bound.on('sms', sms => { void sms.sendResp(); });
- });
-
- const { session } = await connect(t, smpp, { reconnect: { maxDelay: 50, minDelay: 10 } });
-
- assert.ok(session);
-
- const reconnected = once<true>(resolve => { session.on('reconnected', () => { resolve(true); }); });
- const dead = session.sock;
+ test('is live on a new session after a reconnect, and the old one stays closed', async t => {
+ const smpp = await startServer(t, { onSms: () => undefined });
+ const esme = await bound(t, smpp, { reconnect: { maxDelay: 50, minDelay: 10 } });
+ const reconnected = once<true>(resolve => { esme.on('reconnected', () => { resolve(true); }); });
+ const dead = linkOf(esme);
for (const serverSession of smpp.sessions) {
await serverSession.close();
@@ -1674,15 +1571,17 @@ describe('robustness', () => {
let closes = 0;
- session.on('close', () => { closes++; });
- dead.emit('close');
+ esme.on('close', () => { closes++; });
+ dead.sock.emit('close');
- const sent = await session.sendSms({
+ const sent = await esme.sendSms({
from: '46701113311',
message: 'still up',
to: '46709771337',
});
+ assert.equal(dead.closed, true);
+ assert.notEqual(linkOf(esme), dead);
assert.equal(closes, 0);
assert.equal(sent.err, undefined);
});
@@ -1690,12 +1589,12 @@ describe('robustness', () => {
test('closes the session when the signal aborts after the bind', async t => {
const smpp = await startServer(t);
const controller = new AbortController();
- const { err, session } = await connect(t, smpp, { signal: controller.signal });
+ const { err, client: esme } = await connect(t, smpp, { signal: controller.signal });
assert.equal(err, undefined);
- assert.ok(session);
+ assert.ok(esme);
- const closed = once<true>(resolve => { session.on('close', () => { resolve(true); }); });
+ const closed = once<true>(resolve => { esme.on('close', () => { resolve(true); }); });
controller.abort();
@@ -1707,9 +1606,9 @@ describe('robustness', () => {
const smpp = await startServer(t);
const bound = once<Session>(resolve => { smpp.on('session', resolve); });
- const { session } = await connect(t, smpp);
+ const { client: esme } = await connect(t, smpp);
- assert.ok(session);
+ assert.ok(esme);
const peer = await bound;
const controller = new AbortController();
@@ -1718,7 +1617,7 @@ describe('robustness', () => {
peer.on('incomingPduObj', pduObj => { seen.push(pduObj.cmdName); });
controller.abort();
- const sent = await session.sendSms({
+ const sent = await esme.sendSms({
from: '46701113311',
message: 'must never reach the peer',
to: '46709771337',
@@ -1731,22 +1630,17 @@ describe('robustness', () => {
// The guard sits before the send window, or a full window makes the aborted call queue first.
test('does not wait for a send window slot it will never use', async t => {
- const smpp = await startServer(t);
-
- // The peer answers nothing, so the one slot stays held for the whole test.
- smpp.on('session', session => session.on('sms', () => undefined));
-
- const { session } = await connect(t, smpp, { maxOutstanding: 1, responseTimeout: 5000 });
-
- assert.ok(session);
+ // The peer never returns, so the one slot stays held for the whole test.
+ const smpp = await startServer(t, { onSms: () => new Promise(() => undefined) });
+ const esme = await bound(t, smpp, { maxOutstanding: 1, responseTimeout: 5000 });
- void session.sendSms({ from: '46701113311', message: 'holds the slot', to: '46709771337' });
+ void esme.sendSms({ from: '46701113311', message: 'holds the slot', to: '46709771337' });
const controller = new AbortController();
controller.abort();
- const aborted = await raceWithin(500, session.sendSms({
+ const aborted = await raceWithin(500, esme.sendSms({
from: '46701113311',
message: 'must not queue behind the held one',
to: '46709771337',
@@ -1756,28 +1650,28 @@ describe('robustness', () => {
assert.ok(aborted !== false && aborted.err instanceof Error);
});
- // A socket the loop opened and never handed over is one leaked per retry, forever.
test('leaves no socket open when coming back up fails', async t => {
const opened: net.Socket[] = [];
-
- function onConnected(): Promise<VoidResult> {
- if (opened.length === 1) return Promise.resolve({ err: new Error('bind refused') });
-
- throw new Error('bind exploded');
- }
-
+ let attempts = 0;
const loop = new ReconnectLoop({
- connect: () => {
+ log: silentLog,
+ maxDelay: 10,
+ minDelay: 1,
+ onDown: () => undefined,
+ onUp: () => undefined,
+ open: () => {
const sock = new net.Socket();
opened.push(sock);
+ attempts++;
+
+ // A bind that fails takes its socket with it, whether it returned an err or threw.
+ sock.destroy();
- return Promise.resolve({ sock });
+ if (attempts === 1) return Promise.resolve({ err: new Error('bind refused') });
+
+ throw new Error('bind exploded');
},
- log: silentLog,
- maxDelay: 10,
- minDelay: 1,
- onConnected,
});
t.after(() => {
@@ -1794,7 +1688,9 @@ describe('robustness', () => {
&& opened[1]?.destroyed === true);
assert.ok(destroyed, 'a failed setup should leave no socket open');
+ assert.equal(loop.current(), undefined);
});
+
});
describe('a PDU the codec cannot read', () => {
@@ -1814,21 +1710,21 @@ describe('a PDU the codec cannot read', () => {
return pduObj;
}
- async function bound(t: TestContext, options: Parameters<typeof client>[0] = {}) {
+ async function boundToPeer(t: TestContext, options: Parameters<typeof client>[0] = {}) {
const peer = await smscPeer(t);
- const { session } = await client({ port: peer.port, ...options });
+ const { client: esme } = await client({ port: peer.port, ...options });
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
- return { peer, session };
+ return { esme, peer };
}
// ukarim/smscsim signs every deliver_sm it sends unprompted with a raw uint32, so about half
// land above SMPP 3.4 4.7.1's ceiling; refusing them cost the link (findings/01-smscsim.md).
test('answers a deliver_sm whose sequence number is above the spec range, and keeps the link', async t => {
- const { peer, session } = await bound(t);
- const reported = once<Dlr>(resolve => { session.on('dlr', resolve); });
+ const { esme, peer } = await boundToPeer(t);
+ const reported = once<Dlr>(resolve => { esme.on('dlr', resolve); });
peer.writeRaw(pduBytes({ cmdName: 'deliver_sm', params: receipt, seqNr: 0x80000001 }));
@@ -1852,8 +1748,8 @@ describe('a PDU the codec cannot read', () => {
});
test('answers an unknown command id with generic_nack ESME_RINVCMDID', async t => {
- const { peer, session } = await bound(t);
- const failed = once<Error>(resolve => { session.on('sessionError', resolve); });
+ const { esme, peer } = await boundToPeer(t);
+ const failed = once<Error>(resolve => { esme.on('sessionError', resolve); });
peer.writeRaw(withUnknownCmdId({ cmdName: 'enquire_link', seqNr: 9 }));
@@ -1866,10 +1762,10 @@ describe('a PDU the codec cannot read', () => {
});
test('answers a deliver_sm with a truncated TLV stream with ESME_RINVTLVSTREAM', async t => {
- const { peer, session } = await bound(t);
+ const { esme, peer } = await boundToPeer(t);
let reports = 0;
- session.on('dlr', () => { reports++; });
+ esme.on('dlr', () => { reports++; });
peer.writeRaw(truncatedTlv({ cmdName: 'deliver_sm', params: receipt, seqNr: 5 }));
const answered = await answerTo(peer);
@@ -1885,10 +1781,10 @@ describe('a PDU the codec cannot read', () => {
});
test('answers a deliver_sm ending in a bare TLV header with ESME_RINVTLVSTREAM', async t => {
- const { peer, session } = await bound(t);
+ const { esme, peer } = await boundToPeer(t);
let reports = 0;
- session.on('dlr', () => { reports++; });
+ esme.on('dlr', () => { reports++; });
peer.writeRaw(bareTlvHeader({ cmdName: 'deliver_sm', params: receipt, seqNr: 55 }));
const answered = await answerTo(peer);
@@ -1903,7 +1799,7 @@ describe('a PDU the codec cannot read', () => {
});
test('answers a deliver_sm whose body is shorter than it declares with ESME_RINVCMDLEN', async t => {
- const { peer } = await bound(t);
+ const { peer } = await boundToPeer(t);
peer.writeRaw(shortened({ cmdName: 'deliver_sm', params: receipt, seqNr: 6 }, 3));
@@ -1915,9 +1811,9 @@ describe('a PDU the codec cannot read', () => {
});
test('settles the request a response it could not read was answering, and reports it', async t => {
- const { peer, session } = await bound(t, { responseTimeout: 60000 });
- const failed = once<Error>(resolve => { session.on('sessionError', resolve); });
- const sending = session.sendSms({ from: '46701113311', message: 'hi', to: '46709771337' });
+ const { esme, peer } = await boundToPeer(t, { responseTimeout: 60000 });
+ const failed = once<Error>(resolve => { esme.on('sessionError', resolve); });
+ const sending = esme.sendSms({ from: '46701113311', message: 'hi', to: '46709771337' });
const submitted = await answerTo(peer);
assert.equal(submitted.cmdName, 'submit_sm');
@@ -1944,9 +1840,9 @@ describe('a PDU the codec cannot read', () => {
});
test('tears the link down when the stream itself cannot be framed', async t => {
- const { peer, session } = await bound(t, { reconnect: false });
- const failed = once<Error>(resolve => { session.on('sessionError', resolve); });
- const closed = once<true>(resolve => { session.on('close', () => { resolve(true); }); });
+ const { esme, peer } = await boundToPeer(t, { reconnect: false });
+ const failed = once<Error>(resolve => { esme.on('sessionError', resolve); });
+ const closed = once<true>(resolve => { esme.on('close', () => { resolve(true); }); });
// A command_length below the 16 octet header leaves nothing that can find the next PDU.
peer.writeRaw(Buffer.from('00000004000000150000000000000001', 'hex'));
@@ -1972,96 +1868,77 @@ describe('application hooks that throw or reject', () => {
assert.equal(reported.message, 'authenticate exploded');
});
- test('turns a throwing sms listener into a session error', async t => {
- const smpp = await startServer(t);
+ test('turns a throwing onSms handler into a refusal and a session error', async t => {
+ const smpp = await startServer(t, { onSms: () => { throw new Error('handler exploded'); } });
const failed = once<Error>(resolve => {
- smpp.on('session', session => {
- session.on('sessionError', resolve);
- session.on('sms', () => { throw new Error('listener exploded'); });
- });
+ smpp.on('session', session => { session.on('sessionError', resolve); });
});
- const { session } = await connect(t, smpp, { responseTimeout: 200 });
-
- assert.ok(session);
-
- const sent = await session.sendSms({
+ const esme = await bound(t, smpp, { responseTimeout: 200 });
+ const sent = await esme.sendSms({
from: '46701113311',
- message: 'blows up the listener',
+ message: 'blows up the handler',
to: '46709771337',
});
const reported = await raceWithin(500, failed);
- assert.ok(sent.err instanceof Error);
- assert.ok(reported instanceof Error, 'a throwing sms listener should reach the session');
- assert.equal(reported.message, 'listener exploded');
+ assert.match(sent.err?.message ?? '', /ESME_RTHROTTLED/, 'the peer keeps a message the handler failed on');
+ assert.ok(reported instanceof Error, 'a throwing handler should reach the session');
+ assert.equal(reported.message, 'handler exploded');
});
- // The guard for a throwing sms listener used to emit sessionError from inside its own catch.
test('survives a sessionError listener that throws as well', async t => {
- const smpp = await startServer(t);
+ const smpp = await startServer(t, { onSms: () => { throw new Error('handler exploded'); } });
smpp.on('session', session => {
session.on('sessionError', () => { throw new Error('the reporter exploded too'); });
- session.on('sms', () => { throw new Error('listener exploded'); });
});
- const { session } = await connect(t, smpp, { responseTimeout: 200 });
-
- assert.ok(session);
-
- const sent = await session.sendSms({
+ const esme = await bound(t, smpp, { responseTimeout: 200 });
+ const sent = await esme.sendSms({
from: '46701113311',
- message: 'blows up both listeners',
+ message: 'blows up both',
to: '46709771337',
});
assert.ok(sent.err instanceof Error);
});
- test('normalises whatever a rejecting async sms listener threw into a session error', async t => {
- const smpp = await startServer(t);
+ test('normalises whatever a rejecting async handler threw into a session error', async t => {
const reason: unknown = null;
- const failed = once<Error>(resolve => {
- smpp.on('session', session => {
- session.on('sessionError', resolve);
- session.on('sms', async sms => {
- await sms.sendResp();
+ const smpp = await startServer(t, {
+ onSms: async sms => {
+ await sms.sendResp();
- throw reason;
- });
- });
+ throw reason;
+ },
});
- const { session } = await connect(t, smpp, { responseTimeout: 200 });
-
- assert.ok(session);
-
- const sent = await session.sendSms({
+ const failed = once<Error>(resolve => {
+ smpp.on('session', session => { session.on('sessionError', resolve); });
+ });
+ const esme = await bound(t, smpp, { responseTimeout: 200 });
+ const sent = await esme.sendSms({
from: '46701113311',
message: 'rejects after answering',
to: '46709771337',
});
const reported = await raceWithin(500, failed);
- assert.equal(sent.err, undefined);
- assert.ok(reported instanceof Error, 'a rejecting sms listener should reach the session');
+ assert.equal(sent.err, undefined, 'the answer it gave before failing stands');
+ assert.ok(reported instanceof Error, 'a rejecting handler should reach the session');
assert.equal(reported.message, 'null');
});
test('survives a sessionError listener that rejects as well', async t => {
- const smpp = await startServer(t);
+ const smpp = await startServer(t, { onSms: () => Promise.reject(new Error('handler rejected')) });
smpp.on('session', session => {
session.on('sessionError', () => Promise.reject(new Error('the reporter rejected too')));
- session.on('sms', () => Promise.reject(new Error('listener rejected')));
});
- const { session } = await connect(t, smpp, { responseTimeout: 200 });
-
- assert.ok(session);
-
- const sent = await session.sendSms({
+ const esme = await bound(t, smpp, { responseTimeout: 200 });
+ const sent = await esme.sendSms({
from: '46701113311',
- message: 'rejects in both listeners',
+ message: 'rejects in both',
to: '46709771337',
});
@@ -2089,9 +1966,9 @@ describe('application hooks that throw or reject', () => {
session.on('close', () => { throw new Error('close listener exploded'); });
});
- const { session } = await connect(t, smpp);
+ const { client: esme } = await connect(t, smpp);
- assert.ok(session);
+ assert.ok(esme);
await smpp.close();
assert.equal(smpp.sessions.size, 0);
});
@@ -2099,43 +1976,30 @@ describe('application hooks that throw or reject', () => {
test('sends on through an application logger that throws', async t => {
const thrower = (): void => { throw new Error('the logger exploded'); };
const log: SmppLog = { debug: thrower, error: thrower, info: thrower, verbose: thrower, warn: thrower };
- const smpp = await startServer(t, { log });
-
- smpp.on('session', session => {
- session.on('sms', sms => { void sms.sendResp(); });
- });
-
- const { session } = await connect(t, smpp, { log });
-
- assert.ok(session);
-
- const sent = await session.sendSms({ from: '46701113311', message: 'logged', to: '46709771337' });
+ const smpp = await startServer(t, { log, onSms: () => undefined });
+ const esme = await bound(t, smpp, { log });
+ const sent = await esme.sendSms({ from: '46701113311', message: 'logged', to: '46709771337' });
assert.equal(sent.err, undefined);
});
test('refuses a send window that can never free a slot', async t => {
const smpp = await startServer(t);
- const { err, session } = await connect(t, smpp, { maxOutstanding: 0 });
+ const { err, client: esme } = await connect(t, smpp, { maxOutstanding: 0 });
assert.ok(err instanceof Error);
assert.match(err.message, /maxOutstanding/);
- assert.equal(session, undefined);
+ assert.equal(esme, undefined);
const negative = await connect(t, smpp, { shutdownTimeout: -1 });
assert.ok(negative.err instanceof Error);
assert.match(negative.err.message, /shutdownTimeout/);
- assert.equal(negative.session, undefined);
+ assert.equal(negative.client, undefined);
});
test('keeps the message id off a submit_sm_resp that refuses the message', async t => {
- const smpp = await startServer(t);
-
- smpp.on('session', bound => {
- bound.on('sms', sms => { void sms.sendResp({ status: 'ESME_RMSGQFUL' }); });
- });
-
+ const smpp = await startServer(t, { onSms: () => ({ status: 'ESME_RMSGQFUL' }) });
const peer = rawPeer(t, smpp.port);
peer.write(bindOf(0x34));
@@ -2158,18 +2022,19 @@ describe('application hooks that throw or reject', () => {
assert.deepEqual(refused.params, {});
});
- test('keeps the reconnect loop alive when connect throws', async t => {
+ test('keeps the reconnect loop alive when open throws', async t => {
let attempts = 0;
const loop = new ReconnectLoop({
- connect: () => {
+ log: silentLog,
+ maxDelay: 10,
+ minDelay: 1,
+ onDown: () => undefined,
+ onUp: () => undefined,
+ open: () => {
attempts++;
throw new Error('connect exploded');
},
- log: silentLog,
- maxDelay: 10,
- minDelay: 1,
- onConnected: () => Promise.resolve({}),
});
t.after(() => { loop.stop(); });
@@ -2177,7 +2042,7 @@ describe('application hooks that throw or reject', () => {
const retried = await waitFor(() => attempts >= 2);
- assert.ok(retried, 'a throwing connect should be retried, not left for the process to die on');
+ assert.ok(retried, 'a throwing open should be retried, not left for the process to die on');
});
test('keeps backing off when every link dies as soon as it comes up', async t => {
@@ -2193,53 +2058,67 @@ describe('application hooks that throw or reject', () => {
verbose: noop,
warn: noop,
};
+ const sessions: Session[] = [];
let up = 0;
const loop = new ReconnectLoop({
- connect: () => Promise.resolve({ sock: new net.Socket() }),
log,
maxDelay: 80,
minDelay: 10,
now: () => clock.now,
- onConnected: () => {
- up++;
+ onDown: () => undefined,
+ onUp: () => { up++; },
+ open: () => {
+ const session = new Session({ sock: new net.Socket() });
- return Promise.resolve({});
+ sessions.push(session);
+
+ return Promise.resolve({ session });
},
});
- t.after(() => { loop.stop(); });
+ t.after(async () => {
+ loop.stop();
+
+ for (const session of sessions) {
+ await session.close({ signal: AbortSignal.abort() });
+ }
+ });
for (let died = 0; died < 4; died++) {
loop.schedule();
await waitFor(() => up === died + 1);
+ await loop.current()?.close();
await delay(5);
}
assert.deepEqual(delays, [10, 20, 40, 80]);
// A link that outlasted the longest wait earned a fresh start.
- clock.now += 80;
loop.schedule();
- await waitFor(() => delays.length === 5);
+ await waitFor(() => up === 5);
+ clock.now += 80;
+ await loop.current()?.close();
+ await waitFor(() => delays.length === 6);
- assert.deepEqual(delays, [10, 20, 40, 80, 10]);
+ assert.deepEqual(delays, [10, 20, 40, 80, 80, 10]);
});
test('starts only one reconnect attempt at a time', async t => {
let attempts = 0;
let finish: (() => void) | undefined;
const loop = new ReconnectLoop({
- connect: () => {
+ log: silentLog,
+ maxDelay: 5,
+ minDelay: 1,
+ onDown: () => undefined,
+ onUp: () => undefined,
+ open: () => {
attempts++;
return new Promise(resolve => {
finish = () => { resolve({ err: new Error('no socket') }); };
});
},
- log: silentLog,
- maxDelay: 5,
- minDelay: 1,
- onConnected: () => Promise.resolve({}),
});
t.after(() => {
@@ -2250,48 +2129,44 @@ describe('application hooks that throw or reject', () => {
assert.ok(await waitFor(() => attempts === 1));
- // A second drop landing while the first attempt is still inside connect().
+ // A second drop landing while the first attempt is still inside open().
loop.schedule();
await delay(30);
assert.equal(attempts, 1);
});
+
});
describe('the server\'s onRequest hook', () => {
const answeredId = '01a07501-b609-7d27-ab98-d3b29bd78e7e';
- function submitTo(session: Session, to: string, message = 'screened by the hook') {
- return session.send({
+ function submitTo(esme: SmppClient, to: string, message = 'screened by the hook') {
+ return esme.send({
cmdName: 'submit_sm',
params: { destination_addr: to, short_message: message, source_addr: '46701113311' },
});
}
test('refuses an inbound submit_sm with the status the hook chose', async t => {
+ let messages = 0;
const smpp = await startServer(t, {
- onRequest: async (bound, pduObj) => {
+ onRequest: async (peer, pduObj) => {
if (!isCommand(pduObj, 'submit_sm')) return false;
- await bound.sendReturn(pduObj, 'ESME_RINVDSTADR');
+ await peer.sendReturn(pduObj, 'ESME_RINVDSTADR');
return true;
},
+ onSms: () => { messages++; },
});
- let messages = 0;
-
- smpp.on('session', bound => bound.on('sms', () => { messages++; }));
-
- const { session } = await connect(t, smpp);
-
- assert.ok(session);
-
- const answered = await submitTo(session, '46700000000');
+ const esme = await bound(t, smpp);
+ const answered = await submitTo(esme, '46700000000');
assert.equal(answered.err, undefined);
assert.ok(answered.pduObj);
assert.equal(answered.pduObj.cmdStatus, 'ESME_RINVDSTADR');
- assert.equal(messages, 0, 'a request the hook answered never reaches the sms event');
+ assert.equal(messages, 0, 'a request the hook answered never reaches the handler');
});
test('answers a bind itself, so a hook that claims every request cannot intercept one', async t => {
@@ -2332,26 +2207,19 @@ describe('the server\'s onRequest hook', () => {
assert.deepEqual(seen, [], 'a bind, and everything a peer sends before one, is never the hook\'s');
});
- test('passes a declined request to the sms event, and offers the keepalive and the unbind too', async t => {
+ test('passes a declined request to the handler, and offers the keepalive and the unbind too', async t => {
const seen: string[] = [];
+ const handed = handedOver(received => received.sendResp({ smsId: answeredId }));
const smpp = await startServer(t, {
- onRequest: (_bound, pduObj) => { seen.push(pduObj.cmdName); return false; },
+ onRequest: (_peer, pduObj) => { seen.push(pduObj.cmdName); return false; },
+ onSms: handed.onSms,
});
- const incoming = once<Sms>(resolve => {
- smpp.on('session', bound => bound.on('sms', async sms => {
- resolve(sms);
- await sms.sendResp({ smsId: answeredId });
- }));
- });
- const { session } = await connect(t, smpp);
-
- assert.ok(session);
+ const esme = await bound(t, smpp);
+ const answered = await submitTo(esme, '46709771337', 'declined by the hook');
+ const sms = await handed.sms;
- const answered = await submitTo(session, '46709771337', 'declined by the hook');
- const sms = await incoming;
-
- await session.send({ cmdName: 'enquire_link' });
- await session.unbind();
+ await esme.send({ cmdName: 'enquire_link' });
+ await esme.unbind();
assert.ok(answered.pduObj);
assert.equal(answered.pduObj.cmdStatus, 'ESME_ROK');
@@ -2361,21 +2229,16 @@ describe('the server\'s onRequest hook', () => {
});
test('reports a hook that throws and answers nothing for it', async t => {
+ let messages = 0;
const smpp = await startServer(t, {
onRequest: () => { throw new Error('the onRequest hook exploded'); },
+ onSms: () => { messages++; },
});
const failed = once<Error>(resolve => {
- smpp.on('session', bound => { bound.on('sessionError', resolve); });
+ smpp.on('session', peer => { peer.on('sessionError', resolve); });
});
- let messages = 0;
-
- smpp.on('session', bound => bound.on('sms', () => { messages++; }));
-
- const { session } = await connect(t, smpp, { responseTimeout: 300 });
-
- assert.ok(session);
-
- const answered = await submitTo(session, '46709771337');
+ const esme = await bound(t, smpp, { responseTimeout: 300 });
+ const answered = await submitTo(esme, '46709771337');
const reported = await failed;
assert.ok(answered.err instanceof Error);
@@ -2384,21 +2247,16 @@ describe('the server\'s onRequest hook', () => {
});
test('reports a hook that rejects and answers nothing for it', async t => {
+ let messages = 0;
const smpp = await startServer(t, {
onRequest: () => Promise.reject(new Error('the onRequest hook rejected')),
+ onSms: () => { messages++; },
});
const failed = once<Error>(resolve => {
- smpp.on('session', bound => { bound.on('sessionError', resolve); });
+ smpp.on('session', peer => { peer.on('sessionError', resolve); });
});
- let messages = 0;
-
- smpp.on('session', bound => bound.on('sms', () => { messages++; }));
-
- const { session } = await connect(t, smpp, { responseTimeout: 300 });
-
- assert.ok(session);
-
- const answered = await submitTo(session, '46709771337');
+ const esme = await bound(t, smpp, { responseTimeout: 300 });
+ const answered = await submitTo(esme, '46709771337');
const reported = await failed;
assert.ok(answered.err instanceof Error);
@@ -2453,13 +2311,13 @@ describe('the server\'s onRequest hook', () => {
},
shutdownTimeout: 2000,
});
- const bound = once<Session>(resolve => { smpp.on('session', resolve); });
- const { session } = await connect(t, smpp);
+ const accepted = once<Session>(resolve => { smpp.on('session', resolve); });
+ const { client: esme } = await connect(t, smpp);
- assert.ok(session);
+ assert.ok(esme);
- const answered = await submitTo(session, '46709771337');
- const serverSide = await bound;
+ const answered = await submitTo(esme, '46709771337');
+ const serverSide = await accepted;
const started = Date.now();
const closed = await serverSide.close();
const waited = Date.now() - started;
@@ -2474,17 +2332,17 @@ describe('the server\'s onRequest hook', () => {
describe('link timers', () => {
test('closes a client link the peer has stopped answering', async t => {
const peer = await smscPeer(t);
- const { err, session } = await client({
+ const { err, client: esme } = await client({
enquireLinkInterval: 50,
port: peer.port,
reconnect: false,
});
assert.equal(err, undefined);
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
- const closed = once<true>(resolve => { session.on('close', () => { resolve(true); }); });
+ const closed = once<true>(resolve => { esme.on('close', () => { resolve(true); }); });
assert.ok(
await raceWithin(1000, closed),
@@ -2494,16 +2352,16 @@ describe('link timers', () => {
test('reconnects a link that timed out', async t => {
const peer = await smscPeer(t);
- const { session } = await client({
+ const { client: esme } = await client({
enquireLinkInterval: 40,
port: peer.port,
reconnect: { maxDelay: 20, minDelay: 10 },
});
- assert.ok(session);
- closeAfter(t, session);
+ assert.ok(esme);
+ closeAfter(t, esme);
- const back = once<true>(resolve => { session.on('reconnected', () => { resolve(true); }); });
+ const back = once<true>(resolve => { esme.on('reconnected', () => { resolve(true); }); });
assert.ok(await raceWithin(2000, back), 'a link that timed out should be reconnected');
});
diff --git a/test/tls.test.ts b/test/tls.test.ts
index 1e311f4..b28cb32 100644
--- a/test/tls.test.ts
+++ b/test/tls.test.ts
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import net from 'node:net';
import test, { describe } from 'node:test';
+import type { OnSms } from '../src/session-options.ts';
import type { Sms } from '../src/sms.ts';
import type { SmppServer } from '../src/server.ts';
import type { TestContext } from 'node:test';
@@ -104,8 +105,9 @@ function createCertificate(): { cert: string; key: string } {
const certificate = createCertificate();
-async function startServer(t: TestContext): Promise<SmppServer> {
+async function startServer(t: TestContext, onSms?: OnSms): Promise<SmppServer> {
const { err, server: smpp } = await server({
+ ...(onSms ? { onSms } : {}),
port: 0,
tls: { cert: certificate.cert, key: certificate.key },
});
@@ -123,20 +125,27 @@ function once<T>(register: (resolve: (value: T) => void) => void): Promise<T> {
describe('tls', () => {
test('binds over a verified handshake and delivers an SMS', async t => {
- const smpp = await startServer(t);
- const incoming = once<Sms>(resolve => {
- smpp.on('session', session => session.on('sms', resolve));
+ let onIncoming: ((sms: Sms) => void) | undefined;
+ const incoming = once<Sms>(resolve => { onIncoming = resolve; });
+ const smpp = await startServer(t, sms => {
+ onIncoming?.(sms);
+
+ return { smsId: 'tls-id' };
});
- const { err, session } = await client({
+ const { err, client: esme } = await client({
host,
port: smpp.port,
tls: { ca: certificate.cert },
});
assert.equal(err, undefined);
+ assert.ok(esme);
+ closeAfter(t, esme);
+
+ const session = esme.session;
+
assert.ok(session);
assert.equal(session.boundAs, 'transceiver');
- closeAfter(t, session);
const sock = session.sock;
@@ -145,12 +154,8 @@ describe('tls', () => {
assert.equal(sock.getPeerCertificate().subject.CN, host);
const [sms, sent] = await Promise.all([
- incoming.then(async received => {
- await received.sendResp({ smsId: 'tls-id' });
-
- return received;
- }),
- session.sendSms({ from: 'MyBrand', message: 'hello over tls', to: '46709771337' }),
+ incoming,
+ esme.sendSms({ from: 'MyBrand', message: 'hello over tls', to: '46709771337' }),
]);
assert.equal(sms.message, 'hello over tls');
@@ -158,21 +163,21 @@ describe('tls', () => {
assert.equal(sent.err, undefined);
assert.deepEqual(sent.smsIds, ['tls-id']);
- assert.deepEqual(await session.unbind(), {});
+ assert.deepEqual(await esme.unbind(), {});
});
test('returns an error rather than throwing when the certificate is not trusted', async t => {
const smpp = await startServer(t);
- const { err, session } = await client({ host, port: smpp.port, tls: {} });
+ const { err, client: esme } = await client({ host, port: smpp.port, tls: {} });
assert.ok(err instanceof Error);
assert.match(err.message, /self.signed certificate/);
- assert.equal(session, undefined);
+ assert.equal(esme, undefined);
});
test('returns an error when the certificate does not cover the host', async t => {
const smpp = await startServer(t);
- const { err, session } = await client({
+ const { err, client: esme } = await client({
host: '127.0.0.1',
port: smpp.port,
tls: { ca: certificate.cert },
@@ -180,7 +185,7 @@ describe('tls', () => {
assert.ok(err instanceof Error);
assert.match(err.message, /altnames/);
- assert.equal(session, undefined);
+ assert.equal(esme, undefined);
});
test('refuses to listen over tls without a certificate', async () => {
diff --git a/todo.md b/todo.md
index 3e5b9c7..0997258 100644
--- a/todo.md
+++ b/todo.md
@@ -21,16 +21,17 @@ public surface is documented in [README.md](README.md); this is the short form.
```ts
import { client, server } from '@larvit/smpp';
-const { err, session } = await client({ host, password, port, username });
-const { err: sendErr, pduObjs, smsIds } = await session.sendSms({ dlr, from, message, to });
-await session.unbind();
+const { err, client: esme } = await client({ host, onSms, password, port, username });
+const { err: sendErr, pduObjs, smsIds } = await esme.sendSms({ dlr, from, message, to });
+await esme.unbind();
-const { err: serverErr, server: smpp } = await server({ authenticate, port });
-smpp.on('session', session => {
- session.on('sms', async sms => {
+const { err: serverErr, server: smpp } = await server({
+ authenticate,
+ onSms: async sms => {
await sms.sendResp();
if (sms.dlr) await sms.sendDlr('DELIVERED');
- });
+ },
+ port,
});
await smpp.close();
```