diff --git a/AGENTS.md b/AGENTS.md index 882bb24..ae676cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,20 +38,23 @@ 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, session }, and the first-connect retry of reconnect.fromStart 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: the public methods and events, and what each life transition does to the collaborators below + session-life.ts SessionLife: the one state value, every transition, and the reconnect loop as the `down` state + sms.ts The Sms an onSms handler gets (sendResp/sendDlr), and the one record of whether it was answered + backoff.ts Backoff: the wait before each connect attempt concat.ts How a PDU says it is a segment: its UDH, or the sar_* TLVs + defaults.ts Every default, grouped by the surface that fills it dlr.ts Delivery receipts: text and TLV parsing, receipt status codes dlr-merger.ts DlrMerger: per-segment receipts counted into one MessageDlr 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 DlrMerger, HandledMessages and Reassembler share + handled-messages.ts HandledMessages: the messages whose onSms is running, counted, waited on, and answered once it settles 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 link-timers.ts LinkTimers: the enquire_link heartbeat and the idle timeout + link-waiters.ts LinkWaiters: the requests with no bound link to go out on 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 @@ -62,12 +65,11 @@ src/ pdu-transport.ts PduTransport: the socket a session reads complete PDUs off 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 result.ts Result — 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 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, ReconnectOptions, OnSms, bind direction and the option checks sms-id.ts Message ids: the peer's notation, the - 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 +85,11 @@ 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`. `SessionLife` reaches the rest of the session only +through the `LifeEffects` it is handed, and `OutgoingRequests` reads its state through a function. +The ways back up are the `Session` handed to `createSms()` and `IncomingRequests` — because +`Sms.session` and `OnRequest` are public and name it — and `OnRequest`, `OnSms` and `onConnected` in +`session-options.ts`, all imported as a type only. **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 @@ -288,13 +292,14 @@ this is not a changelog. - A stream this library cannot frame is a dead link; one PDU it cannot parse is not. - A deliberate shutdown drains; an unusable link and an abort do not. - `sendSms()` puts every segment of a message on the wire together. -- Every segment of a concatenated message is answered as it arrives, so `sendResp()` on one is the - application's own signal rather than the peer's answer. +- Every segment of a concatenated message is answered as it arrives, so `sendResp()` on one is + nothing to do. - `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`. +- An inbound message goes to `onSms`, the handler's promise is the hold on it, and the library + answers it once the handler has settled. +- A session with no `onSms` refuses every inbound message with the retry status and reports each on + `sessionError`. - What the application holds unanswered 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, @@ -304,13 +309,15 @@ this is not a changelog. - 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 session's life is one state value, every transition is in `SessionLife.enter()`, and the + reconnect loop is the `down` state. ### [Internals and tests](docs/decisions.md#internals-and-tests) +- Every default lives in `defaults.ts`, grouped by the surface that fills it. - 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 `LinkWaiters`, `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..83cfde1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ ## 0.6.0 (unreleased) +- **Inbound messages go to an `onSms` handler option**, on `client()`, `server()` and `Session`; the + `sms` event is gone. A message is answered `ESME_ROK` under `sms.smsId` when the handler returns, + unless `sendResp()` answered it first, and refused with a retry status (`ESME_RTHROTTLED` on a + submission, `ESME_RX_T_APPN` on a delivery) when the handler throws or rejects before answering, + or when the session has no `onSms` at all. `close()` waits for the handlers still running, up to + `shutdownTimeout`; `sendResp()` and `sendDlr()` still go out during that wait. `sms.answered` says + whether the peer has been answered, and replaces `answeredOnArrival`; a `sendResp()` that would + change an answer already given returns `err`, and one asking for the same answer does nothing. + `shutdownTimeout: 0` waits for a handler as long as it runs, bounded by the five minutes past which + one is no longer counted, where it used to give the messages `responseTimeout`. +- **`encoding: 'ASCII'` is `encoding: 'GSM7'`**, the alphabet it always wrote; `encodings.GSM7`, + `dataCodingByEncoding.GSM7` and what `detect()`, `encodeMessage()` and `encodingByDataCoding()` + return follow. `'ASCII'` is refused by name. +- `session.state` reads where the session is: `connected`, `bound`, `closing`, `down` or `ended`, + the `LinkState` type. A `send()` on a session you constructed yourself waits for `bound()` before + it goes out, up to `responseTimeout`, since a link not yet bound refuses it; a bind goes out at once. +- A `sendResp()` the wire did not carry leaves `sms.smsId` as it was, so a later `sendDlr()` names + no id the peer was never given. - `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 @@ -42,9 +60,8 @@ the cap is lost, since its segments were already answered. - A message arriving while the application holds 1000 unanswered, or 64 MiB of them counted the way `maxOctets` counts segments, is refused with `ESME_RTHROTTLED` (`ESME_RX_T_APPN` on a - delivery), so the peer keeps it and retries. **Call `sendResp()` on every `sms`, multipart - included**: 1000 left unanswered now stop inbound traffic for up to five minutes, where the oldest - used to be dropped with a warning. + delivery), so the peer keeps it and retries. 1000 messages whose handler has not returned now stop + inbound traffic for up to five minutes, where the oldest used to be dropped with a warning. - A `submit_sm` segment the reassembly buffer has no room for is refused with `ESME_RTHROTTLED`, where it was `ESME_RMSGQFUL`. - `server()` refuses a `maxOctets` below 1 or not a whole number, `Infinity` included, like its diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..e6ea437 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,89 @@ +# Draft E: `onSms` holds the message, the session's life is one machine + +Rounds one and two showed two caps, the held-message contract and the lifecycle. This draft removes +both at once, goal-2 safe. + +## The contract, as an implementer sees it + +- **Receiving.** `onSms: sms => …`, an option on `client()`, `server()` and `Session`. The message is + held while the handler's promise runs: it counts toward the bound (1000, 64 MiB) and `close()` + waits for it, up to `shutdownTimeout`. A handler running five minutes is no longer waited for. +- **Answering, in one sentence.** The message is answered `ESME_ROK` under `sms.smsId` when the + handler returns, unless `sms.sendResp()` answered it first; a handler that throws has it refused + with the retry status, so the peer sends it again. No `onSms`: every message is refused that way and + reported on `sessionError`. `sendResp({ smsId, status })` names the id or refuses; an answer already + given cannot change (`err`), asking for the same one again does nothing. `sms.answered` says where + you are; a multipart message arrives with it already true. +- **Receipts.** `sms.sendDlr()` goes out where it is called, after `sendResp()` when written after + it, and during a drain too. No timing rule. +- **Sending and shutdown.** `sendSms()`/`send()` unchanged; `close()` refuses them, waits for the + handlers, then the requests on the wire, then tears down. `session.state` is public, read-only. +- **Names.** `encoding: 'GSM7'` (`'ASCII'` refused). README carries a glossary under "SMPP terms". + +## The lifecycle, in `session-life.ts` + +``` +connected --bound()---------> bound connected: a socket with no bind yet; carries a bind only +connected --close()---------> ended +connected --link lost-------> down | ended down where a reconnect policy exists +bound -----close()/unbind()-> closing draining; sends refused, receipts still go out +bound -----link lost--------> down | ended +closing ---drained----------> ended +closing ---link lost--------> ended +down ------socket opened----> connected the policy rebinds, which is what reaches bound() +down ------attempt failed---> down backs off (Backoff) and waits again +down ------close()----------> ended +``` + +One value, `SessionLife.state`; one method, `enter()`, does every transition's effects through a +five-function `LifeEffects` seam; a transition asked of a state that has none is ignored; every event +is emitted last, after state and effects, so a listener that calls `close()` from `disconnected` +re-enters harmlessly (tested). The retry timer is the `down` state's and dies with it, so no stopped +flag exists; `attempt()` is the one continuation returning into the machine after an await, and it +checks the state and link it came back to. + +## Who owns which state + +| Module | Owns | +| --- | --- | +| `session-life.ts` | `state`, the retry timer, `Backoff`, the link count (second bind onwards is `reconnected`). | +| `session.ts` | The public API; the `LifeEffects` closures (the only place a transition touches collaborators); `bind`. | +| `outgoing-requests.ts` | `request()`: one path — link wait (`LinkWaiters`), window, attempt, retry only while `down`. `requestOnLink()`: a bind or unbind, straight out. Reads `state` through a function, copies nothing. | +| `incoming-requests.ts` | Routing, synchronous but for the hook and the peer's `unbind`. Answers through an `answer` closure, receipts through `send`; holds `session` because `Sms.session` and `OnRequest` are public. | +| `handled-messages.ts` | The running handlers: count, weight, deadline, `idle()` for the drain, and the answer after settle. | +| `sms.ts` | The one record of "answered" (`{ smsId, status }`), read by `answered`, written by `sendResp()`. | +| `defaults.ts` | Every default, grouped `client`/`reconnect`/`server`/`session`. | +| `backoff.ts` | The delay sequence, shared by the machine and `client()`'s first-connect retry. | + +## Deleted + +`link-life.ts` (`LinkLife`, seven predicates, `generation()`), `reconnect-loop.ts` (and its `halted`), +`held-messages.ts` (`HeldMessages`, `MessageHold`, the `WeakMap`, `setImmediate`, the listener count), +`Session.linkLost/end/stop/emitClose/teardown/onClose/comeBackUp/attach/resetTimers/answering`, the +`captureRejections` route into the hold, `OutgoingRequests.requestPastDrain/requestOnCurrentLink`, +`IncomingRequests.refusing`, the `sms` event, `answeredOnArrival`, four `defaults` objects and three +lone constants, `EncodingName 'ASCII'`, `DlrMerger.close()` (now `spend()`), and `client.ts`'s +"close() must reach stop() before its first await". + +## Tests + +`docker compose run --rm node npm test`: lint and typecheck clean, **517 tests, 517 pass** (main: 512). +Changed, never weaker on the wire or goal 2: + +- Everywhere: `session.on('sms', …)` → the `onSms` option; an `inbox()` helper hands each message to + the test and stays in the handler until `answer()`/`release()`, a `stuck` handler never returns. +- `LinkLife` (6) → `SessionLife` (7): the table, drain order, reconnect round trip, re-entrant close, + backoff, one attempt at a time, throwing connect. `ReconnectLoop` (4) → those plus a `Session`-level + "leaves no socket open when coming back up fails". `held message bounds` (6) → `handled message + bounds` (9): exits are handler return, sweep, clear; added answer-on-return, refuse-on-throw, + refuse-with-no-handler. `sendResp()`: added "answers once, and refuses to change an answer". +- `graceful shutdown`: messages read `still being handled`; "falls back to responseTimeout at 0" → + "waits for a handler as long as it runs"; "a message no listener took" → "no handler takes is + refused at once"; "a listener that rejected" → "handler … refused for it"; "waits for the listener + still working when another rejected" → "keeps waiting for a handler that answered and is still + working"; the client of "still ends when the message half…" and "counts the segments queued…" holds + with `stuck`, since nothing is unanswered by default any more. +- "refuses to answer a message whose link went…": the answered one is now nothing to do; "refuses an + id and a refusing status…" asserts `already answered under id`; throwing/rejecting listener tests + assert the peer's `ESME_RTHROTTLED`; `'ASCII'` → `'GSM7'`; `readme.test.ts` mirrors the new + examples; `interop-tests/` and `benchmarks/` typecheck (not run here). diff --git a/MIGRATION-NOTES.md b/MIGRATION-NOTES.md new file mode 100644 index 0000000..857a941 --- /dev/null +++ b/MIGRATION-NOTES.md @@ -0,0 +1,19 @@ +# Breaking changes for a 0.5.0 user + +| Was | Now | +| --- | --- | +| `session.on('sms', async sms => { … })` | `client({ onSms: sms => { … } })`, `server({ onSms })` or `new Session({ onSms, sock })`: one handler, given at construction. | +| `smpp.on('session', s => s.on('sms', …))` | `server({ onSms: sms => { … } })`; `sms.session` is the session the message came in on. | +| `await sms.sendResp()` at the end of the handler | Delete it: the message is answered `ESME_ROK` under `sms.smsId` when the handler returns. Keep it where the answer must come first, or with `{ smsId, status }`. | +| A handler that returns without answering leaves the peer waiting | It answers `ESME_ROK`; keep the handler's promise pending for as long as the message should stay unanswered. | +| A listener that throws or rejects leaves the message unanswered | The message is refused (`ESME_RTHROTTLED` on a submission, `ESME_RX_T_APPN` on a delivery) so the peer retries; catch what you want answered otherwise. | +| No `sms` listener leaves every message unanswered | No `onSms` refuses every message the same way and reports each on `sessionError`; give a receiving session a handler. | +| `if (sms.answeredOnArrival) …` | `if (sms.answered) …`: true on arrival for a multipart message, and after `sendResp()`. | +| `sendResp()` twice, or `sendResp({ status })` after `sendResp()` | The same answer again is `{}`; a different one is `err`. Answer once. | +| `close()` waits until `sendResp()` reaches the wire | It waits until the handler returns; `err.message` reads `… N message(s) still being handled`. | +| `sendDlr()` straight after `sendResp()`, in the same turn, to pass a drain | `sendDlr()` goes out from a running handler whenever; the rule is gone. | +| `shutdownTimeout: 0` gives the messages `responseTimeout` | It waits for a handler as long as it runs, up to the five minutes past which one is no longer counted. | +| `encoding: 'ASCII'` | `encoding: 'GSM7'`; likewise `encodings.GSM7`, `dataCodingByEncoding.GSM7`, `encodeMessage(…).encoding === 'GSM7'`. `'ASCII'` is refused by name. | +| `SessionEvents` has an `sms` key | It has none; a listener typed against it moves to `OnSms`. | +| `new Session({ sock })` then `session.send({ cmdName: 'submit_sm' })` | Call `session.bound(bindType, version)` first (or send the bind through `send()` and record its answer): a send waits for the bind, up to `responseTimeout`. | +| A `sendResp()` that fails still renames `sms.smsId` | It leaves `sms.smsId` as it was; read it after a successful answer. | diff --git a/MIGRATION.md b/MIGRATION.md index 0296ede..6b05b0b 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -11,6 +11,9 @@ shape is the same, connect, send, listen for delivery reports, with callbacks re `close()`, or the socket outlives the call. - **`server()` resolves once, when it is listening**, with a handle carrying `close()`, `port` and a `session` event. It no longer calls back once per connection. +- **The `sms` event is the `onSms` option**, on `client()`, `server()` and `Session`: + `onSms: sms => { … }`. The message is answered `ESME_ROK` once the handler returns, or earlier by + `sendResp()`; a handler that throws has it refused with a retry status. - **The id a message is answered with goes to `sendResp({ smsId })`.** `sms.smsId` is read-only: the id the segments were answered with, the id `sendResp()` was given, or the generated UUID v7. Assigning to it throws a `TypeError` in strict-mode code (every ES module, and any file under @@ -44,7 +47,8 @@ shape is the same, connect, send, listen for delivery reports, with callbacks re - **`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). - **`consts.ENCODING.ASCII` is gone**; the same entry is `consts.ENCODING.IA5`, the other name SMPP - 3.4 5.2.19 gives 0x01. `dataCodingByEncoding` is the alphabet `sendSms()` writes, which is 0x00. + 3.4 5.2.19 gives 0x01. The `encoding` option's GSM 03.38 alphabet is `GSM7`, and + `dataCodingByEncoding.GSM7` is what `sendSms()` writes it under, which is 0x00. ## Behaviour that changed on the wire @@ -85,7 +89,7 @@ have for these: directions. 0.4.0 kept only the last one it read. - A body carried in the `message_payload` TLV was ignored, so the message arrived empty, and a `data_sm` was answered `ESME_RINVCMDID`, so a receipt thrown on one was lost silently. Both reach - the application now: a receipt as `dlr`, answered for you, and a message as `sms` for you to answer. + the application now: a receipt as `dlr`, answered for you, and a message to `onSms`. - A long message segmented by the `sar_msg_ref_num`, `sar_total_segments` and `sar_segment_seqnum` TLVs rather than a user data header was never reassembled, so each segment arrived as its own message. Both spellings reassemble now. diff --git a/README.md b/README.md index 9ff2b7d..2cc17ca 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ window, long messages and delivery receipts. TypeScript, ESM, no dependencies. - **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. -- **Graceful shutdown.** `close()` waits for what is in flight, so neither end has to guess. +- **Graceful shutdown.** `close()` waits for your message handlers and for what is in flight, so + neither end has to guess. - **Never throws.** Every fallible call resolves to `{ err?, … }`. - **Interoperable.** Tested as a client against Jasmin and SMPPSim, and as a server against Kannel, jsmpp, Cloudhopper, python-smpplib and php-smpp: @@ -19,7 +20,8 @@ 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) · +[Send options](#send-options) · [SMPP terms](#smpp-terms) · [Session](#session) · +[Receiving in depth](#receiving-in-depth) · [Server in depth](#server-in-depth) · [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) · @@ -92,34 +94,33 @@ 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 mobile-originated messages to its `onSms` handler: ```javascript -session.on('sms', async sms => { - // sms.from, sms.to, sms.message - await sms.sendResp(); +const { err, session } = await client({ + onSms: sms => { + // sms.from, sms.to, sms.message; answered ESME_ROK once this returns + }, }); ``` -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). +The message is answered when your handler returns: `ESME_ROK` under `sms.smsId`, or the answer +you gave first with `sms.sendResp()`. Until it returns, the message counts toward the bound past +which the peer's messages are refused, and `close()` waits for it. A handler that throws has the +message refused with a retry status, so the peer sends it again. Delivery receipts reach you as +`dlr` events, not here: [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: sms => { + // sms.from, sms.to, sms.message, sms.dlr, sms.session + }, }); +if (err) throw err; ``` With authentication and delivery reports: @@ -134,34 +135,32 @@ 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 => { + // sms.session.userData is what authenticate returned for this peer + // no args: ESME_ROK + generated id; or sendResp({ smsId, status: 'ESME_RMSGQFUL' }) + await sms.sendResp(); 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. +- `sendResp()` answers `ESME_ROK` with a generated UUID v7 as the message id, now rather than when + the handler returns. `sendResp({ smsId, status })` names the id or refuses the message. An answer + cannot change: a second call asking for the same answer does nothing, one asking for another + returns `err`. - `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 goes out after the response when called after + `sendResp()` as above: [Server in depth](#server-in-depth). +- A message that arrived in several segments was answered as they arrived, so `sms.answered` is + already true and `sendResp()` there takes no `smsId` or refusing `status`. +- `smpp.close()` waits for every running `onSms` before it closes a session, so nothing is cut off. ## Errors @@ -182,8 +181,8 @@ Neither is named `error`, because Node throws on an unhandled `error` event. | 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. Its segments were answered, so the peer will not resend, and it never reached `onSms`. | `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: @@ -231,8 +230,9 @@ 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 for requests already sent. `0` waits forever: a request ends when the peer answers or `responseTimeout` expires, so both at `0` never ends, and a handler ends when it returns or after the five minutes past which one is no longer counted. | | `maxOutstanding` | `10` | Requests on the wire at once; further sends queue. | +| `onSms` | refuse | `(sms) => Promise \| void`. Takes every inbound message: [Receive SMS](#receive-sms). Without one, every message is refused with a retry status and reported on `sessionError`. | | `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. | | `log` | silent | Any object with `debug`, `error`, `info`, `verbose` and `warn` methods: [Logging](#logging). | @@ -257,6 +257,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` | refuse | `(sms) => Promise \| void`. Takes every message a bound peer submits, on every session; `sms.session` says which: [Run an SMPP server](#run-an-smpp-server). | | `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. | @@ -298,11 +299,11 @@ refuse a non-ASCII sender of its own accord, which reaches you as a refusal such | `encoding` | Alphabet | Characters per SMS | Per segment of a long message | | --- | --- | --- | --- | -| `ASCII` | GSM 03.38 7-bit | 160 | 153 | +| `GSM7` | GSM 03.38 7-bit | 160 | 153 | | `LATIN1` | ISO 8859-1 | 140 | 134 | | `UCS2` | UCS-2 | 70 | 67 | -- Omitted: `ASCII` where the message fits GSM 7-bit, otherwise `UCS2`. `LATIN1` only when named. +- Omitted: `GSM7` where the message fits GSM 7-bit, otherwise `UCS2`. `LATIN1` only when named. Any other name is refused. - GSM extension characters (`{}[]\~^|€` and form feed) count as two, as does a character outside the basic multilingual plane in `UCS2`. @@ -353,13 +354,33 @@ yourself, a `Buffer` body or a stamp you formatted, passes through as written, e field is still checked: [PDUs and the low-level API](#pdus-and-the-low-level-api). The same rule holds for `session.send()`. +## SMPP terms + +The words this README and the wire share, each once. + +| Term | | +| --- | --- | +| ESME, SMSC | The two ends of a link. The ESME (external short message entity) is the application's side, the one `client()` is; the SMSC (short message service centre, or MC) is the operator's, the one `server()` stands in for. | +| PDU | Protocol data unit: one SMPP request or response on the wire, a 16-octet header and its fields. | +| bind | The login that opens a link, as a `transmitter`, `receiver` or `transceiver`; `system_id` and `password` are its credentials. | +| `submit_sm`, `deliver_sm` | The message commands: an ESME submits to the SMSC, the SMSC delivers to the ESME. `data_sm` carries a message either way. | +| DLR, receipt | A delivery receipt: the SMSC's report on a submitted message, carried on `deliver_sm` and telling the ESME the message's state. | +| `esm_class` | The octet that says what a message is: its messaging mode, whether it is a receipt, and whether its body starts with a UDH. | +| `data_coding` | The octet naming the body's alphabet, and for some values its message class (flash). | +| UDH | User data header: octets at the front of a body that, for a long message, number its segments. | +| `sar_*` | The three optional parameters that number a segment the other way, with no UDH. | +| TLV | Tag-length-value: an optional parameter after the mandatory fields, `message_payload` and `receipted_message_id` among them. | +| `message_id` | The id the SMSC hands out for a submitted message, and what a receipt names it by; `smsId` on this API. | + ## Session ### Events +A request you must answer is a handler option: `onSms`, and `onRequest` on a server. A fact you may +watch is an event, and nothing waits on its listeners. + | 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. | @@ -374,19 +395,20 @@ holds for `session.send()`. `sendSms()`, `send()`, `sendReturn()`, `unbind()` and `close()`. +**Where the session is.** `session.state` is one of `connected` (a socket, no bind on it yet), +`bound`, `closing` (draining), `down` (dropped, a reconnect on its way) and `ended`. The events above +mark the transitions; nothing else changes it. + **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. +1. Refuse further `sendSms()` and `send()`. `sendResp()` and `sendDlr()` still go out, because they + finish a message the shutdown is waiting on. +2. Wait up to `shutdownTimeout` for every `onSms` handler still running, then for the requests + already sent. 3. Tear down what is left, 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 still running after 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. **Sends and the link.** @@ -426,9 +448,10 @@ const { err, pduObj } = await session.send({ - `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. + end accepted it, on every link it binds, and what lets that link carry requests: until it is called + a `send()` waits for it, up to `responseTimeout`, and a bind goes out at once. `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_` through `session.send()`, after it is constructed and again in `reconnect.onConnected`, and records each accepted one with `session.bound(bindType, pduObj.tlvs.sc_interface_version?.tagValue)`, where `bindType` is the one @@ -442,12 +465,15 @@ 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. +- **Messages being handled.** While 1000 `onSms` handlers are running on a session, or the messages + they hold weigh 64 MiB 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. The handler is not called. 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 dropped from the + count with a `warn`; its `sendResp()` still answers. None of the three is an option. +- **A handler that fails.** One that throws or rejects reaches `sessionError`. Where it had not + answered, the message is refused with the same retry status, so the peer sends it again; where it + had, that answer stands. A session with no `onSms` refuses every message the same way. - **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`. @@ -505,11 +531,11 @@ even where the SMSC took some of its segments; their receipts still arrive as `d 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. -`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. +`sms.answered` is already true on such a message when your handler gets it; a segment count cannot +say, 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 + returns `err` for an `smsId` or a refusing `status`. - `sms.smsId` is the base. `sendDlr()` names `-1`, `-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 +543,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'; @@ -668,7 +694,7 @@ if (isCommand(pduObj, 'submit_sm')) { | 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, hook, result, event payload and table entry has a named type: `ClientOptions`, `ServerOptions`, `OnSms`, `LinkState`, `SendSmsOptions`, `SendSmsResult`, `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..02a2e5d 100644 --- a/benchmarks/smsc-sink.ts +++ b/benchmarks/smsc-sink.ts @@ -5,22 +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/docs/decisions.md b/docs/decisions.md index c245097..0f41884 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -27,7 +27,9 @@ rule and an index of the titles below. set.delete(session))` returns a boolean. This also settles what the drain can wait on: a listener's own promise would be the better completion signal, and reaching it needs `listeners()`, which cannot be re-declared the same way — Node types it invariantly enough that widening `void` to - `unknown` is `TS2416`. Re-probed 2026-09-01; `sendResp()` stays the signal. + `unknown` is `TS2416`. That is why a message goes to a handler option rather than an event: the + handler's own promise is the completion signal a listener cannot give (under [The session's + life](#the-sessions-life)). - **`PduRefusedError` is exported, and `sessionError` names it in the event's type.** Maintainer's call, 2026-09-05, from a product review: one event carries both a PDU the peer malformed and the @@ -226,7 +228,7 @@ rule and an index of the titles below. `encodingByDataCoding()` reads the alphabet off that same test rather than repeating the group masks beside it. It is exported for the reason `concatOf()` is — an application that needs a class other than 0 would otherwise rewrite the read this fixed. Rejected: a `messageClass` field on the - `sms` event, which pays goal 8 for three classes nothing here acts on, where the boolean the + `Sms`, which pays goal 8 for three classes nothing here acts on, where the boolean the application already had covers the one it does. Compressed text is out of scope and stays out — nothing here implements 3GPP TS 23.042, so a compressed body reaches the application as whatever its declared alphabet makes of it — but bit 5 does not move the class bits, so 0x30 is read as @@ -583,23 +585,23 @@ rule and an index of the titles below. one round trip rather than one per segment. Rejected: sending each segment once the last is answered, which a receiver waiting for the whole message before answering would deadlock. -- **Every segment of a concatenated message is answered as it arrives, so `sendResp()` on one is the - application's own signal rather than the peer's answer.** Maintainer's call, 2026-09-06, from the - Jasmin interoperability phase: Jasmin dispatches one `submit_sm` per connector at a time and will - not send segment 2 until segment 1 is answered, so holding a group unanswered until it was whole - deadlocked every multi-segment message against a production gateway +- **Every segment of a concatenated message is answered as it arrives, so `sendResp()` on one is + nothing to do.** Maintainer's call, 2026-09-06, from the Jasmin interoperability phase: Jasmin + dispatches one `submit_sm` per connector at a time and will not send segment 2 until segment 1 is + answered, so holding a group unanswered until it was whole deadlocked every multi-segment message + against a production gateway ([interop-tests/findings/03-jasmin.md](../interop-tests/findings/03-jasmin.md)). Goal 1 has the answer a real SMSC gives — one `message_id` per `submit_sm`, immediately — so the group's id base is generated when it opens and each segment is answered `-`, the notation `sms-id.ts` owns and `DlrMerger` reads back. The id is therefore fixed by the first segment, which is why an `smsId` or a refusing `status` passed to `sendResp()` on such a message is an error rather than a silent - no-op. `answeredOnArrival` is on `Sms` because nothing the application can compute says it, and the - discriminant a reader would reach for instead is wrong. A message `sendResp()` still answers itself is - untouched, and is where a caller-chosen id and a refusal live; `onRequest` is the escape hatch for - an application that must refuse a PDU the `sms` event could not have shown it yet. `collect()` - answers every segment it will not carry rather than leaving it unanswered, which is the same stall - in miniature: the field that numbered it where the segment belongs to no group, the retry status - where the segment's own arrival overran the octet cap, since a peer told that still holds it. Rejected: + no-op — the same rule as for any answer already given. `Sms.answered` is true on such a message + from the start because nothing the application can compute says it, and the discriminant a reader + would reach for instead, the segment count, is wrong. `onRequest` is the escape hatch for an + application that must refuse a PDU `onSms` could not have shown it yet. `collect()` answers every + segment it will not carry rather than leaving it unanswered, which is the same stall in miniature: + the field that numbered it where the segment belongs to no group, the retry status where the + segment's own arrival overran the octet cap, since a peer told that still holds it. Rejected: answering every segment but the one that completes the group, which leaves the peer holding some segments accepted and one refused with nothing in SMPP to retract the rest, and still cannot honour a caller's `smsId` on the segments already gone. Rejected: a hook that mints the id per segment, @@ -608,12 +610,12 @@ rule and an index of the titles below. distinguishing feature is that it deadlocks. Accepted: a group given up on — expired, evicted, or dropped with the link — is traffic the peer will not send again, so each one reaches `sessionError` as well as the log. Rejected there: an exported `MessageLostError` carrying the group, on the - `PduRefusedError` pattern — no `sms` ever fired for that group, so there is nothing in it the + `PduRefusedError` pattern — no handler ever ran for that group, so there is nothing in it the application could act on, and goal 8 does not buy a second exported class to make a count distinguishable. Accepted: a completing segment whose own answer the socket would not carry still reaches the application, because the message is whole and correct and the failed answer is on `sessionError` — a peer that re-sends after the drop is the smaller risk than dropping a message - in hand. The answer goes out before the `sms` event either way, so a listener's own receipt can + in hand. The answer goes out before the handler runs either way, so a handler's own receipt can never precede the acceptance of the message it reports on. - **`server()` composes the application's `onRequest` after its own bind handling, and offers it @@ -653,23 +655,39 @@ 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. +- **An inbound message goes to `onSms`, the handler's promise is the hold on it, and the library + answers it once the handler has settled.** Maintainer's call, 2026-09-29, from the third + comprehension round: under the `sms` event the hold had six exits — a listener count, a `WeakMap` + routing a rejection back from `Session`, a `setImmediate` a receipt relied on — and every seat + named it. A handler has one: its promise settles. Goal 2 settles what the answer is. A handler that + returned has taken the message, so answering `ESME_ROK` then is not answering before the + application has it; one that threw has decided nothing, so the message is refused with the retry + status and the peer keeps it, where answering nothing left the peer to time out and a synchronous + throw used to release the hold with the message unanswered. `sendResp()` stays, for the answer that + cannot wait for the return — an id of the application's own, a refusal, or freeing the peer's + window before slow work — and is what `sendDlr()` follows, so a receipt goes out after the + response it reports on. "Answered" is one record, inside the `Sms`: `sendResp()` writes it and + the runner reads it, so a handler that answered and then failed keeps its answer. Goal 4 settles the + bound: a running handler counts against the same 1000 and 64 MiB the unanswered message did, and a + handler running past five minutes stops counting rather than holding a drain forever, which is + also why `shutdownTimeout: 0` no longer needs a fallback for the application half. Rejected: + answering `ESME_ROK` on arrival and running the handler after, which loses a message the process + dies on — goal 2's "work the peer has no reason to send again is not dropped". Rejected: leaving a + message the handler returned without answering to the peer's timeout, which is the same loss one + retry later and a footgun besides. Rejected: the handler's return value as the answer, which has + nowhere to put a receipt that must follow the response. Rejected: `onSms` as an event whose + listeners' promises are collected — `listeners()` cannot be re-typed to admit a promise (under + [The public surface](#the-public-surface)), and two listeners are two answers. Valid while the + message-carrying commands are answered per segment. + +- **A session with no `onSms` refuses every inbound message with the retry status and reports each + on `sessionError`.** Maintainer's call, 2026-09-29. Goal 2 settles the status: `ESME_RX_T_APPN` + and `ESME_RTHROTTLED` leave the message with the peer, where a permanent error would have it + dropped and silence would have the peer time out on it. Goal 4 settles that it is answered at all. + The report is per message because the state is the application's misconfiguration and every + refused message is traffic it meant to take. Rejected: `ESME_RX_P_APPN`, the permanent error, + which a peer honours by discarding. Rejected: answering `ESME_ROK`, which reports a message taken + that nothing took. - **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 @@ -750,18 +768,33 @@ 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 session's life is one state value, every transition is in `SessionLife.enter()`, and the + reconnect loop is the `down` state.** Maintainer's call, 2026-09-29, from the third comprehension + round: a four-value phase beside a `stopped` flag, seven predicates over them read by three + collaborators, and four `Session` methods whose correctness hung on call order were what every + seat least wanted to modify, and `ReconnectLoop` carried a second stopped flag of its own. Goal 8's + reshapeable internals need a lifecycle a reader can hold. `connected`, `bound`, `closing`, `down` + and `ended` are the states; a transition asked of a state that has none is ignored, which is what + makes a listener that re-enters `close()` from `disconnected` harmless, and every event is emitted + after its state and effects are in place, so no comment has to say so. The retry timer belongs to + `down` and is cancelled by leaving it, so nothing else needs a stopped flag; the one continuation + that returns into the machine after an await, the reconnect attempt, checks the state it came + back to and the link it left from. `OutgoingRequests` reads the state through a function and + copies nothing, and a bind is what moves the link to `bound`: a request on a `connected` link + waits, up to `responseTimeout`, since sending it comes back `ESME_RINVBNDSTS` (goal 1). Rejected: + a reducer returning effects for `Session` to run, which read as a machine only until the effects + ran and was scored no higher. Rejected: the machine inside `Session`, which put it past the file + cap beside the API it serves. `client()`'s first-connect retry keeps a loop of its own over the + same `Backoff`, because no session exists yet to be `down`. Valid while a session is constructed + with a socket. ## Internals and tests +- **Every default lives in `defaults.ts`, grouped by the surface that fills it.** Maintainer's + call, 2026-09-29: five files each carried a `defaults` object and three more a lone constant, so + the README's "Default" column had no single place to be checked against. Goal 5 owns the defaults; + one file is what makes them reviewable as a set. + - **Locality work comes before other work until a scoring run reads 7.0.** Maintainer's call, 2026-09-27, when #30 merged under the comprehension floor at 6, 6, 7 and 6; #46, #48 and #49 merged under it on that condition, #49 at 6, 6, 7 and 6 with Locality 5, 5, 6 and 6. Serves goal @@ -777,7 +810,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 `LinkWaiters`, `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..b6e6e94 100644 --- a/interop-tests/cloudhopper.test.ts +++ b/interop-tests/cloudhopper.test.ts @@ -41,23 +41,32 @@ async function driver(path: string, params: Record = {}): Promis const manualTexts = new Set(); const allSms: { session: Session; sms: Sms }[] = []; -function attach(session: Session): void { - session.on('sms', sms => { - allSms.push({ session, sms }); +/** Stays in the handler until the test has answered the message, so the answer is the test's own. */ +function untilAnswered(sms: Sms): Promise { + return new Promise(resolve => { + const timer = setInterval(() => { + if (!sms.answered) return; + + clearInterval(timer); + resolve(); + }, 20); + }); +} - if (manualTexts.has(sms.message)) return; +function onSms(sms: Sms): Promise { + allSms.push({ session: sms.session, sms }); - // 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()); - }); + if (manualTexts.has(sms.message)) return untilAnswered(sms); + + // 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. + return delay(SLOW_DELAY_MS); } -const { err: serverErr, server: smpp } = await server({ authenticate: () => true, idleTimeout: 40_000, port: SMPP_PORT }); +const { err: serverErr, server: smpp } = await server({ authenticate: () => true, idleTimeout: 40_000, onSms, 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 +76,13 @@ const cert = readFileSync('/shared-certs/server.crt'); const { err: tlsServerErr, server: tlsSmpp } = await server({ authenticate: () => true, idleTimeout: 40_000, + onSms, 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(); diff --git a/interop-tests/dumbclient.test.ts b/interop-tests/dumbclient.test.ts index 81bc9ff..e9d6ef0 100644 --- a/interop-tests/dumbclient.test.ts +++ b/interop-tests/dumbclient.test.ts @@ -105,6 +105,7 @@ const { err, server: smpp } = await server({ authenticate: ({ systemId }) => ({ userData: { systemId } satisfies ScenarioUserData }), idleTimeout: 40_000, log, + onSms, port: SMPP_PORT, }); @@ -126,38 +127,43 @@ 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 { 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); + + return chain; } -function fastRespond(session: Session, sms: Sms, arrivalIndex: number): void { - void sms.sendResp().then(result => { answered(session, arrivalIndex, result); }); +function fastRespond(session: Session, sms: Sms, arrivalIndex: number): Promise { + return sms.sendResp().then(result => { answered(session, arrivalIndex, result); }); } -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; }); +/** The handler stays in each message until it is answered, so the throttle bound counts what is unanswered. */ +function onSms(sms: Sms): Promise { + const { session } = sms; + const name = scenarioOf(session); - session.on('sms', sms => { - const name = scenarioOf(session); + sessionByScenario.set(name, session); - sessionByScenario.set(name, session); + const s = statsFor(name); + const arrivalIndex = s.arrived; - const s = statsFor(name); - const arrivalIndex = s.arrived; + s.arrived++; + if (s.ids.has(sms.smsId)) s.duplicateIds++; + else s.ids.add(sms.smsId); + s.peakOutstanding = Math.max(s.peakOutstanding, s.arrived - s.answered); - s.arrived++; - if (s.ids.has(sms.smsId)) s.duplicateIds++; - else s.ids.add(sms.smsId); - s.peakOutstanding = Math.max(s.peakOutstanding, s.arrived - s.answered); + if (name === 'dumb-w500' || name === 'dumb-w2000') return slowRespond(session, sms, arrivalIndex); - if (name === 'dumb-w500' || name === 'dumb-w2000') slowRespond(session, sms, arrivalIndex); - else fastRespond(session, sms, arrivalIndex); - }); + return fastRespond(session, sms, arrivalIndex); +} + +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,7 +212,7 @@ 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 +// maxHandledMessages (1000, 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 - unanswered messages at their bound, asking the peer to retry'; @@ -236,7 +242,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 maxHandledMessages (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); diff --git a/interop-tests/jasmin.test.ts b/interop-tests/jasmin.test.ts index c6c9b92..d6ef5f5 100644 --- a/interop-tests/jasmin.test.ts +++ b/interop-tests/jasmin.test.ts @@ -86,6 +86,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 = (sms.session.userData as { variant?: UpstreamVariant } | undefined)?.variant; + + if (variant) upstreamSms.push({ sms, variant }); + + await sms.sendResp(); + + if (sms.dlr) { + await delay(150); + await sms.sendDlr('DELIVERED'); + } + }, port: UPSTREAM_PORT, }); @@ -105,21 +117,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 { @@ -227,7 +224,7 @@ async function sendUdhMo(session: Session, opts: { from: string; message: string const multipart = segments.length > 1; for (const segment of segments) { - const params = submitSmParams({ from: opts.from, message: opts.message, to: opts.to }, segment, { encoding: 'ASCII', multipart }); + const params = submitSmParams({ from: opts.from, message: opts.message, to: opts.to }, segment, { encoding: 'GSM7', multipart }); const sent = await session.send({ cmdName: 'deliver_sm', params }); assert.equal(sent.err, undefined); @@ -247,7 +244,7 @@ async function sendMessagePayloadMo(session: Session, opts: { from: string; mess }, tlvs: { // The body is octets under the PDU's own data_coding wherever it is carried, and 0 is GSM. - message_payload: { tagValue: encodeMessage(opts.message, 'ASCII').buffer }, + message_payload: { tagValue: encodeMessage(opts.message, 'GSM7').buffer }, }, }); } @@ -397,15 +394,12 @@ 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' }); + const sms: Sms[] = []; + const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } }); assert.equal(err, undefined); assert.ok(session); - const sms: Sms[] = []; - - session.on('sms', s => { sms.push(s); }); - const text = `s7-long-${'p'.repeat(300)}`; await sendUdhMo(upstreamSession, { from: TO, message: text, to: FROM }); @@ -418,15 +412,12 @@ describe('S7 - Jasmin as the ESME against our server (HTTP send API, DLR callbac 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' }); + const sms: Sms[] = []; + const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } }); assert.equal(err, undefined); assert.ok(session); - const sms: Sms[] = []; - - session.on('sms', s => { sms.push(s); }); - const text = `一😀${'q'.repeat(60)}`; await sendSarMo(upstreamSession, { from: TO, message: text, to: FROM }); @@ -502,15 +493,12 @@ 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' }); + const sms: Sms[] = []; + const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } }); assert.equal(err, undefined); assert.ok(session); - const sms: Sms[] = []; - - session.on('sms', s => { sms.push(s); }); - const text = `sar-mo-${'m'.repeat(300)}`; await sendSarMo(upstreamSession, { from: TO, message: text, to: FROM }); @@ -526,15 +514,12 @@ 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' }); + const sms: Sms[] = []; + const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } }); assert.equal(err, undefined); assert.ok(session); - const sms: Sms[] = []; - - session.on('sms', s => { sms.push(s); }); - const text = `udh-mo-${'n'.repeat(300)}`; await sendUdhMo(upstreamSession, { from: TO, message: text, to: FROM }); @@ -551,15 +536,12 @@ 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' }); + const sms: Sms[] = []; + const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } }); assert.equal(err, undefined); assert.ok(session); - const sms: Sms[] = []; - - session.on('sms', s => { sms.push(s); }); - const text = 'message-payload only, sm_length 0'; const pushed = await sendMessagePayloadMo(upstreamSession, { from: TO, message: text, to: FROM }); diff --git a/interop-tests/jsmpp.test.ts b/interop-tests/jsmpp.test.ts index db96513..32e1b69 100644 --- a/interop-tests/jsmpp.test.ts +++ b/interop-tests/jsmpp.test.ts @@ -43,9 +43,26 @@ const bindPdus: Record[] = []; * before triggering the submit that will carry this exact text, so the global auto-ack never runs. */ const manualTexts = new Set(); +/** Stays in the handler until the test has answered the message, so the answer is the test's own. */ +function untilAnswered(sms: Sms): Promise { + return new Promise(resolve => { + const timer = setInterval(() => { + if (!sms.answered) return; + + clearInterval(timer); + resolve(); + }, 20); + }); +} + const { err: serverErr, server: smpp } = await server({ authenticate: () => true, idleTimeout: 40_000, + onSms: sms => { + allSms.push({ session: sms.session, sms }); + + return manualTexts.has(sms.message) ? untilAnswered(sms) : undefined; + }, port: SMPP_PORT, }); @@ -58,10 +75,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 }); }); }); @@ -135,7 +148,7 @@ describe('S2 - long messages in every spelling (targets 2, 3, 5)', () => { const sms = await waitForSms(text, 15_000); - assert.equal(sms.answeredOnArrival, true); + assert.equal(sms.answered, true); assert.equal(segments[0]?.messageId, `${sms.smsId}-1`); assert.equal(segments[1]?.messageId, `${sms.smsId}-2`); }); @@ -151,7 +164,7 @@ describe('S2 - long messages in every spelling (targets 2, 3, 5)', () => { const sms = await waitForSms(text, 15_000); - assert.equal(sms.answeredOnArrival, true); + assert.equal(sms.answered, true); assert.equal(segments[0]?.messageId, `${sms.smsId}-1`); assert.equal(segments[1]?.messageId, `${sms.smsId}-2`); }); @@ -170,7 +183,7 @@ describe('S2 - long messages in every spelling (targets 2, 3, 5)', () => { const sms = await waitForSms(text); assert.equal(sms.message, text); - assert.equal(sms.answeredOnArrival, false); + assert.equal(sms.answered, false); }); test('sar_* (target 3): one reassembled sms, each segment answered -', async () => { @@ -186,7 +199,7 @@ describe('S2 - long messages in every spelling (targets 2, 3, 5)', () => { const sms = await waitForSms(text, 15_000); - assert.equal(sms.answeredOnArrival, true); + assert.equal(sms.answered, true); assert.equal(segments[0]?.messageId, `${sms.smsId}-1`); assert.equal(segments[1]?.messageId, `${sms.smsId}-2`); // Neither ~130-char slice ever reached the application on its own. diff --git a/interop-tests/kannel.test.ts b/interop-tests/kannel.test.ts index 0d911d0..5c6a3d4 100644 --- a/interop-tests/kannel.test.ts +++ b/interop-tests/kannel.test.ts @@ -142,6 +142,21 @@ function variantFromSystemId(systemId: string): Variant | undefined { } const allSms: { sms: Sms; variant: Variant }[] = []; + +/** Stays in the handler until the test has answered the message, so the answer is the test's own. */ +function untilAnswered(sms: Sms): Promise { + return new Promise(resolve => { + const timer = setInterval(() => { + if (!sms.answered) return; + + clearInterval(timer); + resolve(); + }, 20); + }); +} + +/** Sessions whose messages are answered as they land, for a peer that sends one at a time. */ +const answeredOnArrival = new Set(); const allDlrs: { dlr: Dlr; variant: Variant }[] = []; const bindPdus: { params: Record; variant: Variant }[] = []; @@ -154,6 +169,13 @@ const { err: serverErr, server: smpp } = await server({ return variant ? { userData: { variant } } : false; }, idleTimeout: 40_000, + onSms: sms => { + const variant = (sms.session.userData as { variant?: Variant } | undefined)?.variant; + + if (variant) allSms.push({ sms, variant }); + + return answeredOnArrival.has(sms.session) ? undefined : untilAnswered(sms); + }, port: SMPP_PORT, }); @@ -171,12 +193,6 @@ 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; @@ -519,7 +535,7 @@ describe('maxp1 variant - max-pending-submits 1', () => { // 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(); }); + answeredOnArrival.add(session); const texts = Array.from({ length: 20 }, (_, i) => `burst-${String(i).padStart(2, '0')}`); diff --git a/interop-tests/php.test.ts b/interop-tests/php.test.ts index 0415ce9..217d2a6 100644 --- a/interop-tests/php.test.ts +++ b/interop-tests/php.test.ts @@ -48,6 +48,14 @@ const { err: serverErr, server: smpp } = await server({ return true; }, + // php-smpp's submit_sm() blocks synchronously reading the response on the same connection that + // sent it, so the message is answered as the handler returns, before any test observes it - + // 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 +70,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..071a4b5 100644 --- a/interop-tests/python.test.ts +++ b/interop-tests/python.test.ts @@ -58,6 +58,18 @@ async function bindReader(name: string, opts: Partial = {}): Promise { + return new Promise(resolve => { + const timer = setInterval(() => { + if (!sms.answered) return; + + clearInterval(timer); + resolve(); + }, 20); + }); +} const systemIdBySession = new Map(); const { err: serverErr, server: smpp } = await server({ @@ -68,6 +80,13 @@ const { err: serverErr, server: smpp } = await server({ return true; }, + onSms: sms => { + const systemId = systemIdBySession.get(sms.session); + + if (systemId) allSms.push({ sms, systemId }); + + return untilAnswered(sms); + }, port: SMPP_PORT, }); @@ -82,12 +101,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 () => { @@ -149,7 +162,7 @@ async function waitForAck(name: string, sequence: number, budget = 8000): Promis } async function echoBack(session: Session, sms: Sms, dataCoding: number, text: string): Promise { - const encName = dataCoding === 3 ? 'LATIN1' : dataCoding === 8 ? 'UCS2' : 'ASCII'; + const encName = dataCoding === 3 ? 'LATIN1' : dataCoding === 8 ? 'UCS2' : 'GSM7'; const buf = encodings[encName].encode(text); const sent = await session.send({ cmdName: 'deliver_sm', @@ -320,7 +333,7 @@ describe('S2 - long messages (python-smpplib, UDH)', () => { const sms = await waitForSms(name, text, 15_000); - assert.equal(sms.answeredOnArrival, true); + assert.equal(sms.answered, true); const results = sent.results as { messageId: string; status: number }[]; diff --git a/interop-tests/smppsim.test.ts b/interop-tests/smppsim.test.ts index 8a08817..c434c0f 100644 --- a/interop-tests/smppsim.test.ts +++ b/interop-tests/smppsim.test.ts @@ -72,14 +72,6 @@ function collectDlrs(session: Session): Received[] { 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; @@ -202,14 +194,14 @@ 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, session } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } }); assert.equal(err, undefined); assert.ok(session); closeAfter(t, session); const dlrs = collectDlrs(session); - const sms = collectSms(session); const { reassembled, smsIds } = await sendUntilComplete( session, @@ -588,13 +580,13 @@ describe('smppsim - C15 bind version negotiation', () => { 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, session } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } }); assert.equal(err, undefined); assert.ok(session); closeAfter(t, session); - const sms = collectSms(session); await session.sendSms({ encoding: 'LATIN1', from: FROM, message: 'å ä ö', to: TO }); @@ -605,13 +597,13 @@ 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, session } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } }); assert.equal(err, undefined); assert.ok(session); closeAfter(t, session); - const sms = collectSms(session); await session.sendSms({ encoding: 'UCS2', from: FROM, message: 'ucs2 round trip', to: TO }); @@ -622,13 +614,13 @@ 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, session } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } }); assert.equal(err, undefined); assert.ok(session); closeAfter(t, session); - const sms = collectSms(session); await session.sendSms({ flash: true, from: FROM, message: 'flash test', to: TO }); @@ -640,13 +632,13 @@ 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, session } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } }); assert.equal(err, undefined); assert.ok(session); closeAfter(t, session); - const sms = collectSms(session); const body = 'message class test'; const sent = await session.send({ @@ -668,13 +660,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, session } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } }); assert.equal(err, undefined); assert.ok(session); closeAfter(t, session); - 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 diff --git a/interop-tests/smscsim.test.ts b/interop-tests/smscsim.test.ts index ba23042..0c1785c 100644 --- a/interop-tests/smscsim.test.ts +++ b/interop-tests/smscsim.test.ts @@ -163,9 +163,11 @@ describe('smscsim - multipart segments', () => { describe('smscsim - MO injection through the web UI', () => { test('a message posted to the web page arrives as an sms event', async t => { + const incoming: Sms[] = []; const { err, session } = await client({ bindType: 'transceiver', host: PEER_HOST, + onSms: sms => { incoming.push(sms); }, port: PEER_PORT, username: 'mo-inject', }); @@ -174,10 +176,6 @@ describe('smscsim - MO injection through the web UI', () => { assert.ok(session); closeAfter(t, session); - const incoming: Sms[] = []; - - session.on('sms', sms => { incoming.push(sms); }); - const response = await fetch(`http://${PEER_HOST}:${String(PEER_WEB_PORT)}/`, { body: new URLSearchParams({ message: 'hello from the web UI', diff --git a/src/backoff.ts b/src/backoff.ts new file mode 100644 index 0000000..e60e634 --- /dev/null +++ b/src/backoff.ts @@ -0,0 +1,44 @@ +import { defaults } from './defaults.ts'; + +export type BackoffOptions = { + maxDelay?: number | undefined; + minDelay?: number | undefined; + now?: (() => number) | undefined; +}; + +/** The wait before each connect attempt: doubling from minDelay to maxDelay, and over again once a link has lasted. */ +export class Backoff { + private readonly maxDelay: number; + private readonly minDelay: number; + private readonly now: () => number; + private delay: number; + private upAt: number | undefined; + + constructor(options: BackoffOptions = {}) { + this.maxDelay = options.maxDelay ?? defaults.reconnect.maxDelay; + this.minDelay = options.minDelay ?? defaults.reconnect.minDelay; + this.now = options.now ?? Date.now; + this.delay = this.minDelay; + } + + /** The wait before the next attempt. */ + next(): number { + // 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) { + this.delay = this.minDelay; + } + + this.upAt = undefined; + + const delay = this.delay; + + this.delay = Math.min(delay * 2, this.maxDelay); + + return delay; + } + + /** A link is up: whether it lasts decides where the next wait starts. */ + linkUp(): void { + this.upAt = this.now(); + } +} diff --git a/src/client.ts b/src/client.ts index 6b7299c..d757bfe 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,17 +1,17 @@ import type { ConnectionOptions } from 'node:tls'; import type { Result, VoidResult } from './result.ts'; -import type { BindType, ReconnectOptions } from './session-options.ts'; +import type { BindType, OnSms, ReconnectOptions } from './session-options.ts'; import type { SmppLog } from './log.ts'; import type { SmsIdFormat } from './sms-id.ts'; import type { Socket } from 'node:net'; export type { BindType }; -import { ReconnectLoop } from './reconnect-loop.ts'; +import { Backoff } from './backoff.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 { guardedLog } from './log.ts'; /** `fromStart` puts the very first connect and bind through the same backoff loop as a drop. */ @@ -29,6 +29,7 @@ export type ClientOptions = { interfaceVersion?: number; log?: SmppLog; maxOutstanding?: number; + onSms?: OnSms; password?: string; port?: number; reconnect?: ReconnectTuning | false; @@ -41,19 +42,6 @@ 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; - function armConnectTimeout( sock: Socket, connectTimeout: number | false, @@ -77,9 +65,9 @@ function armConnectTimeout( } function openSocket(options: ClientOptions): Promise> { - const connectTimeout = options.connectTimeout ?? defaults.connectTimeout; - const host = options.host ?? defaults.host; - const port = options.port ?? defaults.port; + const connectTimeout = options.connectTimeout ?? defaults.client.connectTimeout; + const host = options.host ?? defaults.client.host; + const port = options.port ?? defaults.client.port; const secure = options.tls !== undefined && options.tls !== false; const tlsOptions = typeof options.tls === 'object' ? options.tls : undefined; @@ -134,9 +122,9 @@ async function connectSocket(options: ClientOptions, log: SmppLog): Promise { - const bindType = options.bindType ?? defaults.bindType; - const systemId = options.username ?? defaults.username; + const bindType = options.bindType ?? defaults.client.bindType; + const systemId = options.username ?? defaults.client.username; const sent = await session.send( { cmdName: `bind_${bindType}`, params: bindParams(options, systemId) }, options.signal ? { signal: options.signal } : {}, @@ -197,13 +185,14 @@ function reconnectFor(options: ClientOptions, log: SmppLog): ReconnectOptions | } function createSession(options: ClientOptions, log: SmppLog, sock: Socket): Session { - const enquireLinkInterval = options.enquireLinkInterval ?? defaults.enquireLinkInterval; + const enquireLinkInterval = options.enquireLinkInterval ?? defaults.client.enquireLinkInterval; return new Session({ enquireLinkInterval, - idleTimeout: options.idleTimeout ?? enquireLinkInterval * defaults.idleTimeoutFactor, + idleTimeout: options.idleTimeout ?? enquireLinkInterval * defaults.client.idleTimeoutFactor, log, maxOutstanding: options.maxOutstanding, + onSms: options.onSms, reconnect: reconnectFor(options, log), responseTimeout: options.responseTimeout, shutdownTimeout: options.shutdownTimeout, @@ -212,6 +201,7 @@ function createSession(options: ClientOptions, log: SmppLog, sock: Socket): Sess }); } +/** One connect and bind. A failure takes the session down, since the caller never sees it. */ async function connectAndBind( options: ClientOptions, log: SmppLog, @@ -220,22 +210,8 @@ async function connectAndBind( 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> { + const session = createSession(options, log, opened.sock); const signal = options.signal; - - if (signal?.aborted === true) { - void session.close({ signal }); - - return { err: new Error('Aborted before binding') }; - } - const onAbort = (): void => { void session.close({ signal }); }; // Registered before the bind: an abort landing while it is in flight has to close the session. @@ -243,45 +219,62 @@ async function bindOn( 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 }); + if (!bound.err) return { session }; - return { err: bound.err }; - } + signal?.removeEventListener('abort', onAbort); + void session.close({ signal }); - return { session }; + return { err: bound.err }; } function retriesFromStart(reconnect: ClientOptions['reconnect']): reconnect is ReconnectTuning { 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; +type Settle = (result: Result<{ session: Session }>) => void; - return { - bind: async (sock: Socket): Promise> => { - const bound = await bindOn(createSession(options, log, sock), options); +/** Whether the caller is still waiting: a session bound after it stopped is closed rather than leaked. */ +type Waiting = () => boolean; - if (bound.err) lastErr = bound.err; +/** Tries again after each failure, on the backoff a drop takes, until one attempt binds. */ +function retryUntilBound(options: ClientOptions, log: SmppLog, backoff: Backoff, waiting: Waiting, settle: Settle): () => void { + let timer: NodeJS.Timeout | undefined; - return bound; - }, - connect: async (): Promise> => { - const opened = await connectSocket(options, log); + async function attempt(): Promise { + const bound = await connectAndBind(options, log); - if (opened.err) lastErr = opened.err; + if (!waiting()) { + await bound.session?.close({ signal: AbortSignal.abort() }); - return opened; - }, - lastErr: (): Error => lastErr, - }; + return; + } + + if (!bound.err) { + settle({ session: bound.session }); + + return; + } + + settle({ err: bound.err }); + schedule(); + } + + function schedule(): void { + const delay = backoff.next(); + + // Awaited with no other handle, so an unref()'d wait would exit the process unbound. + timer = setTimeout(() => { + log.info('reconnect - retrying', { delay }); + void attempt(); + }, delay); + } + + schedule(); + + return () => { clearTimeout(timer); }; } -/** Retries the first connect and bind, on the backoff a drop takes, until one of them binds. */ +/** Retries the first connect and bind until one of them binds; only the caller's signal ends the wait. */ function keepTrying( options: ClientOptions, log: SmppLog, @@ -289,42 +282,29 @@ function keepTrying( failed: Error, ): Promise> { return new Promise(resolve => { - const attempts = initialAttempts(options, log, failed); const signal = options.signal; + let lastErr = failed; 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, - }); + const stop = retryUntilBound(options, log, new Backoff(tuning), () => !settled, result => { + // A failed attempt only records what to blame an abort on; a bound one is the answer. + if (result.err) { + lastErr = result.err; - function settle(result: Result<{ session: Session }>): void { - if (settled) return; + return; + } settled = true; - loop.stop(); signal?.removeEventListener('abort', onAbort); resolve(result); - } + }); function onAbort(): void { - settle({ err: new Error('Aborted while connecting', { cause: attempts.lastErr() }) }); + settled = true; + stop(); + resolve({ err: new Error('Aborted while connecting', { cause: lastErr }) }); } signal?.addEventListener('abort', onAbort, { once: true }); - loop.schedule(); }); } diff --git a/src/defaults.ts b/src/defaults.ts new file mode 100644 index 0000000..8c1eb72 --- /dev/null +++ b/src/defaults.ts @@ -0,0 +1,41 @@ +import { defaultInterfaceVersion } from './defs/constants.ts'; + +/** Every default this library runs on. README documents each beside the option it fills. */ +export const defaults = { + client: { + 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, + password: 'pass', + port: 2775, + username: 'user', + }, + reconnect: { + maxDelay: 30_000, + minDelay: 1000, + }, + server: { + idleTimeout: 40_000, + port: 2775, + }, + session: { + /** 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. */ + handledMessageTimeout: 300_000, + interfaceVersion: defaultInterfaceVersion, + maxDlrMerges: 1000, + maxHandledMessages: 1000, + maxHandledOctets: 64 * 1024 * 1024, + maxOutstanding: 10, + maxReassembly: 1000, + maxReassemblyOctets: 64 * 1024 * 1024, + reassemblyTimeout: 300_000, + responseTimeout: 30_000, + shutdownTimeout: 5000, + systemId: '', + }, +} as const; diff --git a/src/defs/encodings.ts b/src/defs/encodings.ts index c454b02..98ebfe4 100644 --- a/src/defs/encodings.ts +++ b/src/defs/encodings.ts @@ -1,4 +1,4 @@ -export type EncodingName = 'ASCII' | 'LATIN1' | 'UCS2'; +export type EncodingName = 'GSM7' | 'LATIN1' | 'UCS2'; export type Encoding = { decode: (buffer: Uint8Array) => string; @@ -42,7 +42,8 @@ for (const [extended, base] of gsmExtendedPairs) { gsmExtChars.set(base, extended); } -const ascii: Encoding = { +/** GSM 03.38 7-bit, one character per octet: the SMSC packs the septets, not the ESME. */ +const gsm7: Encoding = { decode(buffer) { let result = ''; @@ -103,7 +104,7 @@ const ucs2: Encoding = { }; export const encodings: Record = { - ASCII: ascii, + GSM7: gsm7, LATIN1: latin1, UCS2: ucs2, }; @@ -115,7 +116,7 @@ export function isEncodingName(value: unknown): value is EncodingName { } export function detect(value: string): EncodingName { - if (encodings.ASCII.match(value)) return 'ASCII'; + if (encodings.GSM7.match(value)) return 'GSM7'; if (encodings.LATIN1.match(value)) return 'LATIN1'; return 'UCS2'; @@ -163,21 +164,21 @@ function messageClassEncoding(dataCoding: number): EncodingName | undefined { if (messageClassOf(dataCoding) === undefined) return undefined; if ((dataCoding & 0xF0) === 0xF0) { - return (dataCoding & 0x04) === 0x04 ? 'LATIN1' : 'ASCII'; + return (dataCoding & 0x04) === 0x04 ? 'LATIN1' : 'GSM7'; } const alphabet = (dataCoding >> 2) & 0x03; if (alphabet === 0x01) return 'LATIN1'; - return alphabet === 0x02 ? 'UCS2' : 'ASCII'; + return alphabet === 0x02 ? 'UCS2' : 'GSM7'; } /** * SMPP data_coding is a flat table for 0x00-0x0E, and the message class ranges are how a flash UCS2 * message arrives as 0x18. The 8-bit binary codings resolve to LATIN1, the one codec here that maps * every octet to a code point and back unchanged, so a binary payload survives; alphabets with no - * codec fall back to ASCII. + * codec fall back to GSM7. */ export function encodingByDataCoding(dataCoding: number): EncodingName { const messageClass = messageClassEncoding(dataCoding); @@ -186,7 +187,7 @@ export function encodingByDataCoding(dataCoding: number): EncodingName { if (dataCoding === 0x08) return 'UCS2'; // 0x02 and 0x04 are 8-bit binary, 0x03 is Latin-1. - return dataCoding >= 0x02 && dataCoding <= 0x04 ? 'LATIN1' : 'ASCII'; + return dataCoding >= 0x02 && dataCoding <= 0x04 ? 'LATIN1' : 'GSM7'; } /** @@ -194,7 +195,7 @@ export function encodingByDataCoding(dataCoding: number): EncodingName { * takes 0x00, the SMSC default alphabet, rather than SMPP 3.4 5.2.19's 0x01, which is IA5. */ export const dataCodingByEncoding: Readonly> = { - ASCII: 0x00, + GSM7: 0x00, LATIN1: 0x03, UCS2: 0x08, }; diff --git a/src/dlr-merger.ts b/src/dlr-merger.ts index 0c20cae..12a4349 100644 --- a/src/dlr-merger.ts +++ b/src/dlr-merger.ts @@ -126,7 +126,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)); @@ -142,7 +142,7 @@ export class DlrMerger { /** 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.spend(base); this.log.info('dlrMerger - incomplete receipts expired', { base, expected: group.expected.size }); } } @@ -151,7 +151,7 @@ export class DlrMerger { 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; @@ -162,7 +162,8 @@ export class DlrMerger { this.groups.set(base, { expected, parts: new Map() }); } - private close(base: string): void { + /** The base has been merged, or given up on: its receipts are never merged again. */ + private spend(base: string): void { this.groups.delete(base); this.spent.delete(base); @@ -178,7 +179,7 @@ export class DlrMerger { const [base] = oldest; - this.close(base); + this.spend(base); this.log.warn('dlrMerger - buffer full, dropping the oldest message', { base, max: this.max }); } } diff --git a/src/handled-messages.ts b/src/handled-messages.ts new file mode 100644 index 0000000..69b4354 --- /dev/null +++ b/src/handled-messages.ts @@ -0,0 +1,158 @@ +import type { ErrorName } from './defs/errors.ts'; +import type { OnSms } from './session-options.ts'; +import type { Sms, SmsHandlers, SmsInput } 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 { errorFrom } from './error-from.ts'; +import { retainedOctets } from './retained-pdu.ts'; + +export type HandledMessagesOptions = { + log: SmppLog; + max: number; + maxOctets: number; + /** Injected so expiry can be exercised without a wall clock. */ + now?: (() => number) | undefined; + onSms: OnSms | undefined; + /** Where a handler's failure is reported. */ + report: (err: Error) => void; + timeout: number; +}; + +/** + * The messages whose `onSms` is running. Each counts toward the bound and holds a shutdown until + * the handler settles, its deadline passes, or the link goes; and each is answered once the handler + * has settled, where the handler did not answer it itself. + */ +export class HandledMessages { + private readonly idleWaiters = new IdleWaiters(); + private readonly log: SmppLog; + private readonly max: number; + private readonly maxOctets: number; + private readonly onSms: OnSms | undefined; + private readonly report: (err: Error) => void; + private readonly running: ExpiringGroups; + private atBound = false; + private keys = 0; + + constructor(options: HandledMessagesOptions) { + this.log = options.log; + this.max = options.max; + this.maxOctets = options.maxOctets; + this.onSms = options.onSms; + this.report = options.report; + this.running = new ExpiringGroups({ + max: options.max, + now: options.now, + onSweep: () => { this.sweep(); }, + timeout: options.timeout, + }); + } + + get octets(): number { + return this.running.weight; + } + + get size(): number { + return this.running.size; + } + + /** Whether a message arriving now is refused: at the bound, and until the store is half empty again. */ + refuses(): boolean { + this.sweep(); + + if (this.running.full || this.running.weight >= this.maxOctets) { + if (!this.atBound) { + this.atBound = true; + this.log.warn('handledMessages - messages at their bound, refusing new ones until handlers return', { + messages: this.size, + octets: this.octets, + }); + } + + return true; + } + + // Half, so a peer keeping its window full does not flip this on every answer. + if (this.atBound && this.size <= this.max / 2 && this.octets <= this.maxOctets / 2) { + this.atBound = false; + this.log.info('handledMessages - messages down to half their bound, accepting again', { messages: this.size }); + } + + return false; + } + + /** Hands the message to the handler. `retryStatus` answers it where the handler fails first. */ + offer(input: SmsInput, handlers: SmsHandlers, retryStatus: ErrorName): Sms { + const key = String(this.keys++); + const sms = createSms(input, handlers); + + this.sweep(); + this.running.set(key, sms); + this.running.weigh(key, input.pduObjs.reduce((sum, pduObj) => sum + retainedOctets(pduObj), 0)); + void this.run(key, sms, retryStatus); + + return sms; + } + + /** Drops every message: their segments went with the link, so no answer of ours correlates now. */ + clear(): void { + this.running.takeAll(); + this.idleWaiters.settle(); + } + + /** Resolves 0 once every handler has settled, or with how many have not. */ + idle(timeout: number, signal: AbortSignal | undefined): Promise { + return this.idleWaiters.wait(() => this.running.size, timeout, signal); + } + + /** Drops every message past its deadline. Runs before each offer and on its own timer. */ + sweep(): void { + const expired = this.running.takeExpired(); + + if (expired.length === 0) return; + + this.log.warn('handledMessages - handlers still running past their deadline', { messages: expired.length }); + this.settle(); + } + + private async run(key: string, sms: Sms, retryStatus: ErrorName): Promise { + const failure = await this.handle(sms); + + if (failure) { + this.log.error('handledMessages - a handler failed', { message: failure.message }); + this.report(failure); + } + + // A handler that returned has taken the message; one that failed first has decided nothing. + if (!sms.answered) { + const answered = await sms.sendResp(failure ? { status: retryStatus } : {}); + + if (answered.err) { + this.log.warn('handledMessages - could not answer a message its handler settled', { message: answered.err.message }); + } + } + + if (this.running.get(key) !== sms) return; + + this.running.delete(key); + this.settle(); + } + + private async handle(sms: Sms): Promise { + if (!this.onSms) return new Error('No onSms handler takes inbound messages'); + + try { + await this.onSms(sms); + + return undefined; + } catch (thrown: unknown) { + return errorFrom(thrown); + } + } + + private settle(): void { + if (this.running.size === 0) this.idleWaiters.settle(); + } +} 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; - -/** - * 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> { - 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; - 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(); - 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 { - 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..fd59feb 100644 --- a/src/incoming-requests.ts +++ b/src/incoming-requests.ts @@ -1,19 +1,19 @@ import type { Concat } from './concat.ts'; import type { DlrMerger } from './dlr-merger.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 { OnRequest, OnSms } from './session-options.ts'; import type { PduObject } from './pdu.ts'; -import type { VoidResult } from './result.ts'; import type { Session } from './session.ts'; +import type { SmsHandlers } from './sms.ts'; import type { SmppLog } from './log.ts'; import type { SmsIdFormat } from './sms-id.ts'; -import { HeldMessages } from './held-messages.ts'; +import type { VoidResult } from './result.ts'; +import { HandledMessages } from './handled-messages.ts'; import { Reassembler } from './reassembly.ts'; -import { bindCommands, defaults, standsInFor } from './session-options.ts'; +import { bindCommands, standsInFor } from './session-options.ts'; import { concatOf } from './concat.ts'; +import { defaults } from './defaults.ts'; import { detach } from './retained-pdu.ts'; import { dlrFromPdu } from './dlr.ts'; import { respIdParams, segmentId } from './sms-id.ts'; @@ -44,14 +44,19 @@ const lostReasons: Record = { }; export type IncomingRequestsOptions = { + /** Writes one response, on the link the request arrived on. */ + answer: SmsHandlers['answer']; dlrMerger: DlrMerger; - link: LinkLife; log: SmppLog; maxOctets?: number | undefined; maxReassembly?: number | undefined; + now?: (() => number) | undefined; onRequest?: OnRequest | undefined; + onSms?: OnSms | undefined; reassemblyTimeout?: number | undefined; - sendPastDrain: HeldMessagesOptions['sendPastDrain']; + /** A request that goes out during a drain too: what a receipt for a message being handled takes. */ + send: SmsHandlers['send']; + /** Handed to the hook and to every Sms, and asked which end it is and what its bind carries. */ session: Session; smsIdFormat?: SmsIdFormat | undefined; systemId?: string | undefined; @@ -60,51 +65,51 @@ export type IncomingRequestsOptions = { /** 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 handled: HandledMessages; + private readonly handlers: SmsHandlers; private readonly log: SmppLog; private readonly onRequest: OnRequest | undefined; private readonly reassembler: Reassembler; private readonly session: Session; 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, + this.handled = new HandledMessages({ log: options.log, - max: defaults.maxHeldMessages, - maxOctets: defaults.maxHeldOctets, - sendPastDrain: options.sendPastDrain, - session: options.session, - timeout: defaults.heldMessageTimeout, + max: defaults.session.maxHandledMessages, + maxOctets: defaults.session.maxHandledOctets, + now: options.now, + onSms: options.onSms, + report: err => { options.session.emit('sessionError', err); }, + timeout: defaults.session.handledMessageTimeout, }); - this.link = options.link; + this.handlers = { answer: options.answer, send: options.send }; this.log = options.log; this.onRequest = options.onRequest; this.reassembler = new Reassembler({ log: options.log, - max: options.maxReassembly ?? defaults.maxReassembly, + max: options.maxReassembly ?? defaults.session.maxReassembly, maxOctets: options.maxOctets, + now: options.now, onLost: lost => { this.reportLost(lost); }, - timeout: options.reassemblyTimeout ?? defaults.reassemblyTimeout, + timeout: options.reassemblyTimeout ?? defaults.session.reassemblyTimeout, }); this.session = options.session; this.smsIdFormat = options.smsIdFormat ?? {}; - this.systemId = options.systemId ?? defaults.systemId; + this.systemId = options.systemId ?? defaults.session.systemId; } async handle(pduObj: PduObject): Promise { - const generation = this.link.generation(); + const link = this.session.sock; 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; // The link it arrived on went while the hook ran, so nothing we answer now correlates. - if (this.link.generation() !== generation) { + if (link.destroyed) { this.log.info('session - dropping a request whose link went', { cmdName: pduObj.cmdName }); return; @@ -115,7 +120,7 @@ export class IncomingRequests { bindType: this.session.boundAs ?? '', cmdName: pduObj.cmdName, }); - await this.session.sendReturn(pduObj, 'ESME_RINVBNDSTS'); + this.handlers.answer(pduObj, 'ESME_RINVBNDSTS', {}); return; } @@ -128,52 +133,47 @@ export class IncomingRequests { 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' - ? this.onMessage(pduObj) - : this.onDelivery(pduObj)); + if (this.carriedAs(pduObj) === 'submit_sm') this.onMessage(pduObj); + else this.onDelivery(pduObj); + break; case 'enquire_link': - await this.session.sendReturn(pduObj); + this.handlers.answer(pduObj, 'ESME_ROK', {}); break; case 'submit_sm': - await this.onMessage(pduObj); + this.onMessage(pduObj); break; case 'unbind': - await this.session.sendReturn(pduObj); + this.handlers.answer(pduObj, 'ESME_ROK', {}); // A peer that has said it is finished will not answer what we still have outstanding. await this.session.close({ signal: AbortSignal.abort() }); break; default: - await this.unhandled(pduObj); + this.unhandled(pduObj); } } - /** Drops the segments of every message that never became whole, and of every one still held. */ + /** Drops the segments of every message that never became whole, and the count of every one being handled. */ clear(): void { - this.refusing = false; - this.held.clear(); + this.handled.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. */ + /** Waits out the handlers still running, and says how many never settled. */ async drain(timeout: number, signal: AbortSignal | undefined): Promise { - const unanswered = await this.held.idle(timeout, signal); + const unsettled = await this.handled.idle(timeout, signal); - if (unanswered === 0) return {}; + if (unsettled === 0) return {}; - this.log.warn('session - shutting down with messages unanswered', { timeout, unanswered }); + this.log.warn('session - shutting down with messages still being handled', { timeout, unsettled }); - return { err: new Error(`Shut down with ${String(unanswered)} message(s) unanswered`) }; + return { err: new Error(`Shut down with ${String(unsettled)} message(s) still being handled`) }; } - private async unhandled(pduObj: PduObject): Promise { + private unhandled(pduObj: PduObject): 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 }); + this.handlers.answer(pduObj, 'ESME_RALYBND', { system_id: this.systemId }); return; } @@ -185,7 +185,7 @@ export class IncomingRequests { } this.log.info('session - no handler for command', { cmdName: pduObj.cmdName }); - await this.session.sendReturn(pduObj, 'ESME_RINVCMDID'); + this.handlers.answer(pduObj, 'ESME_RINVCMDID', {}); } private carriedAs(pduObj: PduObject): string { @@ -193,11 +193,11 @@ export class IncomingRequests { } /** SMPP carries a mobile-originated message and a delivery receipt on the same command. */ - private async onDelivery(pduObj: PduObject): Promise { + private onDelivery(pduObj: PduObject): void { const dlr = dlrFromPdu(pduObj, this.smsIdFormat); if (!dlr) { - await this.onMessage(pduObj); + this.onMessage(pduObj); return; } @@ -208,52 +208,30 @@ export class IncomingRequests { if (merged) this.session.emit('messageDlr', merged); - await this.session.sendReturn(pduObj); + this.handlers.answer(pduObj, 'ESME_ROK', {}); } - private async refusedAtBound(pduObj: PduObject): Promise { - if (this.held.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.verbose('session - unanswered messages at their bound, asking the peer to retry', { + /** + * A concatenated message is answered segment by segment as it arrives: a peer that dispatches + * one request at a time never sends the second segment until the first has been answered. + */ + private onMessage(pduObj: PduObject): void { + const carriedAs = this.carriedAs(pduObj); + + if (this.handled.refuses()) { + this.log.verbose('session - messages at their bound, asking the peer to retry', { cmdName: pduObj.cmdName, seqNr: pduObj.seqNr, }); - await this.session.sendReturn(pduObj, throttledStatus(this.carriedAs(pduObj))); - - return true; - } + this.handlers.answer(pduObj, throttledStatus(carriedAs), {}); - // 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.refusing = false; - this.log.info('session - unanswered messages down to half their bound, accepting again', { messages: this.held.size }); + return; } - return false; - } - - /** - * A concatenated message is answered segment by segment as it arrives: a peer that dispatches - * one request at a time never sends the second segment until the first has been answered. - */ - private async onMessage(pduObj: PduObject): Promise { - if (await this.refusedAtBound(pduObj)) return; - const concat = concatOf(pduObj); if (!concat) { - this.held.offer([detach(pduObj)]); + this.offer([detach(pduObj)], undefined, carriedAs); return; } @@ -261,21 +239,26 @@ export class IncomingRequests { const collected = this.reassembler.collect(pduObj, concat); if (!collected.kept) { - await this.session.sendReturn( - pduObj, - refusedSegmentStatus(this.carriedAs(pduObj), collected.refusal, concat.spelling), - ); + this.handlers.answer(pduObj, refusedSegmentStatus(carriedAs, collected.refusal, concat.spelling), {}); return; } - await this.session.sendReturn( + this.handlers.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) this.offer(collected.whole, collected.smsId, carriedAs); + } + + private offer(pduObjs: PduObject[], answeredAs: string | undefined, carriedAs: string): void { + this.handled.offer( + { answeredAs, link: this.session.sock, pduObjs, session: this.session }, + this.handlers, + throttledStatus(carriedAs), + ); } private reportLost(lost: LostGroup): void { diff --git a/src/index.ts b/src/index.ts index 71fa973..b3e8a20 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,7 +52,9 @@ export type { } from './server.ts'; export type { CloseOptions, + LinkState, MessageDlr, + OnSms, ReconnectOptions, SendOptions, SendSmsOptions, 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(); - 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 { - 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 { - 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 { - this.log.verbose('linkLife - holding a request until a link is back', { timeout: left }); - - return new Promise(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-waiters.ts b/src/link-waiters.ts new file mode 100644 index 0000000..02d280f --- /dev/null +++ b/src/link-waiters.ts @@ -0,0 +1,71 @@ +import type { SmppLog } from './log.ts'; +import type { VoidResult } from './result.ts'; + +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'); +} + +/** The requests with no bound link to go out on, waiting for the next one. */ +export class LinkWaiters { + private readonly log: SmppLog; + private readonly now: () => number; + private readonly waiting = new Set(); + + constructor(log: SmppLog, now: () => number = Date.now) { + this.log = log; + this.now = now; + } + + get size(): number { + return this.waiting.size; + } + + /** Resolves when released, or with the reason it stopped waiting. A deadline of 0 waits for as long as it takes. */ + wait(deadline: number, signal: AbortSignal | undefined): Promise { + 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() }); + + this.log.verbose('linkWaiters - holding a request until a link is back', { timeout: left }); + + return new Promise(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('linkWaiters - 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); + }); + } + + /** Everything waiting goes, with the same answer. */ + release(result: VoidResult): void { + for (const settle of [...this.waiting]) { + settle(result); + } + } +} diff --git a/src/message.ts b/src/message.ts index 0665019..f6913c3 100644 --- a/src/message.ts +++ b/src/message.ts @@ -11,7 +11,7 @@ const singleMessageBits = 1120; export const maxSegments = 255; /** Budget per segment: the 134 octets left of 140 after the UDH, or the 153 septets GSM packs into them. */ -const segmentUnits: Record = { ASCII: 153, LATIN1: 134, UCS2: 134 }; +const segmentUnits: Record = { GSM7: 153, LATIN1: 134, UCS2: 134 }; export type SplitOptions = { encoding?: EncodingName; @@ -70,7 +70,7 @@ export function bitCount(message: string, encoding?: EncodingName): number { const encoded = encodings[resolved].encode(message); // GSM characters are packed seven bits to a septet; everything else stays octet-aligned. - return resolved === 'ASCII' ? encoded.length * 7 : encoded.length * 8; + return resolved === 'GSM7' ? encoded.length * 7 : encoded.length * 8; } /** diff --git a/src/outgoing-requests.ts b/src/outgoing-requests.ts index a0adf24..faaa616 100644 --- a/src/outgoing-requests.ts +++ b/src/outgoing-requests.ts @@ -1,9 +1,10 @@ -import type { LinkLife } from './link-life.ts'; +import type { LinkState } from './session-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 { LinkWaiters } from './link-waiters.ts'; import { PendingRequests } from './pending-requests.ts'; import { SendWindow } from './send-window.ts'; import { UnansweredError } from './unanswered-error.ts'; @@ -11,22 +12,29 @@ import { bindCommands } from './session-options.ts'; import { objToPdu } from './pdu.ts'; export type OutgoingRequestsOptions = { - link: LinkLife; log: SmppLog; maxOutstanding: number; responseTimeout: number; + /** Read, never copied: the session's life is the one owner of it. */ + state: () => LinkState; 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 }; +type Response = Result<{ pduObj: PduObject }>; + +/** `written` is false where nothing reached the socket, so another link may carry the request. */ +type Attempt = { result: Response; written: boolean }; function abortedBeforeSend(): Error { return new Error('Aborted before the request was sent'); } +function over(): Error { + return new Error('Session is closed'); +} + /** A response carries the request's sequence number, which only sendReturn() has. */ -function misuse(input: PduObjectInput): Error | undefined { +export function misuse(input: PduObjectInput): Error | undefined { return input.cmdName.endsWith('_resp') ? new Error(`Use sendReturn() for responses, not send(): ${input.cmdName}`) : undefined; @@ -34,24 +42,31 @@ function misuse(input: PduObjectInput): Error | undefined { /** Everything this end asks of the peer: which link carries it, how many at once, and the answer. */ export class OutgoingRequests { - private readonly link: LinkLife; private readonly log: SmppLog; private readonly pending: PendingRequests; private readonly responseTimeout: number; + private readonly state: () => LinkState; private readonly transport: PduTransport; + private readonly waiters: LinkWaiters; 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; + this.state = options.state; this.transport = options.transport; + this.waiters = new LinkWaiters(options.log); this.window = new SendWindow({ limit: options.maxOutstanding, log: options.log }); } - canCarry(): boolean { - return this.link.isUp() && !this.transport.sock.destroyed; + /** The link is bound: everything held for one goes out on it. */ + linkUp(): void { + if (this.waiters.size > 0) { + this.log.verbose('outgoingRequests - sending what was held for a link', { held: this.waiters.size }); + } + + this.waiters.release({}); } /** The link is gone, and every answer still owed on it with it. */ @@ -59,6 +74,12 @@ export class OutgoingRequests { this.pending.settleAll(new Error('Session closed before a response arrived')); } + /** The session is over: nothing held will ever go out. */ + end(): void { + this.linkLost(); + this.waiters.release({ err: over() }); + } + /** Hands a response to the request waiting for it. False means nothing was. */ deliver(pduObj: PduObject): boolean { return this.pending.deliver(pduObj); @@ -69,58 +90,48 @@ export class OutgoingRequests { this.pending.settle(seqNr, { err }); } - request(input: PduObjectInput, options: SendOptions): Promise> { - // 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') }); - } + /** + * Waits for a bound link and a window slot, then sends. What never reached the socket waits for + * the next link; what did is answered or reported unanswered, never sent again. + */ + async request(input: PduObjectInput, options: SendOptions): Promise { + // Before the link and the window, or an aborted call waits for what it will never use. + const refused = misuse(input) ?? (options.signal?.aborted === true ? abortedBeforeSend() : undefined); - return this.requestPastDrain(input, options); - } + if (refused) return { err: refused }; - /** request() without the drain's refusal, which a receipt for a held message has to take. */ - async requestPastDrain( - input: PduObjectInput, - options: SendOptions, - ): Promise> { - const refused = this.refuse(input, options); + // A bind is what makes a link usable, so it cannot wait for one. + if (bindCommands.includes(input.cmdName)) return this.requestOnLink(input, options); - if (refused) return { err: refused }; + const deadline = this.responseTimeout > 0 ? Date.now() + this.responseTimeout : 0; - // 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(); + for (;;) { + const sent = await this.sendOnce(input, options, deadline); - return shut ? { err: shut } : this.requestOnCurrentLink(input, options); + if (sent) return sent; } + } - const waitForLink = this.link.hold(options.signal); - - for (;;) { - const held = await waitForLink(); + /** One pass at the link and the window. Undefined means nothing reached the socket and a link is on its way. */ + private async sendOnce(input: PduObjectInput, options: SendOptions, deadline: number): Promise { + const link = await this.waitForLink(deadline, options.signal); - if (held.err) return { err: held.err }; + if (link.err) return { err: link.err }; - const slot = await this.window.acquire(options.signal); + const slot = await this.window.acquire(options.signal); - if (slot.err) return { err: slot.err }; + if (slot.err) return { err: slot.err }; - const attempt = await this.attempt(input, options).finally(() => { this.window.release(); }); + const attempt = await this.attempt(input, options).finally(() => { this.window.release(); }); - if (!this.retriesOnNextLink(attempt)) return attempt.result; - } + // Only a link on its way back is worth waiting for; on any other state the failure stands. + return attempt.written || this.state() !== 'down' ? attempt.result : undefined; } - /** Straight onto the current link, for what has to go out either way. */ - async requestOnCurrentLink( - input: PduObjectInput, - options: SendOptions = {}, - ): Promise> { + /** Straight onto the attached socket, outside the window: a bind or an unbind. */ + async requestOnLink(input: PduObjectInput, options: SendOptions = {}): Promise { + if (this.state() === 'ended') return { err: over() }; + return (await this.attempt(input, options)).result; } @@ -135,28 +146,29 @@ 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); + private waitForLink(deadline: number, signal: AbortSignal | undefined): Promise { + switch (this.state()) { + case 'bound': + case 'closing': + return Promise.resolve({}); + case 'ended': + return Promise.resolve({ err: over() }); + case 'connected': + case 'down': + return this.waiters.wait(deadline, signal); + } } private async attempt(input: PduObjectInput, options: SendOptions): Promise { // 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 }; + return { result: { err: abortedBeforeSend() }, written: false }; } const seqNr = this.pending.nextSeqNr(); const built = objToPdu({ ...input, seqNr }); - if (built.err) return { result: { err: built.err }, retryOnNextLink: false }; + if (built.err) return { result: { err: built.err }, written: false }; const response = this.pending.wait(seqNr, { signal: options.signal, @@ -167,12 +179,12 @@ export class OutgoingRequests { if (written.err) { this.pending.settle(seqNr, { err: written.err }); - return { result: { err: written.err }, retryOnNextLink: true }; + return { result: { err: written.err }, written: false }; } 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 { result: answered.err ? { err: new UnansweredError(answered.err) } : answered, written: true }; } } diff --git a/src/reassembly.ts b/src/reassembly.ts index 4f3d2c5..710d7bb 100644 --- a/src/reassembly.ts +++ b/src/reassembly.ts @@ -2,6 +2,7 @@ import type { Concat } from './concat.ts'; import type { PduObject } from './pdu.ts'; import type { SmppLog } from './log.ts'; import { ExpiringGroups } from './expiring-groups.ts'; +import { defaults } from './defaults.ts'; import { decodeMessage } from './message.ts'; import { detach, retainedOctets } from './retained-pdu.ts'; import { messageOctets } from './message-body.ts'; @@ -42,8 +43,6 @@ export type Collected = whole?: PduObject[] | undefined; }; -export const defaultMaxOctets = 64 * 1024 * 1024; - type Group = { parts: Map; smsId: string; @@ -89,7 +88,7 @@ export class Reassembler { private readonly onLost: (lost: LostGroup) => void; constructor(options: ReassemblerOptions) { - this.maxOctets = options.maxOctets ?? defaultMaxOctets; + this.maxOctets = options.maxOctets ?? defaults.session.maxReassemblyOctets; this.groups = new ExpiringGroups({ max: options.max, maxWeight: this.maxOctets, diff --git a/src/reconnect-loop.ts b/src/reconnect-loop.ts deleted file mode 100644 index af8044d..0000000 --- a/src/reconnect-loop.ts +++ /dev/null @@ -1,143 +0,0 @@ -import type { Result, VoidResult } from './result.ts'; -import type { SmppLog } from './log.ts'; -import type { Socket } from 'node:net'; - -export const backoffDefaults = { - maxDelay: 30_000, - minDelay: 1000, -}; - -export type ReconnectLoopOptions = { - connect: () => Promise>; - 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; - /** Whether the wait between attempts lets the process exit. Default true. */ - unref?: boolean | undefined; -}; - -/** Reopens a dropped connection, backing off between attempts until it is told to stop. */ -export class ReconnectLoop { - private readonly maxDelay: number; - private readonly minDelay: number; - private readonly now: () => number; - private readonly options: ReconnectLoopOptions; - private attempting = false; - private delay: number; - private halted = 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.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; - } - - schedule(): void { - if (this.timer || this.attempting || this.isStopped()) 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) { - this.delay = this.minDelay; - } - - this.upAt = undefined; - - const delay = this.delay; - - // 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(); - }, delay); - - if (this.options.unref ?? true) this.timer.unref(); - - this.delay = Math.min(delay * 2, this.maxDelay); - } - - stop(): void { - this.halted = true; - - if (this.timer) clearTimeout(this.timer); - - this.timer = undefined; - } - - private async run(): Promise { - 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; - }); - - this.attempting = false; - - if (retry) this.schedule(); - } - - /** True means the attempt failed and the loop should try again. */ - private async attempt(): Promise { - if (this.isStopped()) return false; - - const opened = await this.options.connect(); - - if (opened.err) { - this.options.log.warn('reconnect - could not open a socket', { - message: opened.err.message, - }); - - return true; - } - - if (this.isStopped()) { - opened.sock.destroy(); - - return false; - } - - const up = await this.bringUp(opened.sock); - - if (up.err) { - this.options.log.warn('reconnect - could not come back up', { message: up.err.message }); - - return true; - } - - this.upAt = this.now(); - - return false; - } - - /** 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 { - try { - const up = await this.options.onConnected(sock); - - if (up.err) sock.destroy(); - - return up; - } catch (thrown: unknown) { - sock.destroy(); - - return { err: thrown instanceof Error ? thrown : new Error(String(thrown)) }; - } - } -} diff --git a/src/server.ts b/src/server.ts index 2b9aefe..77a3709 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; + /** Takes every message a bound peer submits, on every session. */ + 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 = (...args: ServerEvents[K]) => unknown; @@ -166,7 +161,7 @@ function bindRespTlvs(session: Session, options: ServerOptions): TlvInputs | und if (!session.acceptsOptionalParams()) return undefined; return { - sc_interface_version: { tagValue: options.interfaceVersion ?? defaults.interfaceVersion }, + sc_interface_version: { tagValue: options.interfaceVersion ?? defaults.session.interfaceVersion }, }; } @@ -176,7 +171,7 @@ async function onBind( bindType: BindType, options: ServerOptions, ): Promise { - const identity = { system_id: options.systemId ?? defaults.systemId }; + const identity = { system_id: options.systemId ?? defaults.session.systemId }; const systemId = paramText(pduObj.params.system_id); if (!await authenticate(session, pduObj, options)) { @@ -233,17 +228,18 @@ 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.server.idleTimeout, 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, sock, - systemId: options.systemId ?? defaults.systemId, + systemId: options.systemId ?? defaults.session.systemId, }); session.linkEnd = 'smsc'; @@ -271,7 +267,7 @@ function createSecureListener(tlsOptions: TlsOptions, log: SmppLog): TlsServer { /** 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) { + for (const name of ['authenticate', 'onRequest', 'onSms'] as const) { const hook: unknown = options[name]; if (hook !== undefined && typeof hook !== 'function') { @@ -298,7 +294,7 @@ function checkOptions(options: ServerOptions, log: SmppLog, port: number): VoidR } // An int8 TLV on every bind response: out of range here means no ESME can ever bind. - const version = options.interfaceVersion ?? defaults.interfaceVersion; + const version = options.interfaceVersion ?? defaults.session.interfaceVersion; if (!Number.isInteger(version) || version < 0 || version > 0xFF) { log.warn('server - interface version out of range', { interfaceVersion: version }); @@ -345,7 +341,7 @@ function onListening(listener: NetServer, smpp: SmppServer, options: ServerOptio /** Starts listening for SMPP connections. Resolves once the socket is bound. */ export function server(options: ServerOptions = {}): Promise> { const log = guardedLog(options.log); - const port = options.port ?? defaults.port; + const port = options.port ?? defaults.server.port; const created = createListener(options, log, port); if (created.err) return Promise.resolve({ err: created.err }); diff --git a/src/session-life.ts b/src/session-life.ts new file mode 100644 index 0000000..b40b913 --- /dev/null +++ b/src/session-life.ts @@ -0,0 +1,204 @@ +import type { Result, VoidResult } from './result.ts'; +import type { SmppLog } from './log.ts'; +import type { Socket } from 'node:net'; +import { Backoff } from './backoff.ts'; +import { errorFrom } from './error-from.ts'; + +/** + * Where the session is in its life. One value, and every transition is in enter() below: + * + * connected --bound()---------> bound the link carries everything now + * connected --close()---------> ended + * connected --link lost-------> down | ended down where a reconnect policy exists + * bound -----close()/unbind()-> closing draining; new sends are refused, receipts still go out + * bound -----link lost--------> down | ended + * closing ---drained----------> ended + * closing ---link lost--------> ended + * down ------socket opened----> connected then the policy rebinds, which is what reaches bound() + * down ------attempt failed---> down waits the backoff out and tries again + * down ------close()----------> ended + * + * `connected` is a socket with no bind on it yet: it carries a bind and nothing else. `ended` is + * final. A transition from any other state is ignored, which is what makes a listener that + * re-enters here harmless: every event is emitted after its state and effects are in place. + */ +export type LinkState = 'bound' | 'closing' | 'connected' | 'down' | 'ended'; + +/** What a transition does to the rest of the session, each named for the state it serves. */ +export type LifeEffects = { + /** A fresh socket is the link now. */ + attach: (sock: Socket) => void; + emit: (event: 'close' | 'disconnected' | 'reconnected') => void; + /** The link is gone: drop the socket, its timers, and everything that only made sense on it. */ + linkDown: () => void; + /** The link is bound: everything waiting for one goes out. */ + linkUp: () => void; + /** The session is over: nothing waiting will ever go out. */ + over: () => void; +}; + +/** How a dropped link is followed by another: open a socket, then bind on it. */ +export type ReconnectPolicy = { + connect: () => Promise>; + maxDelay?: number | undefined; + minDelay?: number | undefined; + rebind: () => Promise; +}; + +export type SessionLifeOptions = { + effects: LifeEffects; + log: SmppLog; + now?: (() => number) | undefined; + reconnect?: ReconnectPolicy | undefined; +}; + +/** connect() and rebind() are the application's, so a throw from either is a result here. */ +async function caught(call: () => Promise): Promise { + try { + return await call(); + } catch (thrown: unknown) { + return { err: errorFrom(thrown) }; + } +} + +export class SessionLife { + state: LinkState = 'connected'; + + private readonly backoff: Backoff; + private readonly effects: LifeEffects; + private readonly log: SmppLog; + private readonly reconnect: ReconnectPolicy | undefined; + /** Counts the sockets this session has had, so the second bind onwards is a reconnect. */ + private links = 1; + private timer: NodeJS.Timeout | undefined; + + constructor(options: SessionLifeOptions) { + this.backoff = new Backoff({ ...options.reconnect, now: options.now }); + this.effects = options.effects; + this.log = options.log; + this.reconnect = options.reconnect; + } + + /** Whether the link carries requests: bound, or bound and draining. */ + carries(): boolean { + return this.state === 'bound' || this.state === 'closing'; + } + + /** A socket is on the link. */ + attached(): boolean { + return this.state !== 'down' && this.state !== 'ended'; + } + + bound(): void { + if (this.state === 'connected') this.enter('bound'); + } + + /** Starts the drain. False means there is no bound link to drain, so the caller ends at once. */ + closing(): boolean { + if (this.state !== 'bound') return false; + + this.enter('closing'); + + return true; + } + + end(): void { + if (this.state !== 'ended') this.enter('ended'); + } + + linkLost(): void { + if (!this.attached()) return; + + this.enter(this.state !== 'closing' && this.reconnect ? 'down' : 'ended'); + } + + private enter(next: LinkState, sock?: Socket): void { + const from = this.state; + + this.state = next; + this.log.debug('session - state', { from, to: next }); + + switch (next) { + case 'bound': + this.effects.linkUp(); + + if (this.links > 1) { + this.backoff.linkUp(); + this.log.info('session - reconnected'); + this.effects.emit('reconnected'); + } + + break; + case 'closing': + break; + case 'connected': + if (sock) { + this.links++; + this.effects.attach(sock); + } + + break; + case 'down': + this.effects.linkDown(); + this.schedule(); + this.effects.emit('disconnected'); + break; + case 'ended': + if (this.timer) clearTimeout(this.timer); + + this.effects.linkDown(); + this.effects.over(); + this.effects.emit('close'); + } + } + + /** Read through a method: an await above may have moved the state, which a narrowed field would hide. */ + private is(state: LinkState): boolean { + return this.state === state; + } + + /** Announced when the wait is over rather than when it starts: a cancelled one never happened. */ + private schedule(): void { + if (this.timer) return; + + const delay = this.backoff.next(); + + this.timer = setTimeout(() => { + this.timer = undefined; + this.log.info('reconnect - retrying', { delay }); + void this.attempt(); + }, delay); + this.timer.unref(); + } + + /** The one continuation that re-enters the machine after an await, so it checks what it came back to. */ + private async attempt(): Promise { + if (!this.reconnect) return; + + const { connect, rebind } = this.reconnect; + const opened = await caught(connect); + + if (!this.is('down')) { + if (!opened.err) opened.sock.destroy(); + + return; + } + + if (opened.err) { + this.log.warn('reconnect - could not open a socket', { message: opened.err.message }); + this.schedule(); + + return; + } + + this.enter('connected', opened.sock); + + const link = this.links; + const rebound = await caught(rebind); + + if (!rebound.err || this.links !== link || !this.is('connected')) return; + + this.log.warn('reconnect - could not come back up', { message: rebound.err.message }); + this.linkLost(); + } +} diff --git a/src/session-options.ts b/src/session-options.ts index 0b768c9..b9cc033 100644 --- a/src/session-options.ts +++ b/src/session-options.ts @@ -8,11 +8,11 @@ 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'; +/** Facts about the session, which nothing waits on. A message to answer goes to `onSms` instead. */ export type SessionEvents = { close: []; data: [Buffer]; @@ -23,7 +23,6 @@ export type SessionEvents = { messageDlr: [MessageDlr]; reconnected: []; sessionError: [Error | PduRefusedError]; - sms: [Sms]; }; export const bindCommands: readonly string[] = [ @@ -85,6 +84,13 @@ export type CloseOptions = { signal?: AbortSignal | undefined }; */ export type OnRequest = (session: Session, pduObj: PduObject) => Promise | boolean; +/** + * Takes every inbound message. The message is held until the handler settles: it counts toward the + * bound and a shutdown waits for it. One that returns has the message answered `ESME_ROK` unless it + * answered it itself; one that throws has it refused with the retry status. + */ +export type OnSms = (sms: Sms) => unknown; + /** * 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). @@ -104,6 +110,7 @@ export type SessionOptions = { maxOutstanding?: number | undefined; maxReassembly?: number | undefined; onRequest?: OnRequest | undefined; + onSms?: OnSms | undefined; reassemblyTimeout?: number | undefined; reconnect?: ReconnectOptions | undefined; responseTimeout?: number | undefined; @@ -116,8 +123,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 +151,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. @@ -187,12 +176,12 @@ export function checkSessionOptions(options: CheckableOptions): VoidResult { function limitsOf(options: CheckableOptions): [string, number, number][] { return [ ['idleTimeout', options.idleTimeout ?? 0, 0], - ['maxOctets', options.maxOctets ?? defaultMaxOctets, 1], - ['maxOutstanding', options.maxOutstanding ?? defaults.maxOutstanding, 1], - ['maxReassembly', options.maxReassembly ?? defaults.maxReassembly, 1], - ['reassemblyTimeout', options.reassemblyTimeout ?? defaults.reassemblyTimeout, 0], - ['responseTimeout', options.responseTimeout ?? defaults.responseTimeout, 0], - ['shutdownTimeout', options.shutdownTimeout ?? defaults.shutdownTimeout, 0], + ['maxOctets', options.maxOctets ?? defaults.session.maxReassemblyOctets, 1], + ['maxOutstanding', options.maxOutstanding ?? defaults.session.maxOutstanding, 1], + ['maxReassembly', options.maxReassembly ?? defaults.session.maxReassembly, 1], + ['reassemblyTimeout', options.reassemblyTimeout ?? defaults.session.reassemblyTimeout, 0], + ['responseTimeout', options.responseTimeout ?? defaults.session.responseTimeout, 0], + ['shutdownTimeout', options.shutdownTimeout ?? defaults.session.shutdownTimeout, 0], ]; } @@ -243,8 +232,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.reconnect.maxDelay); + const minDelay = delayOr(reconnect.minDelay, defaults.reconnect.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]]); diff --git a/src/session.ts b/src/session.ts index 1fd9b46..202d0c1 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1,9 +1,10 @@ import type { ErrorName } from './defs/errors.ts'; +import type { LinkState } from './session-life.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, OnSms, ReconnectOptions, 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 { SmppLog } from './log.ts'; @@ -11,15 +12,15 @@ 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 { OutgoingRequests, misuse } from './outgoing-requests.ts'; import { PduTransport } from './pdu-transport.ts'; -import { ReconnectLoop } from './reconnect-loop.ts'; +import { SessionLife } from './session-life.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, bindCommands, 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'; @@ -29,6 +30,7 @@ import { ConcatReference } from './udh.ts'; export type { CloseOptions, MessageDlr, + OnSms, ReconnectOptions, SendOptions, SendSmsOptions, @@ -36,8 +38,8 @@ export type { SessionEvents, SessionOptions, }; -export type { BindType }; -export { bindCommands, defaultSystemId }; +export type { BindType, LinkState }; +export { bindCommands }; /** A listener may return a promise: an `async` one that rejects is routed like one that throws. */ type SessionListener = (...args: SessionEvents[K]) => unknown; @@ -62,10 +64,9 @@ export class Session extends EventEmitter { private readonly concatReference = new ConcatReference(); private readonly dlrMerger: DlrMerger; private readonly incoming: IncomingRequests; - private readonly link: LinkLife; + private readonly life: SessionLife; private readonly options: SessionOptions; private readonly outgoing: OutgoingRequests; - private readonly reconnectLoop: ReconnectLoop | undefined; private readonly timers: LinkTimers; private readonly transport: PduTransport; @@ -93,13 +94,11 @@ export class Session extends EventEmitter { 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); } @@ -108,43 +107,29 @@ export class Session extends EventEmitter { 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.dlrMerger = new DlrMerger({ + log: this.log, + max: defaults.session.maxDlrMerges, + timeout: defaults.session.dlrMergeTimeout, + }); 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.life.linkLost(); }, }); this.transport = this.transportFor(options.sock); + this.life = this.lifeFor(options.reconnect); this.outgoing = new OutgoingRequests({ - link: this.link, log: this.log, - maxOutstanding: options.maxOutstanding ?? defaults.maxOutstanding, - responseTimeout, + maxOutstanding: options.maxOutstanding ?? defaults.session.maxOutstanding, + responseTimeout: options.responseTimeout ?? defaults.session.responseTimeout, + state: () => this.life.state, transport: this.transport, }); - this.incoming = new IncomingRequests({ - dlrMerger: this.dlrMerger, - link: this.link, - log: this.log, - maxOctets: options.maxOctets, - maxReassembly: options.maxReassembly, - onRequest: options.onRequest, - reassemblyTimeout: options.reassemblyTimeout, - sendPastDrain: input => this.outgoing.requestPastDrain(input, {}), - session: this, - smsIdFormat: options.smsIdFormat, - systemId: options.systemId, - }); - - this.resetTimers(); + this.incoming = this.incomingFor(options); + this.timers.reset(); } /** Replaced on reconnect, so hold the session rather than this. */ @@ -152,6 +137,11 @@ export class Session extends EventEmitter { return this.transport.sock; } + /** Where the session is in its life; `session-life.ts` draws the transitions. */ + get state(): LinkState { + return this.life.state; + } + /** The role the ESME bound with, whichever end of the link this is. Undefined before any bind. */ get boundAs(): BindType | undefined { return this.bind?.as; @@ -162,13 +152,16 @@ export class Session extends EventEmitter { return this.bind?.peerVersion; } - /** Records a bind this link accepted or had accepted, until the next one. */ + /** Records a bind this link accepted or had accepted, which is what lets it carry requests. */ bound(bindType: string, declaredVersion: unknown): VoidResult { const checked = checkedBind(bindType, declaredVersion); - if (!checked.err) this.bind = checked.bind; + if (checked.err) return { err: checked.err }; - return checked.err ? { err: checked.err } : {}; + this.bind = checked.bind; + this.life.bound(); + + return {}; } /** Whether this session's bind direction carries a command. Consulted by the library's senders. */ @@ -181,8 +174,13 @@ export class Session extends EventEmitter { return this.peerInterfaceVersion === undefined || this.peerInterfaceVersion >= optionalParamsMinVersion; } - /** Sends a request and resolves with the peer's response. */ + /** Sends a request and resolves with the peer's response. Refused once a shutdown has begun. */ send(input: PduObjectInput, options: SendOptions = {}): Promise> { + // A misuse is named as one ahead of the drain, rather than blamed on the shutdown. + if (this.life.state === 'closing' && !misuse(input)) { + return Promise.resolve({ err: new Error('Session is shutting down') }); + } + return this.outgoing.request(input, options); } @@ -196,22 +194,6 @@ export class Session extends EventEmitter { 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 { if (!this.bindAllows('submit_sm')) { return unsent(new Error('A receiver-bound session does not carry submit_sm')); @@ -235,25 +217,25 @@ export class Session extends EventEmitter { */ async unbind(): Promise { const drained = await this.drain(undefined); - const wasOpen = this.link.isAttached(); + const wasOpen = this.life.attached(); const sent = wasOpen - ? await this.outgoing.requestOnCurrentLink({ cmdName: 'unbind' }) + ? await this.outgoing.requestOnLink({ cmdName: 'unbind' }) : { err: new Error('Session is closed') }; - const closedOnUnbind = wasOpen && !this.link.isAttached(); + const closedOnUnbind = wasOpen && !this.life.attached(); - this.end(); + this.life.end(); return sent.err && !closedOnUnbind ? { 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 tears down whatever is left. Never reconnects. */ async close(options: CloseOptions = {}): Promise { const drained = await this.drain(options.signal); - this.end(); + this.life.end(); return drained; } @@ -261,140 +243,97 @@ export class Session extends EventEmitter { private transportFor(sock: Socket): PduTransport { return new PduTransport({ log: this.log, - onClose: () => { this.onClose(); }, - onData: chunk => { this.onData(chunk); }, + onClose: () => { this.life.linkLost(); }, + 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.life.linkLost(); }, }, sock); } - private loopFor(reconnect: ReconnectOptions | undefined): ReconnectLoop | undefined { - if (!reconnect) return undefined; - - return new ReconnectLoop({ - connect: reconnect.connect, + /** What each transition does to the collaborators above; the machine itself is `session-life.ts`. */ + private lifeFor(reconnect: ReconnectOptions | undefined): SessionLife { + return new SessionLife({ + effects: { + attach: sock => { this.transport.attach(sock); this.timers.reset(); }, + emit: event => { this.emit(event); }, + linkDown: () => { this.linkDown(); }, + linkUp: () => { this.timers.reset(); this.outgoing.linkUp(); }, + over: () => { this.outgoing.end(); this.dlrMerger.clear(); }, + }, log: this.log, - maxDelay: reconnect.maxDelay, - minDelay: reconnect.minDelay, - onConnected: sock => this.comeBackUp(sock, reconnect.onConnected), + reconnect: reconnect && { + connect: reconnect.connect, + maxDelay: reconnect.maxDelay, + minDelay: reconnect.minDelay, + rebind: () => reconnect.onConnected(this), + }, }); } - private async comeBackUp( - sock: Socket, - bind: (session: Session) => Promise, - ): Promise { - this.attach(sock); - - const bound = await bind(this); - - if (bound.err) { - this.teardown(); + private incomingFor(options: SessionOptions): IncomingRequests { + return new IncomingRequests({ + answer: (pduObj, status, params) => this.answer(pduReturn(pduObj, status, params), pduObj.cmdName, pduObj.seqNr), + dlrMerger: this.dlrMerger, + log: this.log, + maxOctets: options.maxOctets, + maxReassembly: options.maxReassembly, + onRequest: options.onRequest, + onSms: options.onSms, + reassemblyTimeout: options.reassemblyTimeout, + send: input => this.outgoing.request(input, {}), + session: this, + smsIdFormat: options.smsIdFormat, + systemId: options.systemId, + }); + } - return { err: bound.err }; - } + /** The link is gone: what only made sense on it goes with it. Every answer still owed settles. */ + private linkDown(): void { + this.timers.clear(); + this.incoming.clear(); + this.outgoing.linkLost(); + this.sock.destroy(); + } - // 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.life.attached()) { + 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 running and the requests already issued. */ private async drain(signal: AbortSignal | undefined): Promise { - this.stop(); - // No bound link, so nothing is on the wire to wait out. - if (!this.outgoing.canCarry()) return {}; + if (!this.life.closing()) return {}; - const timeout = this.options.shutdownTimeout ?? defaults.shutdownTimeout; + const timeout = this.options.shutdownTimeout ?? defaults.session.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 messages = 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()) { + if (this.life.state !== 'closing') { return { err: new Error('The session closed before the drain finished') }; } if (!messages.err) return requests; - if (!requests.err) return messages; return { err: new Error(`${messages.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; - - const responseTimeout = this.options.responseTimeout ?? defaults.responseTimeout; - - return responseTimeout > 0 ? responseTimeout : defaults.responseTimeout; - } - - /** The session is over now, drained or not. Nothing brings it back. */ - 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(); - } - - 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.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(); - } - private dispatch(pduObj: PduObject): void { if (isResp(pduObj)) { if (!this.outgoing.deliver(pduObj)) { @@ -405,15 +344,11 @@ export class Session extends EventEmitter { } 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); - this.log.error('session - a handler threw', { - cmdName: pduObj.cmdName, - message: err.message, - seqNr: pduObj.seqNr, - }); + this.log.error('session - a handler threw', { cmdName: pduObj.cmdName, message: err.message, seqNr: pduObj.seqNr }); this.emit('sessionError', err); }); } @@ -433,21 +368,4 @@ export class Session extends EventEmitter { 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..40c50ba 100644 --- a/src/sms.ts +++ b/src/sms.ts @@ -1,8 +1,10 @@ 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'; +import type { Socket } from 'node:net'; import { UnansweredError } from './unanswered-error.ts'; import { consts } from './defs/constants.ts'; import { decodeSegments } from './reassembly.ts'; @@ -28,15 +30,12 @@ export type SendRespOptions = { }; /** - * 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 it. A multipart message arrives as one Sms carrying + * every segment's PDU, answered segment by segment as they arrived. */ export type Sms = { - /** - * Whether the peer was answered as the message's segments arrived, which is what a concatenated - * message needs and a segment count cannot tell you. `sendResp()` then writes nothing. - */ - answeredOnArrival: boolean; + /** Whether the peer has been answered: by `sendResp()`, on arrival for a multipart message, or once `onSms` returned. */ + readonly answered: boolean; dlr: boolean; /** GSM 03.38 message class 0: shown on arrival and not stored. */ flash: boolean; @@ -46,13 +45,12 @@ export type Sms = { /** Sends a delivery report back to the sender. Defaults to DELIVERED. */ sendDlr: (status?: MessageState) => Promise; /** - * 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, `ESME_ROK` under a generated id unless told otherwise. A message + * already answered has nothing to do, and refuses an id or a status that would change the answer. */ sendResp: (options?: SendRespOptions) => Promise; session: Session; - /** The id the segments were answered with, the id `sendResp()` was given, or a generated UUID v7. */ + /** The id the message was or will be answered with: the segments' base, the id `sendResp()` was given, or a generated UUID v7. */ readonly smsId: string; submitTime: Date; to: string; @@ -61,39 +59,49 @@ export type Sms = { export type SmsInput = { /** The id base the segments were already answered with; absent leaves the answer to `sendResp()`. */ answeredAs?: string | undefined; + /** The socket the message arrived on: a response correlates on that link and no other. */ + link: Socket; pduObjs: PduObject[]; session: Session; }; +/** What answering and receipting a message needs from the session it arrived on. */ export type SmsHandlers = { - answered: () => void; - lostLink: () => boolean; + /** Writes one response now, on the link the request arrived on. */ + answer: (pduObj: PduObject, status: ErrorName, params: Record) => VoidResult; + /** A request that goes out during a drain too, since a receipt finishes what the drain waits on. */ send: (input: PduObjectInput) => Promise>; }; /** GSM 03.38 section 4 gives class 0 immediate display; every other class is stored somewhere. */ const immediateDisplayClass = 0; +/** The one record of whether and how the peer was answered. */ +type Answer = { smsId: string; status: ErrorName | undefined }; + export function createSms(input: SmsInput, handlers: SmsHandlers): 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 = { + smsId: input.answeredAs ?? uuidv7(), + status: input.answeredAs === undefined ? undefined : 'ESME_ROK', + }; const sms: Sms = { - answeredOnArrival: input.answeredAs !== undefined, + get answered(): boolean { + return answer.status !== undefined; + }, dlr: typeof registered === 'number' && registered !== 0, flash: typeof dataCoding === 'number' && messageClassOf(dataCoding) === immediateDisplayClass, 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)), + sendResp: options => Promise.resolve(sendResp(sms, input.link, answer, options ?? {}, handlers)), session: input.session, get smsId(): string { - return answered.smsId; + return answer.smsId; }, submitTime: new Date(), to: paramText(first?.params.destination_addr), @@ -102,63 +110,49 @@ 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, -): Promise { - if (options.smsId !== undefined) { - return Promise.resolve({ - err: new Error('This message\'s id was fixed when its first segment arrived; read sms.smsId'), - }); +/** An answer already given cannot change; one asked for again is nothing to do. */ +function alreadyAnswered(answer: Answer, given: ErrorName, options: SendRespOptions): VoidResult { + if (options.smsId !== undefined && options.smsId !== answer.smsId) { + return { err: new Error(`This message was already answered under id ${answer.smsId}; read 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'), - }); + if ((options.status ?? 'ESME_ROK') !== given) { + return { err: new Error(`This message was already answered ${given}, and an answer cannot change; refuse a submission from the onRequest option instead`) }; } - handlers.answered(); - - return Promise.resolve({}); + return {}; } -async function sendResp( +function sendResp( sms: Sms, - session: Session, - answered: { smsId: string }, + link: Socket, + answer: Answer, options: SendRespOptions, - handlers: Pick, -): Promise { - const total = sms.pduObjs.length; - - 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 !== undefined) answered.smsId = options.smsId; + handlers: Pick, +): VoidResult { + if (options.smsId === '') return { err: new Error('smsId must not be empty') }; + if (answer.status !== undefined) return alreadyAnswered(answer, answer.status, options); + if (sms.pduObjs.length === 0) return { err: new Error('No PDUs to answer') }; // A response carries the sequence number it was asked on, which the next link knows nothing about. - if (handlers.lostLink()) { + if (link.destroyed) { return { err: new Error('The link this message arrived on is gone, so nothing would correlate the response') }; } - 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 status = options.status ?? 'ESME_ROK'; + const total = sms.pduObjs.length; + const failure = sms.pduObjs + .map((pduObj, index) => handlers.answer(pduObj, status, respIdParams(pduObj.cmdName, segmentId(smsId, index, total)))) + .find(result => result.err); - const failure = results.find(result => result.err); + // Nothing reached the peer, so the message is still unanswered and the id it was given is not its. + if (failure) return failure; - if (!failure) handlers.answered(); + answer.smsId = smsId; + answer.status = status; - return failure ?? {}; + return {}; } /** The receipt as text, which is all of it a peer below SMPP 3.4 is allowed to be sent. */ @@ -239,5 +233,6 @@ async function sendDlr( ...(session.acceptsOptionalParams() ? { tlvs: receiptTlvs(smsId, status) } : {}), }); })); + return collectReceipt(sent); } diff --git a/test/declared-alphabet.test.ts b/test/declared-alphabet.test.ts index 8132883..e762ffc 100644 --- a/test/declared-alphabet.test.ts +++ b/test/declared-alphabet.test.ts @@ -21,7 +21,7 @@ const bothTables = 'Cost 5$ @home'; /** What SMPP 3.4 5.2.19 assigns each coding, read the way a peer honouring the field reads it. */ const byTheSpecsTable: Record string> = { // The SMSC default alphabet, which every peer in interop-tests/ runs as GSM 03.38. - 0x00: octets => encodings.ASCII.decode(octets), + 0x00: octets => encodings.GSM7.decode(octets), // IA5 (CCITT T.50), whose whole range is what Latin-1 reads below 0x80. [consts.ENCODING.IA5]: octets => octets.toString('latin1'), }; @@ -121,17 +121,18 @@ describe('the alphabet a message declares is the one its octets are written in', // 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); @@ -166,7 +167,7 @@ describe('the alphabet a message declares is the one its octets are written in', describe('what a peer declares is read as generously as it was before', () => { test('reads data_coding 0x01 as GSM 03.38, as 0x00 is read', () => { - assert.equal(encodingByDataCoding(0x01), 'ASCII'); + assert.equal(encodingByDataCoding(0x01), 'GSM7'); const octets = Buffer.from('436f73742035022000686f6d65', 'hex'); diff --git a/test/encodings.test.ts b/test/encodings.test.ts index 67fc68c..db908ff 100644 --- a/test/encodings.test.ts +++ b/test/encodings.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import test, { describe } from 'node:test'; import { dataCodingByEncoding, detect, encodingByDataCoding, encodings, isEncodingName, unencodable } from '../src/defs/encodings.ts'; -describe('ASCII (GSM 03.38)', () => { +describe('GSM7 (GSM 03.38)', () => { const samples: [string, number[]][] = [ ['@£$¥', [0, 1, 2, 3]], [' 1a=', [0x20, 0x31, 0x61, 0x3D]], @@ -10,23 +10,23 @@ describe('ASCII (GSM 03.38)', () => { ]; test('matches strings encodable in the GSM 03.38 charset', () => { - assert.ok(encodings.ASCII.match('')); - assert.ok(encodings.ASCII.match('@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1BÆæßÉ !"#¤%&\'')); - assert.ok(encodings.ASCII.match('()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZ')); - assert.ok(encodings.ASCII.match('ÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà')); - assert.ok(encodings.ASCII.match('\f^{}\\[~]|€')); + assert.ok(encodings.GSM7.match('')); + assert.ok(encodings.GSM7.match('@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1BÆæßÉ !"#¤%&\'')); + assert.ok(encodings.GSM7.match('()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZ')); + assert.ok(encodings.GSM7.match('ÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà')); + assert.ok(encodings.GSM7.match('\f^{}\\[~]|€')); }); test('rejects strings outside the GSM 03.38 charset', () => { - assert.ok(!encodings.ASCII.match('`')); - assert.ok(!encodings.ASCII.match('ÁáçÚUÓO')); - assert.ok(!encodings.ASCII.match('تست')); + assert.ok(!encodings.GSM7.match('`')); + assert.ok(!encodings.GSM7.match('ÁáçÚUÓO')); + assert.ok(!encodings.GSM7.match('تست')); }); test('round-trips the sample strings', () => { for (const [str, bytes] of samples) { - assert.deepEqual(encodings.ASCII.encode(str), Buffer.from(bytes)); - assert.equal(encodings.ASCII.decode(Buffer.from(bytes)), str); + assert.deepEqual(encodings.GSM7.encode(str), Buffer.from(bytes)); + assert.equal(encodings.GSM7.decode(Buffer.from(bytes)), str); } }); }); @@ -97,8 +97,8 @@ describe('UCS2', () => { describe('detect()', () => { test('picks the narrowest encoding that fits the string', () => { - assert.equal(detect(''), 'ASCII'); - assert.equal(detect('ÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà(){}[]'), 'ASCII'); + assert.equal(detect(''), 'GSM7'); + assert.equal(detect('ÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà(){}[]'), 'GSM7'); assert.equal(detect('`ÁáçÚUÓO'), 'UCS2'); assert.equal(detect('«©®µ¶±»'), 'UCS2'); assert.equal(detect('ʹʺʻʼʽ`'), 'UCS2'); @@ -122,16 +122,16 @@ describe('detect()', () => { describe('unencodable()', () => { test('names the first character an alphabet cannot carry, and nothing where it carries them all', () => { assert.deepEqual(unencodable('あいう', 'LATIN1'), { char: 'あ', index: 0 }); - assert.deepEqual(unencodable('Åsa naïve', 'ASCII'), { char: 'ï', index: 6 }); - assert.equal(unencodable('€{}[]\\~^|\f', 'ASCII'), undefined); - assert.equal(unencodable('Åsa', 'ASCII'), undefined); + assert.deepEqual(unencodable('Åsa naïve', 'GSM7'), { char: 'ï', index: 6 }); + assert.equal(unencodable('€{}[]\\~^|\f', 'GSM7'), undefined); + assert.equal(unencodable('Åsa', 'GSM7'), undefined); assert.equal(unencodable('`ÁáçÚ', 'LATIN1'), undefined); assert.equal(unencodable('あいう😀', 'UCS2'), undefined); }); test('counts the index in the units the message is written in, so a surrogate pair reads back whole', () => { assert.deepEqual(unencodable('ab😀', 'LATIN1'), { char: '😀', index: 2 }); - assert.deepEqual(unencodable('a😀b', 'ASCII'), { char: '😀', index: 1 }); + assert.deepEqual(unencodable('a😀b', 'GSM7'), { char: '😀', index: 1 }); }); test('carries every octet through Latin-1, which is what an 8-bit binary body is sent as', () => { @@ -147,36 +147,36 @@ describe('unencodable()', () => { }); test('reads a character at a time, so a bare GSM escape beside its base reads as carried', () => { - assert.equal(unencodable('\x1Be', 'ASCII'), undefined); - assert.deepEqual(encodings.ASCII.encode('\x1Be'), encodings.ASCII.encode('€')); + assert.equal(unencodable('\x1Be', 'GSM7'), undefined); + assert.deepEqual(encodings.GSM7.encode('\x1Be'), encodings.GSM7.encode('€')); }); }); describe('encodingByDataCoding()', () => { test('resolves the flat SMPP data_coding table', () => { - assert.equal(encodingByDataCoding(0x00), 'ASCII'); - assert.equal(encodingByDataCoding(0x01), 'ASCII'); + assert.equal(encodingByDataCoding(0x00), 'GSM7'); + assert.equal(encodingByDataCoding(0x01), 'GSM7'); assert.equal(encodingByDataCoding(0x08), 'UCS2'); }); // 0.4.0 resolved 0x03 to the alias ISO_8859_1, which has no decoder, and silently fell back to // ASCII — every Latin-1 message came out corrupted. - test('resolves 0x03 to LATIN1 rather than falling back to ASCII', () => { + test('resolves 0x03 to LATIN1 rather than falling back to GSM7', () => { assert.equal(encodingByDataCoding(0x03), 'LATIN1'); }); test('reads the alphabet bits when a message class is present', () => { - assert.equal(encodingByDataCoding(0x10), 'ASCII'); - assert.equal(encodingByDataCoding(0x11), 'ASCII'); + assert.equal(encodingByDataCoding(0x10), 'GSM7'); + assert.equal(encodingByDataCoding(0x11), 'GSM7'); assert.equal(encodingByDataCoding(0x18), 'UCS2'); assert.equal(encodingByDataCoding(0x1A), 'UCS2'); - assert.equal(encodingByDataCoding(0xF0), 'ASCII'); - assert.equal(encodingByDataCoding(0xF1), 'ASCII'); + assert.equal(encodingByDataCoding(0xF0), 'GSM7'); + assert.equal(encodingByDataCoding(0xF1), 'GSM7'); }); // The compressed and automatic-deletion groups put the alphabet where the plain one does. test('reads them in the compressed and automatic-deletion groups too', () => { - assert.equal(encodingByDataCoding(0x30), 'ASCII'); + assert.equal(encodingByDataCoding(0x30), 'GSM7'); assert.equal(encodingByDataCoding(0x38), 'UCS2'); assert.equal(encodingByDataCoding(0x54), 'LATIN1'); assert.equal(encodingByDataCoding(0x58), 'UCS2'); @@ -196,10 +196,10 @@ describe('encodingByDataCoding()', () => { } }); - test('falls back to ASCII for alphabets it has no codec for', () => { - assert.equal(encodingByDataCoding(0x05), 'ASCII'); - assert.equal(encodingByDataCoding(0x0E), 'ASCII'); + test('falls back to GSM7 for alphabets it has no codec for', () => { + assert.equal(encodingByDataCoding(0x05), 'GSM7'); + assert.equal(encodingByDataCoding(0x0E), 'GSM7'); // No class, so nothing says the octet is spelled 03.38 rather than SMPP's own flat table. - assert.equal(encodingByDataCoding(0x48), 'ASCII'); + assert.equal(encodingByDataCoding(0x48), 'GSM7'); }); }); diff --git a/test/interop.test.ts b/test/interop.test.ts index 2e5d905..e02141a 100644 --- a/test/interop.test.ts +++ b/test/interop.test.ts @@ -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 }); + let arrived: ((sms: Sms) => void) | undefined; + const incoming = new Promise(resolve => { arrived = resolve; }); + const { err: serverErr, server: smpp } = await server({ onSms: sms => { arrived?.(sms); }, port: 0 }); assert.equal(serverErr, undefined); assert.ok(smpp); closeAfter(t, smpp); - const incoming = new Promise(resolve => { - smpp.on('session', session => session.on('sms', resolve)); - }); - const refSession = reference.connect({ url: `smpp://localhost:${String(smpp.port)}`, }); diff --git a/test/message-class.test.ts b/test/message-class.test.ts index e613139..f282e01 100644 --- a/test/message-class.test.ts +++ b/test/message-class.test.ts @@ -27,18 +27,12 @@ type MessagePeer = { /** A server that answers every message, and a client to write raw submit_sm PDUs at it. */ async function messagesInto(t: TestContext): Promise { 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); @@ -216,7 +210,7 @@ describe('sendSms() flash', () => { const sent = await submitSms(deps, { encoding, from, message: 'Hello world', to }); assert.ok(sent.err instanceof Error, JSON.stringify(encoding)); - assert.match(sent.err.message, /encoding must be ASCII, LATIN1, UCS2/); + assert.match(sent.err.message, /encoding must be GSM7, LATIN1, UCS2/); assert.deepEqual(sent.smsIds, []); } diff --git a/test/message.test.ts b/test/message.test.ts index 37392a4..4145c6b 100644 --- a/test/message.test.ts +++ b/test/message.test.ts @@ -124,7 +124,7 @@ describe('splitMessage()', () => { describe('encodeMessage() and decodeMessage()', () => { test('picks GSM for GSM-safe text and UCS2 otherwise', () => { - assert.equal(encodeMessage('Hello').encoding, 'ASCII'); + assert.equal(encodeMessage('Hello').encoding, 'GSM7'); assert.equal(encodeMessage('تست').encoding, 'UCS2'); }); @@ -134,7 +134,7 @@ describe('encodeMessage() and decodeMessage()', () => { // 0.4.0 resolved data_coding 0x03 to the alias ISO_8859_1, which has no decoder, so every // Latin-1 message was silently decoded as ASCII. - test('decodes Latin-1 rather than falling back to ASCII', () => { + test('decodes Latin-1 rather than falling back to GSM7', () => { assert.equal(decodeMessage(Buffer.from([0xE1, 0xE7, 0xDA]), 0x03).message, 'áçÚ'); }); @@ -165,7 +165,7 @@ describe('encodeMessage() and decodeMessage()', () => { }); describe('the alphabet the encoding helpers are asked for', () => { - const everyName: EncodingName[] = ['ASCII', 'LATIN1', 'UCS2']; + const everyName: EncodingName[] = ['GSM7', 'LATIN1', 'UCS2']; test('is one of three, each with a codec, so none of the three helpers can reach an absent one', () => { assert.deepEqual(Object.keys(encodings).sort(), [...everyName].sort()); diff --git a/test/readme.test.ts b/test/readme.test.ts index f2198a5..bb57ef2 100644 --- a/test/readme.test.ts +++ b/test/readme.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test, { describe } from 'node:test'; import type { Dlr } from '../src/dlr.ts'; +import type { OnSms } from '../src/session-options.ts'; import type { Session } from '../src/session.ts'; import type { Sms } from '../src/sms.ts'; import type { SmppLog } from '../src/log.ts'; @@ -26,21 +27,19 @@ function once(register: (resolve: (value: T) => void) => void): Promise { } /** The README's examples listen on the documented default port, so one server runs at a time. */ -async function answeringServer(t: TestContext): Promise { - 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?: OnSms): Promise { + const { err, server: smpp } = await server({ + onSms: onSms ?? (async sms => { await sms.sendResp(); if (sms.dlr) await sms.sendDlr(); - }); + }), }); + assert.equal(err, undefined); + assert.ok(smpp); + closeAfter(t, smpp); + return smpp; } @@ -121,10 +120,7 @@ describe('README: Client', () => { }); test('the documented sending options', async t => { - const smpp = await answeringServer(t); - const incoming = once(resolve => { - smpp.on('session', session => session.on('sms', resolve)); - }); + const incoming = once(resolve => { void answeringServer(t, resolve); }); const { err, session } = await client(); if (err) throw err; @@ -154,15 +150,20 @@ describe('README: Client', () => { test('receiving an inbound message on a client session', async t => { const smpp = await answeringServer(t); const bound = once(resolve => { smpp.on('session', resolve); }); - const { err, session } = await client(); + const received: string[] = []; + const { err, session } = await client({ + onSms: sms => { + // sms.from, sms.to, sms.message; answered ESME_ROK once this returns + received.push(sms.message); + }, + }); if (err) throw err; closeAfter(t, session); - const incoming = once(resolve => { session.on('sms', resolve); }); const peer = await bound; - void peer.send({ + const delivered = await peer.send({ cmdName: 'deliver_sm', params: { destination_addr: '46709771337', @@ -171,29 +172,23 @@ describe('README: Client', () => { }, }); - const sms = await incoming; - - await sms.sendResp(); - - assert.equal(sms.message, 'inbound hello'); + assert.equal(delivered.pduObj?.cmdStatus, 'ESME_ROK'); + assert.deepEqual(received, ['inbound hello']); }); }); 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: sms => { + // sms.from, sms.to, sms.message, sms.dlr, sms.session received.push(sms.message); - await sms.sendResp(); - }); + }, }); + if (err) throw err; + + closeAfter(t, smpp); const { err: clientErr, session } = await client(); @@ -208,34 +203,27 @@ describe('README: Server', () => { }); 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 => { - answeredOnArrival = sms.answeredOnArrival; + onSms: async sms => { + answeredOnArrival = sms.answered; - if (sms.answeredOnArrival) { - await sms.sendResp(); // multipart: only releases the shutdown drain - } else { - await sms.sendResp(); // ESME_ROK with a generated id - } + // no args: ESME_ROK + generated id; or sendResp({ smsId, status: 'ESME_RMSGQFUL' }) + await sms.sendResp(); if (sms.dlr) { - await sms.sendDlr(); + await sms.sendDlr(); // same as sms.sendDlr('DELIVERED') } - }); + }, }); + if (err) throw err; + + closeAfter(t, smpp); assert.equal(smpp.port, 2775); @@ -264,6 +252,7 @@ describe('README: Server', () => { 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,15 +263,12 @@ 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; diff --git a/test/session-error.test.ts b/test/session-error.test.ts index af9b3b9..b34942b 100644 --- a/test/session-error.test.ts +++ b/test/session-error.test.ts @@ -38,8 +38,12 @@ async function waitFor(condition: () => boolean, budget = 2000): Promise[0] = {}) { - const { err, server: smpp } = await server({ port: 0 }); +async function linked( + t: TestContext, + options: Parameters[0] = {}, + serverOptions: Parameters[0] = {}, +) { + const { err, server: smpp } = await server({ ...serverOptions, port: 0 }); assert.equal(err, undefined); assert.ok(smpp); @@ -94,19 +98,21 @@ 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 { peer, session } = await linked( + t, + { responseTimeout: 200 }, + { onSms: () => { throw new Error('handler exploded'); } }, + ); const failed = once(resolve => { peer.on('sessionError', resolve); }); - peer.on('sms', () => { throw new Error('listener exploded'); }); - - await session.sendSms({ from: '46701113311', message: 'blows the listener up', to: '46709771337' }); + await session.sendSms({ from: '46701113311', message: 'blows the handler up', to: '46709771337' }); 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 => { diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts index 6b36279..bac002a 100644 --- a/test/session-extras.test.ts +++ b/test/session-extras.test.ts @@ -4,29 +4,32 @@ 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 { HandledMessagesOptions } from '../src/handled-messages.ts'; import type { IncomingRequestsOptions } from '../src/incoming-requests.ts'; -import type { HeldMessagesOptions, MessageHold } from '../src/held-messages.ts'; +import type { LifeEffects, LinkState, SessionLifeOptions } from '../src/session-life.ts'; +import type { OnSms } from '../src/session-options.ts'; import type { MessageState } from '../src/defs/constants.ts'; import type { MessageDlr } from '../src/session.ts'; import type { PduObject, PduObjectInput } from '../src/pdu.ts'; -import type { Result } from '../src/result.ts'; +import type { Result, VoidResult } from '../src/result.ts'; import type { SendSmsResult } from '../src/send-sms.ts'; import type { SmppLog } from '../src/log.ts'; -import type { Sms } from '../src/sms.ts'; +import type { SendRespOptions, Sms, SmsHandlers } 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 { HandledMessages } from '../src/handled-messages.ts'; import { IncomingRequests, refusedSegmentStatus } from '../src/incoming-requests.ts'; import { UnansweredError } from '../src/unanswered-error.ts'; import { createSms } from '../src/sms.ts'; -import { LinkLife } from '../src/link-life.ts'; +import { SessionLife } from '../src/session-life.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 { defaults } from '../src/defaults.ts'; import { client } from '../src/client.ts'; import { closeAfter, closeListenerAfter } from './teardown.ts'; import { concatOf } from '../src/concat.ts'; @@ -77,10 +80,70 @@ function once(register: (resolve: (value: T) => void) => void): Promise { }); } +type Inbox = { + /** Answers the message and lets its handler return. */ + answer: (sms: Sms, options?: SendRespOptions) => Promise; + /** The next message handed over; its handler runs until the test answers or releases it. */ + next: () => Promise; + onSms: OnSms; + release: (sms: Sms) => void; +}; + +/** An application that hands each message to the test and stays in the handler until told it is done. */ +function inbox(): Inbox { + const queued: Sms[] = []; + const waiting: ((sms: Sms) => void)[] = []; + const releases = new Map void>(); + const release = (sms: Sms): void => { + releases.get(sms)?.(); + releases.delete(sms); + }; + + return { + answer: async (sms, options) => { + const answered = await sms.sendResp(options); + + release(sms); + + return answered; + }, + next: () => { + const sms = queued.shift(); + + return sms ? Promise.resolve(sms) : once(resolve => waiting.push(resolve)); + }, + onSms: sms => new Promise(done => { + releases.set(sms, done); + + const next = waiting.shift(); + + if (next) next(sms); + else queued.push(sms); + }), + release, + }; +} + +/** A handler that never returns, for a message that must stay held. */ +const stuck: OnSms = () => new Promise(() => undefined); + function delay(ms: number): Promise { 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 { + 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(ms: number, promise: Promise): Promise { return Promise.race([promise, delay(ms).then((): undefined => undefined)]); @@ -101,12 +164,17 @@ function abortAfter( }); } +/** Answers through the session's own sendReturn, so a test that replaces that sees every answer. */ function incomingOn(session: Session, options: Partial = {}): IncomingRequests { return new IncomingRequests({ + answer: (pduObj, status, params) => { + void session.sendReturn(pduObj, status, params); + + return {}; + }, 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') }), + send: () => Promise.resolve({ err: new Error('never sent') }), session, ...options, }); @@ -170,10 +238,9 @@ function latch(): Latch { 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(resolve => { - smpp.on('session', session => session.on('sms', resolve)); - }); + const box = inbox(); + const smpp = await startServer(t, { onSms: box.onSms }); + const incoming = box.next(); const { session } = await connect(t, smpp); assert.ok(session); @@ -208,10 +275,9 @@ describe('merged delivery reports', () => { }); test('reports once, on the final receipts, when the peer reports en route first', async t => { - const smpp = await startServer(t); - const incoming = once(resolve => { - smpp.on('session', session => session.on('sms', resolve)); - }); + const box = inbox(); + const smpp = await startServer(t, { onSms: box.onSms }); + const incoming = box.next(); const { session } = await connect(t, smpp); assert.ok(session); @@ -264,10 +330,9 @@ describe('merged delivery reports', () => { }); test('reports the worst status across the segments', async t => { - const smpp = await startServer(t); - const incoming = once(resolve => { - smpp.on('session', session => session.on('sms', resolve)); - }); + const box = inbox(); + const smpp = await startServer(t, { onSms: box.onSms }); + const incoming = box.next(); const { session } = await connect(t, smpp); assert.ok(session); @@ -378,10 +443,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(resolve => { - smpp.on('session', session => session.on('sms', resolve)); - }); + const box = inbox(); + const smpp = await startServer(t, { onSms: box.onSms }); + const incoming = box.next(); const { session } = await connect(t, smpp); assert.ok(session); @@ -602,17 +666,8 @@ describe('reconnect', () => { }); test('re-binds after the connection drops, keeping the same session object', async t => { - const smpp = await startServer(t); 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 smpp = await startServer(t, { onSms: sms => { messages.push(sms.message); } }); const { err, session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); assert.equal(err, undefined); @@ -652,13 +707,9 @@ describe('reconnect', () => { }); test('merges the receipts of a multipart message across a drop', async t => { - const smpp = await startServer(t); - const incoming = once(resolve => { - smpp.on('session', bound => bound.on('sms', sms => { - resolve(sms); - void sms.sendResp(); - })); - }); + const box = inbox(); + const smpp = await startServer(t, { onSms: box.onSms }); + const incoming = box.next(); const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); assert.ok(session); @@ -671,6 +722,7 @@ describe('reconnect', () => { }); const sms = await incoming; + box.release(sms); 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. @@ -692,21 +744,23 @@ describe('reconnect', () => { assert.equal(report.segments.length, 3); }); - test('refuses to answer a message whose link went, held or already answered', async t => { + test('refuses to answer a held message whose link went, and has nothing to do for one already answered', 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(resolve => { - session.on('sms', sms => { + const both = latch(); + const { session } = await connect(t, smpp, { + onSms: sms => { arrived.push(sms); - if (arrived.length === 2) resolve(true); - }); + if (arrived.length === 2) both.open(); + + return stuck(sms); + }, + reconnect: { maxDelay: 100, minDelay: 20 }, }); + assert.ok(session); + for (const text of ['answered before the drop', 'never answered']) { void peerOf(smpp).send({ cmdName: 'deliver_sm', @@ -718,7 +772,7 @@ describe('reconnect', () => { }); } - await both; + await both.passed; const [answered, held] = arrived; @@ -737,7 +791,7 @@ describe('reconnect', () => { // 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.deepEqual(await answered.sendResp(), {}, 'the answer was given on the link that carried the message'); assert.match((await held.sendResp()).err?.message ?? '', /link this message arrived on is gone/); assert.equal((await held.sendDlr('DELIVERED')).err, undefined); @@ -745,25 +799,28 @@ describe('reconnect', () => { }); 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; } }); + const gone = new net.Socket(); + const session = new Session({ sock: gone }); let messages = 0; - session.on('sms', () => { messages++; }); + closeAfter(t, session); + const incoming = incomingOn(session, { + onRequest: async () => { await delay(10); return false; }, + onSms: () => { messages++; }, + }); const handled = incoming.handle(submitPdu(1)); - link.drop(); + gone.destroy(); await handled; assert.equal(messages, 0); - await incoming.handle(submitPdu(2)); + const stayed = new Session({ sock: new net.Socket() }); + + closeAfter(t, stayed); + await incomingOn(stayed, { onSms: () => { messages++; } }).handle(submitPdu(2)); assert.equal(messages, 1, 'the harness delivers a message whose link stayed'); }); @@ -1148,27 +1205,26 @@ 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: sms => { arrived.push(sms.message); - if (arrived.length === 1) first.open(); - else await sms.sendResp(); - }); - }); + if (arrived.length > 1) return undefined; + + first.open(); - return first; + return stuck(sms); + }, + }; } 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 smpp = await startServer(t, { onSms: sms => { arrived.push(sms.message); } }); const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); assert.ok(session); @@ -1187,9 +1243,9 @@ 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 { first, onSms } = answerAfterTheFirst(arrived); + const smpp = await startServer(t, { onSms }); const { session } = await connect(t, smpp, { maxOutstanding: 1, reconnect: { maxDelay: 100, minDelay: 20 }, @@ -1228,10 +1284,9 @@ 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); @@ -1258,10 +1313,7 @@ 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 smpp = await startServer(t, { onSms: stuck }); const { session } = await connect(t, smpp, { responseTimeout: 200 }); assert.ok(session); @@ -1273,8 +1325,9 @@ describe('sends across a reconnect', () => { }); test('counts a segment aborted after it went out as unanswered', async t => { - const smpp = await startServer(t); - const arrived = once(resolve => { smpp.on('session', peer => peer.on('sms', resolve)); }); + const box = inbox(); + const smpp = await startServer(t, { onSms: box.onSms }); + const arrived = box.next(); const { session } = await connect(t, smpp); assert.ok(session); @@ -1295,8 +1348,9 @@ 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(resolve => { smpp.on('session', peer => peer.on('sms', resolve)); }); + const box = inbox(); + const smpp = await startServer(t, { onSms: box.onSms }); + const arrived = box.next(); const { session } = await connect(t, smpp, { reconnect: false }); assert.ok(session); @@ -1403,92 +1457,207 @@ describe('sends across a reconnect', () => { }); }); -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); +describe('SessionLife', () => { + type Recorded = { events: string[]; life: SessionLife; effects: string[] }; - link.drop(); - now = 101; - - const held = await waitForLink(); + function lifeWith(reconnect?: Partial & { onEvent?: (event: string, life: SessionLife) => void }): Recorded { + const effects: string[] = []; + const events: string[] = []; + const record = (name: keyof LifeEffects) => () => { effects.push(name); }; + const recorded: Recorded = { + effects, + events, + life: new SessionLife({ + effects: { + attach: record('attach'), + emit: event => { + events.push(event); + reconnect?.onEvent?.(event, recorded.life); + }, + linkDown: record('linkDown'), + linkUp: record('linkUp'), + over: record('over'), + }, + log: silentLog, + now: reconnect?.now, + reconnect: reconnect?.reconnect, + }), + }; - assert.match(held.err?.message ?? '', /did not come back in time/); - }); + return recorded; + } - test('holds on a timer that keeps the process alive', async () => { - const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 10_000 }); - const timers = (): number => process.getActiveResourcesInfo().filter(name => name === 'Timeout').length; + const noLink = { connect: () => Promise.resolve({ err: new Error('no socket') }), rebind: () => Promise.resolve({}) }; - link.drop(); + test('walks the states the table draws, and a transition from a state that has none is ignored', () => { + const { effects, events, life } = lifeWith(); + const path: LinkState[] = [life.state]; - const before = timers(); - const held = link.hold(undefined)(); + life.linkLost(); + path.push(life.state); + assert.deepEqual(path, ['connected', 'ended'], 'no reconnect policy, so a lost link ends the session'); + assert.deepEqual(effects, ['linkDown', 'over']); + assert.deepEqual(events, ['close']); - assert.equal(timers(), before + 1, 'an unref\'d timer is not counted here, which is the point'); + life.bound(); + life.linkLost(); + assert.equal(life.state, 'ended', 'ended is final'); + assert.deepEqual(events, ['close']); + }); - link.open(); + test('binds, drains and ends in that order, and only a bound link is drained', () => { + const { effects, events, life } = lifeWith(); - assert.deepEqual(await held, {}); + assert.equal(life.closing(), false, 'nothing bound, nothing to drain'); + life.bound(); + assert.equal(life.state, 'bound'); + assert.deepEqual(effects, ['linkUp']); + assert.deepEqual(events, [], 'the first bind is not a reconnect'); + assert.equal(life.closing(), true); + assert.equal(life.state, 'closing'); + assert.equal(life.carries(), true, 'a receipt still goes out during the drain'); + life.end(); + assert.equal(life.state, 'ended'); + assert.deepEqual(events, ['close']); }); - // 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('goes down and comes back up on a reconnect policy, reporting each step once', async t => { + const opened: net.Socket[] = []; + const { effects, events, life } = lifeWith({ + reconnect: { + connect: () => { + const sock = new net.Socket(); - link.drop(); + opened.push(sock); - const held = await link.hold(AbortSignal.abort())(); + return Promise.resolve({ sock }); + }, + maxDelay: 20, + minDelay: 5, + rebind: () => { + life.bound(); - assert.match(held.err?.message ?? '', /Aborted while waiting for a link/); + return Promise.resolve({}); + }, + }, + }); + + t.after(() => { life.end(); for (const sock of opened) sock.destroy(); }); + life.bound(); + life.linkLost(); + assert.equal(life.state, 'down'); + assert.deepEqual(events, ['disconnected']); + assert.ok(await waitFor(() => life.state === 'bound')); + assert.deepEqual(events, ['disconnected', 'reconnected']); + assert.deepEqual(effects, ['linkUp', 'linkDown', 'attach', 'linkUp']); + life.linkLost(); + assert.equal(life.state, 'down', 'a second drop is retried like the first'); }); - test('awaits the next link only while down with one on its way', () => { - const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 100 }); + // Every event is emitted after its state and effects are in place, so this needs no guard. + test('lets a disconnected listener end the session without a retry ever running', async t => { + let attempts = 0; + const { events, life } = lifeWith({ + onEvent: (event, current) => { if (event === 'disconnected') current.end(); }, + reconnect: { ...noLink, connect: () => { attempts++; return noLink.connect(); }, maxDelay: 10, minDelay: 1 }, + }); - 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'); + t.after(() => { life.end(); }); + life.bound(); + life.linkLost(); + assert.equal(life.state, 'ended'); + assert.deepEqual(events, ['disconnected', 'close']); + await delay(30); + assert.equal(attempts, 0, 'the retry timer went with the state'); }); - 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(); + test('keeps backing off when every link dies as soon as it comes up', async t => { + const clock = { now: 0 }; + const delays: number[] = []; + const log: SmppLog = { + ...silentLog, + info: (msg, metadata) => { + if (msg === 'reconnect - retrying') delays.push(Number(metadata?.delay)); + }, + }; + const life = new SessionLife({ + effects: { attach: () => undefined, emit: () => undefined, linkDown: () => undefined, linkUp: () => undefined, over: () => undefined }, + log, + now: () => clock.now, + reconnect: { + connect: () => Promise.resolve({ sock: new net.Socket() }), + maxDelay: 80, + minDelay: 10, + rebind: () => { life.bound(); return Promise.resolve({}); }, + }, + }); + + t.after(() => { life.end(); }); + life.bound(); + + for (let died = 0; died < 4; died++) { + life.linkLost(); + assert.ok(await waitFor(() => life.state === 'bound')); + } + + assert.deepEqual(delays, [10, 20, 40, 80]); - 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'); + // A link that outlasted the longest wait earned a fresh start. + clock.now += 80; + life.linkLost(); + assert.ok(await waitFor(() => delays.length === 5)); + assert.deepEqual(delays, [10, 20, 40, 80, 10]); }); - test('releases a held request with the reason once the link ends', async () => { - const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 0 }); + test('starts only one reconnect attempt at a time', async t => { + let attempts = 0; + let finish: (() => void) | undefined; + const { life } = lifeWith({ + reconnect: { + connect: () => { + attempts++; + + return new Promise(resolve => { + finish = () => { resolve({ err: new Error('no socket') }); }; + }); + }, + maxDelay: 5, + minDelay: 1, + rebind: () => Promise.resolve({}), + }, + }); - link.drop(); + t.after(() => { life.end(); finish?.(); }); + life.bound(); + life.linkLost(); + assert.ok(await waitFor(() => attempts === 1)); - const held = link.hold(undefined)(); + // A second drop landing while the first attempt is still inside connect(). + life.linkLost(); + await delay(30); + assert.equal(attempts, 1); + }); - link.end(); + test('keeps the reconnect loop alive when connect throws', async t => { + let attempts = 0; + const { life } = lifeWith({ + reconnect: { + connect: () => { + attempts++; - assert.match((await held).err?.message ?? '', /Session is closed/); - link.attach(); - assert.equal(link.isAttached(), false, 'ended is final'); - assert.equal(link.end(), false); + throw new Error('connect exploded'); + }, + maxDelay: 10, + minDelay: 1, + rebind: () => Promise.resolve({}), + }, + }); + + t.after(() => { life.end(); }); + life.bound(); + life.linkLost(); + + assert.ok(await waitFor(() => attempts >= 2), 'a throwing connect should be retried, not left for the process to die on'); }); }); @@ -1537,77 +1706,81 @@ describe('SendWindow', () => { }); // 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)]; - } +describe('handled message bounds', () => { + type Handled = { handled: HandledMessages; release: (sms: Sms) => void; reported: Error[]; running: Sms[] }; - function offer(held: HeldMessages, seqNr: number): MessageHold { - const hold = held.offer(message(seqNr)); + /** Handlers that stay in the message until released, so an offer is counted until the test says otherwise. */ + function handledOn( + t: TestContext, + options: Pick, + onSms?: OnSms, + ): Handled { + const releases = new Map void>(); + const reported: Error[] = []; + const running: Sms[] = []; + const handled = new HandledMessages({ + ...options, + log: silentLog, + onSms: onSms ?? (sms => new Promise(done => { + running.push(sms); + releases.set(sms, done); + })), + report: err => { reported.push(err); }, + }); - assert.ok(hold); + t.after(() => { handled.clear(); }); - return hold; + return { handled, release: sms => releases.get(sms)?.(), reported, running }; } - /** Offers to a session with a listener, so an offer is held rather than released as untaken. */ - function heldOn( - t: TestContext, - options: Pick, - ): HeldMessages { + const answers: SmsHandlers = { + answer: () => ({}), + send: () => Promise.resolve({ err: new Error('never sent') }), + }; + + function offer(handled: HandledMessages, seqNr: number, t: TestContext): Sms { const session = new Session({ sock: new net.Socket() }); closeAfter(t, session); - session.on('sms', () => undefined); - return new HeldMessages({ - ...options, - link: new LinkLife({ log: silentLog, reconnects: false, timeout: 100 }), - log: silentLog, - sendPastDrain: () => Promise.resolve({ err: new Error('never sent') }), - session, - }); + return handled.offer({ link: session.sock, pduObjs: [submitPdu(seqNr)], session }, answers, 'ESME_RTHROTTLED'); } - 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); + test('is full at its count', t => { + const { handled } = handledOn(t, { max: 2, maxOctets: 1_000_000, timeout: 10_000 }); - offer(held, 2); + offer(handled, 1, t); + assert.equal(handled.refuses(), false); + offer(handled, 2, t); - 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); - - held.clear(); + assert.equal(handled.size, 2); + assert.equal(handled.octets, 2 * 1026, 'submitPdu() holds 1026 octets by the maxOctets charge'); + assert.equal(handled.refuses(), true); }); - // 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 message leaves by any way out', async t => { let now = 0; - const held = heldOn(t, { max: 10, maxOctets: 2000, now: () => now, timeout: 10_000 }); - const answered = offer(held, 1); + const { handled, release } = handledOn(t, { max: 10, maxOctets: 2000, now: () => now, timeout: 10_000 }); + const returned = offer(handled, 1, t); - assert.equal(held.full(), false); - offer(held, 2); - assert.equal(held.full(), true); + assert.equal(handled.refuses(), false); + offer(handled, 2, t); + assert.equal(handled.refuses(), true); - answered.release(); - assert.equal(held.full(), false, 'after a release'); - offer(held, 3); + release(returned); + await new Promise(resolve => { setImmediate(resolve); }); + assert.equal(handled.refuses(), false, 'after the handler returned'); + offer(handled, 3, t); now = 20_000; - held.sweep(); + handled.sweep(); now = 0; - assert.equal(held.full(), false, 'after a sweep'); - offer(held, 4); - offer(held, 5); + assert.equal(handled.refuses(), false, 'after a sweep'); + offer(handled, 4, t); + offer(handled, 5, t); - held.clear(); - assert.equal(held.full(), false, 'after a clear'); + handled.clear(); + assert.equal(handled.refuses(), false, 'after a clear'); }); // Dropping one the application still holds frees nothing, and the drain stops waiting for it. @@ -1617,44 +1790,50 @@ describe('held message bounds', () => { closeAfter(t, session); const warnings: string[] = []; - const incoming = incomingOn(session, { log: { ...silentLog, warn: message => { warnings.push(message); } } }); - const answers: (ErrorName | undefined)[] = []; + const releases: (() => void)[] = []; const received: Sms[] = []; + const answers: (ErrorName | undefined)[] = []; + const incoming = incomingOn(session, { + log: { ...silentLog, warn: message => { warnings.push(message); } }, + onSms: sms => new Promise(done => { + received.push(sms); + releases.push(done); + }), + }); 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++) { + for (let seqNr = 1; seqNr <= defaults.session.maxHandledMessages; seqNr++) { await incoming.handle(submitPdu(seqNr)); } - assert.equal(received.length, defaults.maxHeldMessages); + assert.equal(received.length, defaults.session.maxHandledMessages); - await incoming.handle(submitPdu(defaults.maxHeldMessages + 1)); + await incoming.handle(submitPdu(defaults.session.maxHandledMessages + 1)); await incoming.handle(segment(7, 1, 2)); - assert.equal(received.length, defaults.maxHeldMessages); + assert.equal(received.length, defaults.session.maxHandledMessages); 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. - await received[0]?.sendResp(); + releases[0]?.(); 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); + assert.equal(received.length, defaults.session.maxHandledMessages); // A peer keeping its window full crosses the bound on every answer, and that is still one warning. - await received[1]?.sendResp(); + releases[1]?.(); 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)); + await incoming.handle(submitPdu(defaults.session.maxHandledMessages + 2)); + await incoming.handle(submitPdu(defaults.session.maxHandledMessages + 3)); + await incoming.handle(submitPdu(defaults.session.maxHandledMessages + 4)); assert.equal(answers.at(-1), 'ESME_RTHROTTLED'); assert.equal(warnings.length, 1); @@ -1666,12 +1845,11 @@ describe('held message bounds', () => { closeAfter(t, session); - const incoming = incomingOn(session); + let received: Sms | undefined; + const incoming = incomingOn(session, { onSms: sms => { received = sms; } }); 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) } }); const retained = received?.pduObjs[0]?.params.short_message; @@ -1681,59 +1859,136 @@ describe('held message bounds', () => { incoming.clear(); }); - test('gives up on a message the application never answers', t => { + test('stops counting a handler still running past its deadline', t => { let now = 0; - const held = heldOn(t, { max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 }); + const { handled } = handledOn(t, { max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 }); - offer(held, 1); + offer(handled, 1, t); now = 61; // The next message sweeps the one that expired, so only the new one is still waited for. - offer(held, 2); + offer(handled, 2, t); - assert.equal(held.size, 1); - - held.clear(); + assert.equal(handled.size, 1); }); // 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 => { + test('wakes a waiting drain when the last handler passes its deadline', async t => { let now = 0; - const held = heldOn(t, { max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 }); + const { handled } = handledOn(t, { max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 }); - offer(held, 1); + offer(handled, 1, t); - const waiting = held.idle(1000, undefined); + const waiting = handled.idle(1000, undefined); now = 61; - held.sweep(); + handled.sweep(); assert.equal(await waiting, 0); }); + + test('answers ESME_ROK for a handler that returned without answering, and nothing for one that did', async t => { + const statuses: ErrorName[] = []; + const { handled } = handledOn(t, { max: 10, maxOctets: 1_000_000, timeout: 10_000 }, sms => { + if (sms.pduObjs[0]?.seqNr === 2) return sms.sendResp({ smsId: '0199f1a2-3b4c-7d5e-8f60-71a2b3c4d5e6' }); + + return undefined; + }); + const session = new Session({ sock: new net.Socket() }); + + closeAfter(t, session); + + const recording: SmsHandlers = { ...answers, answer: (_pduObj, status) => { statuses.push(status); return {}; } }; + const returned = handled.offer({ link: session.sock, pduObjs: [submitPdu(1)], session }, recording, 'ESME_RTHROTTLED'); + const answered = handled.offer({ link: session.sock, pduObjs: [submitPdu(2)], session }, recording, 'ESME_RTHROTTLED'); + + assert.equal(await handled.idle(1000, undefined), 0); + assert.deepEqual(statuses, ['ESME_ROK', 'ESME_ROK']); + assert.equal(returned.answered, true); + assert.match(returned.smsId, /^[0-9a-f]{8}-/, 'a generated id'); + assert.equal(answered.smsId, '0199f1a2-3b4c-7d5e-8f60-71a2b3c4d5e6'); + }); + + test('refuses with the retry status for a handler that failed before answering, and reports it', async t => { + const statuses: ErrorName[] = []; + const { handled, reported } = handledOn(t, { max: 10, maxOctets: 1_000_000, timeout: 10_000 }, () => { + throw new Error('the handler exploded'); + }); + const session = new Session({ sock: new net.Socket() }); + + closeAfter(t, session); + handled.offer( + { link: session.sock, pduObjs: [submitPdu(1)], session }, + { ...answers, answer: (_pduObj, status) => { statuses.push(status); return {}; } }, + 'ESME_RX_T_APPN', + ); + + assert.equal(await handled.idle(1000, undefined), 0); + assert.deepEqual(statuses, ['ESME_RX_T_APPN']); + assert.deepEqual(reported.map(err => err.message), ['the handler exploded']); + }); + + test('refuses a message no handler takes, and reports it', async t => { + const statuses: ErrorName[] = []; + const reported: Error[] = []; + const handled = new HandledMessages({ + log: silentLog, + max: 10, + maxOctets: 1_000_000, + onSms: undefined, + report: err => { reported.push(err); }, + timeout: 10_000, + }); + const session = new Session({ sock: new net.Socket() }); + + closeAfter(t, session); + handled.offer( + { link: session.sock, pduObjs: [submitPdu(1)], session }, + { ...answers, answer: (_pduObj, status) => { statuses.push(status); return {}; } }, + 'ESME_RTHROTTLED', + ); + + assert.equal(await handled.idle(1000, undefined), 0); + assert.deepEqual(statuses, ['ESME_RTHROTTLED']); + assert.match(reported[0]?.message ?? '', /No onSms handler/); + }); }); describe('sendResp()', () => { + const handlers: SmsHandlers = { + answer: () => ({ err: new Error('Socket is closed') }), + send: () => Promise.resolve({ err: new Error('never sent') }), + }; + // 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() }); closeAfter(t, session); - let answered = 0; + const sms = createSms({ link: session.sock, pduObjs: [submitPdu(1)], session }, handlers); - session.sendReturn = () => Promise.resolve({ err: new Error('Socket is closed') }); + assert.match((await sms.sendResp()).err?.message ?? '', /Socket is closed/); + assert.equal(sms.answered, false); + }); - const sms = createSms({ - pduObjs: [submitPdu(1)], - session, - }, { - answered: () => { answered++; }, - lostLink: () => false, - send: () => Promise.resolve({ err: new Error('never sent') }), + test('answers once, and refuses to change an answer already given', async t => { + const session = new Session({ sock: new net.Socket() }); + const statuses: ErrorName[] = []; + + closeAfter(t, session); + + const sms = createSms({ link: session.sock, pduObjs: [submitPdu(1)], session }, { + ...handlers, + answer: (_pduObj, status) => { statuses.push(status); return {}; }, }); - assert.match((await sms.sendResp()).err?.message ?? '', /Socket is closed/); - assert.equal(answered, 0); + assert.deepEqual(await sms.sendResp({ smsId: '0199f1b0-0c1d-7e2f-9a3b-4c5d6e7f8091', status: 'ESME_RMSGQFUL' }), {}); + assert.equal(sms.answered, true); + assert.deepEqual(await sms.sendResp({ status: 'ESME_RMSGQFUL' }), {}, 'the same answer again is nothing to do'); + assert.match((await sms.sendResp()).err?.message ?? '', /already answered ESME_RMSGQFUL/); + assert.match((await sms.sendResp({ smsId: 'another' })).err?.message ?? '', /already answered under id/); + assert.deepEqual(statuses, ['ESME_RMSGQFUL']); }); }); @@ -1746,11 +2001,11 @@ describe('sendDlr()', () => { let call = 0; const sms = createSms({ + link: session.sock, pduObjs: [submitPdu(1), submitPdu(2), submitPdu(3)], session, }, { - answered: () => undefined, - lostLink: () => false, + answer: () => ({}), send: () => { call++; @@ -2351,7 +2606,7 @@ describe('a peer that sends the next segment only once the last one is answered' params: submitSmParams( { from: '46701113311', message: text, to: '46709771337' }, segment, - { encoding: 'ASCII', multipart: true }, + { encoding: 'GSM7', multipart: true }, ), }); @@ -2372,14 +2627,10 @@ describe('a peer that sends the next segment only once the last one is answered' } test('gets every segment answered as it arrives, and the application one whole message', async t => { - const smpp = await startServer(t); + const box = inbox(); const messages: Sms[] = []; - const incoming = once(resolve => { - smpp.on('session', bound => bound.on('sms', sms => { - messages.push(sms); - resolve(sms); - })); - }); + const smpp = await startServer(t, { onSms: sms => { messages.push(sms); return box.onSms(sms); } }); + const incoming = box.next(); const { session } = await connect(t, smpp, { responseTimeout: 1000 }); assert.ok(session); @@ -2390,7 +2641,7 @@ describe('a peer that sends the next segment only once the last one is answered' assert.equal(sms.message, text); assert.equal(sms.pduObjs.length, answers.length); assert.equal(messages.length, 1, 'the application sees one message, not one per segment'); - assert.equal(sms.answeredOnArrival, true); + assert.equal(sms.answered, true); assert.deepEqual(answers.map(answer => answer.cmdStatus), answers.map(() => 'ESME_ROK')); assert.deepEqual( answers.map(answer => paramText(answer.params.message_id)), @@ -2401,10 +2652,9 @@ describe('a peer that sends the next segment only once the last one is answered' // 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(resolve => { - smpp.on('session', bound => bound.on('sms', resolve)); - }); + const box = inbox(); + const smpp = await startServer(t, { onSms: box.onSms }); + const incoming = box.next(); const { session } = await connect(t, smpp); assert.ok(session); @@ -2420,7 +2670,7 @@ describe('a peer that sends the next segment only once the last one is answered' const sms = await incoming; assert.equal(await within(150, submitted), undefined, 'nothing may answer for the application'); - assert.equal(sms.answeredOnArrival, false); + assert.equal(sms.answered, false); assert.deepEqual(await sms.sendResp({ smsId: '0199e0e9-4a3e-7c62-9a4b-1f0c5d7e8a21' }), {}); const answered = await submitted; @@ -2433,10 +2683,9 @@ describe('a peer that sends the next segment only once the last one is answered' }); test('refuses an id and a refusing status for segments already on the wire', async t => { - const smpp = await startServer(t); - const incoming = once(resolve => { - smpp.on('session', bound => bound.on('sms', resolve)); - }); + const box = inbox(); + const smpp = await startServer(t, { onSms: box.onSms }); + const incoming = box.next(); const { session } = await connect(t, smpp, { responseTimeout: 1000 }); assert.ok(session); @@ -2447,20 +2696,17 @@ describe('a peer that sends the next segment only once the last one is answered' const named = await sms.sendResp({ smsId: '0199e0ea-1f3d-7ab4-8c21-6d4e5f0a9b73' }); const refused = await sms.sendResp({ status: 'ESME_RMSGQFUL' }); - assert.match(named.err?.message ?? '', /fixed when its first segment arrived/); + assert.match(named.err?.message ?? '', /already answered under id/); assert.match(refused.err?.message ?? '', /onRequest/); assert.deepEqual(await sms.sendResp({ status: 'ESME_ROK' }), {}); - assert.equal(sms.answeredOnArrival, true); + assert.equal(sms.answered, true); }); 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 smpp = await startServer(t, { onSms: sms => { messages.push(sms); }, reassemblyTimeout: 60 }); const lost = once(resolve => { - smpp.on('session', bound => { - bound.on('sessionError', resolve); - bound.on('sms', sms => { messages.push(sms); }); - }); + smpp.on('session', bound => { bound.on('sessionError', resolve); }); }); const { session } = await connect(t, smpp, { responseTimeout: 1000 }); @@ -2500,7 +2746,7 @@ describe('a peer that sends the next segment only once the last one is answered' params: submitSmParams( { from: '46701113311', message: text, to: '46709771337' }, first, - { encoding: 'ASCII', multipart: true }, + { encoding: 'GSM7', multipart: true }, ), }); @@ -2512,10 +2758,9 @@ describe('a peer that sends the next segment only once the last one is answered' }); 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(resolve => { - smpp.on('session', bound => bound.on('sms', resolve)); - }); + const box = inbox(); + const smpp = await startServer(t, { onSms: box.onSms, shutdownTimeout: 50 }); + const incoming = box.next(); const { session } = await connect(t, smpp, { responseTimeout: 1000 }); assert.ok(session); @@ -2526,15 +2771,14 @@ describe('a peer that sends the next segment only once the last one is answered' const closed = await peerOf(smpp).close(); assert.ok(closed.err instanceof Error); - assert.match(closed.err.message, /1 message\(s\) unanswered/); + assert.match(closed.err.message, /1 message\(s\) still being handled/); }); - // pduObjs.length is 1 either way here, so answeredOnArrival is the only thing that can say. + // pduObjs.length is 1 either way here, so answered 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(resolve => { - smpp.on('session', bound => bound.on('sms', resolve)); - }); + const box = inbox(); + const smpp = await startServer(t, { onSms: box.onSms }); + const incoming = box.next(); const { session } = await connect(t, smpp, { responseTimeout: 1000 }); assert.ok(session); @@ -2558,17 +2802,14 @@ describe('a peer that sends the next segment only once the last one is answered' assert.equal(answered.pduObj.cmdStatus, 'ESME_ROK'); assert.equal(paramText(answered.pduObj.params.message_id), sms.smsId); assert.equal(sms.pduObjs.length, 1); - assert.equal(sms.answeredOnArrival, true); + assert.equal(sms.answered, true); assert.deepEqual(await sms.sendResp(), {}); }); // 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 smpp = await startServer(t, { onSms: sms => { messages.push(sms); } }); const { session } = await connect(t, smpp, { responseTimeout: 1000 }); assert.ok(session); @@ -2594,11 +2835,8 @@ 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 smpp = await startServer(t, { onSms: sms => { messages.push(sms); } }); const { session } = await connect(t, smpp, { responseTimeout: 1000 }); assert.ok(session); @@ -2662,13 +2900,10 @@ describe('AbortSignal on a send', () => { t: TestContext, options: Parameters[0] = {}, ): Promise { - const smpp = await startServer(t); + const smpp = await startServer(t, { onSms: stuck }); const onWire = once(resolve => { smpp.on('session', bound => bound.on('incomingPduObj', resolve)); }); - - smpp.on('session', bound => bound.on('sms', () => undefined)); - const { session } = await connect(t, smpp, { maxOutstanding: 1, ...options }); assert.ok(session); @@ -2716,18 +2951,18 @@ 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(resolve => { smpp.on('session', bound => bound.on('sms', resolve)); }); + const box = inbox(); 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; - }); - }); + return box.onSms(sms); + }, + }); + const holding = box.next(); const { session } = await connect(t, smpp, { maxOutstanding: 1, responseTimeout: 10_000 }); assert.ok(session); @@ -2748,7 +2983,7 @@ describe('AbortSignal on a send', () => { assert.ok(gaveUp, 'the waiter that gave up must settle before the slot it left is freed'); // 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(); + await box.answer(held); const following = await within(1000, session.sendSms({ from: '46701113311', @@ -2768,17 +3003,15 @@ describe('graceful shutdown', () => { serverOptions: Parameters[0] = {}, message = 'answer me', ) { - const smpp = await startServer(t, serverOptions); - const incoming = once(resolve => { - smpp.on('session', bound => bound.on('sms', resolve)); - }); + const box = inbox(); + const smpp = await startServer(t, { onSms: box.onSms, ...serverOptions }); const { session } = await connect(t, smpp, options); assert.ok(session); const sent = session.sendSms({ from: '46701113311', message, to: '46709771337' }); - return { sent, session, sms: await incoming, smpp }; + return { box, sent, session, sms: await box.next(), smpp }; } test('close() waits out a submit already on the wire and refuses new ones', async t => { @@ -2824,43 +3057,42 @@ describe('graceful shutdown', () => { 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 still running, and ends once it returns', async t => { + const { box, sent, smpp, sms } = await submitInFlight(t); const closing = peerOf(smpp).close(); await delay(50); - await sms.sendResp({ smsId: 'answered-during-the-inbound-drain' }); + await box.answer(sms, { smsId: 'answered-during-the-inbound-drain' }); 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(); assert.ok(closed.err instanceof Error); - assert.match(closed.err.message, /1 message\(s\) unanswered/); + assert.match(closed.err.message, /1 message\(s\) still being handled/); }); - // 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 }); + test('waits for a handler as long as it runs when the shutdown waits forever', async t => { + const { box, smpp, sms } = await submitInFlight(t, {}, { shutdownTimeout: 0 }); const started = Date.now(); - const closed = await peerOf(smpp).close(); - const waited = Date.now() - started; + const closing = peerOf(smpp).close(); - 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); + await delay(150); + await box.answer(sms); + + assert.deepEqual(await closing, {}); + assert.ok(Date.now() - started >= 140, 'nothing but the handler returning ended the wait'); }); // 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 { smpp } = await submitInFlight(t, { onSms: stuck }, { shutdownTimeout: 100 }); const bound = peerOf(smpp); - // The client listens for no 'sms', so this one is never answered and stays in the window. + // The client's handler never returns, so this one is never answered and stays in the window. const unanswered = bound.send({ cmdName: 'submit_sm', params: { @@ -2876,14 +3108,13 @@ describe('graceful shutdown', () => { }), ]); - assert.match(closed.err?.message ?? '', /1 message\(s\) unanswered; .*1 request\(s\) unfinished/); + assert.match(closed.err?.message ?? '', /1 message\(s\) still being handled; .*1 request\(s\) unfinished/); assert.ok((await unanswered).err instanceof Error); }); - // 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. - 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)); + // Multipart, because a receipt has one deliver_sm per segment and every one has to pass the drain. + test('a receipt sent from a running handler still goes out mid-drain', async t => { + const { box, sent, session, smpp, sms } = await submitInFlight(t, {}, {}, 'x'.repeat(400)); const received: Dlr[] = []; const receipts = once(resolve => { session.on('dlr', dlr => { @@ -2901,102 +3132,92 @@ describe('graceful shutdown', () => { assert.equal(receiptSent.err, undefined); assert.deepEqual((await receipts).map(dlr => dlr.smsId), ids); + box.release(sms); 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 takes is refused at once, and holds 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(resolve => { - bound.on('incomingPduObj', pduObj => { - if (pduObj.cmdName === 'submit_sm') resolve(pduObj); - }); - }); - const sent = session.sendSms({ + const reported = once(resolve => { bound.on('sessionError', resolve); }); + const sent = await session.sendSms({ from: '46701113311', message: 'nobody is listening', to: '46709771337', }); - - await arrived; - await delay(50); - const started = Date.now(); + assert.match(sent.err?.message ?? '', /ESME_RTHROTTLED/, 'the peer keeps the message'); + assert.match((await reported).message, /No onSms handler/); assert.deepEqual(await bound.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 has the message refused for it, and holds nothing', async t => { + const smpp = await startServer(t, { + onSms: () => Promise.reject(new Error('the handler gave up')), + shutdownTimeout: 30_000, + }); const failed = once(resolve => { - smpp.on('session', bound => { - bound.on('sessionError', resolve); - bound.on('sms', () => Promise.reject(new Error('the listener gave up'))); - }); + smpp.on('session', bound => { bound.on('sessionError', resolve); }); }); const { session } = await connect(t, smpp); assert.ok(session); - const sent = session.sendSms({ + const sent = await session.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(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'))); - }); + test('close() keeps waiting for a handler that answered and is still working', async t => { + const smpp = await startServer(t, { + onSms: async sms => { + await sms.sendResp({ smsId: 'answered-then-kept-working' }); + await delay(100); + }, }); const { session } = await connect(t, smpp); assert.ok(session); - const sent = session.sendSms({ + const sent = await session.sendSms({ from: '46701113311', - message: 'two listeners, one gives up', + message: 'answered early, finished late', to: '46709771337', }); + const started = Date.now(); - assert.equal((await failed).message, 'the audit listener gave up'); + assert.deepEqual(sent.smsIds, ['answered-then-kept-working']); assert.deepEqual(await peerOf(smpp).close(), {}); - assert.deepEqual((await sent).smsIds, ['answered-after-the-other-gave-up']); + assert.ok(Date.now() - started >= 50, 'the answer is not what ends the wait; the handler is'); }); // Nothing reached the peer, so a drain counting this answered would report an outcome that never was. - test('leaves a message the library refused to answer unanswered', async t => { + test('keeps waiting on a handler whose answer the library refused', async t => { const { sms, smpp } = await submitInFlight(t, {}, { shutdownTimeout: 50 }); const refused = await sms.sendResp({ smsId: '' }); const closed = await peerOf(smpp).close(); assert.match(refused.err?.message ?? '', /smsId must not be empty/); + assert.equal(sms.answered, false); assert.ok(closed.err instanceof Error); - assert.match(closed.err.message, /1 message\(s\) unanswered/); + assert.match(closed.err.message, /1 message\(s\) still being handled/); }); test('gives up on a request that outlasts shutdownTimeout', async t => { @@ -3031,8 +3252,8 @@ 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); + // A handler that never returns, so the single-segment message holding the only slot is never answered. + const smpp = await startServer(t, { onSms: stuck }); const onWire = once(resolve => { smpp.on('session', bound => bound.on('incomingPduObj', resolve)); }); @@ -3174,6 +3395,7 @@ describe('graceful shutdown', () => { await delay(50); assert.deepEqual(reported, []); + assert.equal(session.state, 'ended'); }); test('answers a peer\'s unbind before asking the session to end', async t => { @@ -3208,12 +3430,7 @@ describe('message id notation', () => { } 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 smpp = await startServer(t, { onSms: sms => sms.sendResp({ smsId: '1a2b' }) }); const { session } = await connect(t, smpp, { smsIdFormat: { receipt: 'decimal', submitResp: 'hex' }, }); @@ -3238,13 +3455,9 @@ 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(resolve => { - smpp.on('session', bound => bound.on('sms', sms => { - resolve(sms); - void sms.sendResp(); - })); - }); + const box = inbox(); + const smpp = await startServer(t, { onSms: box.onSms }); + const incoming = box.next(); const { session } = await connect(t, smpp, { smsIdFormat: { receipt: 'decimal', submitResp: 'hex' }, }); diff --git a/test/session.test.ts b/test/session.test.ts index 1f31f0b..1119c8f 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -3,14 +3,15 @@ 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 { Sms } from '../src/sms.ts'; +import type { ClientOptions } from '../src/client.ts'; +import type { OnSms } from '../src/session-options.ts'; +import type { SendRespOptions, Sms } from '../src/sms.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 { client } from '../src/client.ts'; @@ -53,6 +54,53 @@ function once(register: (resolve: (value: T) => void) => void): Promise { return new Promise(resolve => { register(resolve); }); } +type Inbox = { + /** Answers the message and lets its handler return. */ + answer: (sms: Sms, options?: SendRespOptions) => Promise; + /** The next message handed over; its handler runs until the test answers or releases it. */ + next: () => Promise; + onSms: OnSms; + release: (sms: Sms) => void; +}; + +/** An application that hands each message to the test and stays in the handler until told it is done. */ +function inbox(): Inbox { + const queued: Sms[] = []; + const waiting: ((sms: Sms) => void)[] = []; + const releases = new Map void>(); + const release = (sms: Sms): void => { + releases.get(sms)?.(); + releases.delete(sms); + }; + + return { + answer: async (sms, options) => { + const answered = await sms.sendResp(options); + + release(sms); + + return answered; + }, + next: () => { + const sms = queued.shift(); + + return sms ? Promise.resolve(sms) : once(resolve => waiting.push(resolve)); + }, + onSms: sms => new Promise(done => { + releases.set(sms, done); + + const next = waiting.shift(); + + if (next) next(sms); + else queued.push(sms); + }), + release, + }; +} + +/** A handler that never returns, for a message that must stay held. */ +const stuck: OnSms = () => new Promise(() => undefined); + function delay(ms: number): Promise { return new Promise(resolve => { setTimeout(resolve, ms); }); } @@ -507,10 +555,8 @@ describe('bind direction', () => { }); test('refuses sendSms() on a receiver-bound session before it reaches the wire', async t => { - const smpp = await startServer(t); const arrived: Sms[] = []; - - smpp.on('session', peer => peer.on('sms', sms => arrived.push(sms))); + const smpp = await startServer(t, { onSms: sms => arrived.push(sms) }); const { session } = await connect(t, smpp, { bindType: 'receiver' }); @@ -542,10 +588,9 @@ 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(resolve => { - smpp.on('session', peer => peer.on('sms', resolve)); - }); + const box = inbox(); + const smpp = await startServer(t, { onSms: box.onSms }); + const incoming = box.next(); const { session } = await connect(t, smpp, { bindType: 'transmitter' }); assert.ok(session); @@ -585,10 +630,7 @@ 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); @@ -619,10 +661,9 @@ 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(resolve => { - smpp.on('session', session => session.on('sms', resolve)); - }); + const box = inbox(); + const smpp = await startServer(t, { onSms: box.onSms }); + const incoming = box.next(); const { session } = await connect(t, smpp); assert.ok(session); @@ -656,11 +697,10 @@ describe('sending', () => { }); test('reassembles a long SMS and answers every segment', async t => { - const smpp = await startServer(t); + const box = inbox(); + const smpp = await startServer(t, { onSms: box.onSms }); const message = 'Lorem ipsum dolor sit amet, '.repeat(20); - const incoming = once(resolve => { - smpp.on('session', session => session.on('sms', resolve)); - }); + const incoming = box.next(); const { session } = await connect(t, smpp); assert.ok(session); @@ -682,11 +722,10 @@ describe('sending', () => { }); test('carries a UCS2 message through unchanged', async t => { - const smpp = await startServer(t); + const box = inbox(); + const smpp = await startServer(t, { onSms: box.onSms }); const message = 'räksmörgås تست 一'; - const incoming = once(resolve => { - smpp.on('session', session => session.on('sms', resolve)); - }); + const incoming = box.next(); const { session } = await connect(t, smpp); assert.ok(session); @@ -705,10 +744,9 @@ describe('sending', () => { }); test('marks a flash message without losing the UCS2 alphabet', async t => { - const smpp = await startServer(t); - const incoming = once(resolve => { - smpp.on('session', session => session.on('sms', resolve)); - }); + const box = inbox(); + const smpp = await startServer(t, { onSms: box.onSms }); + const incoming = box.next(); const { session } = await connect(t, smpp); assert.ok(session); @@ -729,10 +767,9 @@ 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(resolve => { - smpp.on('session', peer => peer.on('sms', resolve)); - }); + const box = inbox(); + const smpp = await startServer(t, { onSms: box.onSms }); + const incoming = box.next(); const { session } = await connect(t, smpp); assert.ok(session); @@ -768,11 +805,12 @@ describe('receiving', () => { async function inbound( t: TestContext, options: ServerOptions = {}, + clientOptions: ClientOptions = {}, ): Promise<{ peer: Session; session: Session }> { const smpp = await startServer(t, options); const bound = once(resolve => { smpp.on('session', resolve); }); - const { session } = await connect(t, smpp); + const { session } = await connect(t, smpp, clientOptions); assert.ok(session); @@ -780,8 +818,9 @@ describe('receiving', () => { } test('hands a client a deliver_sm that is not a delivery receipt', async t => { - const { peer, session } = await inbound(t); - const incoming = once(resolve => { session.on('sms', resolve); }); + const box = inbox(); + const { peer } = await inbound(t, {}, { onSms: box.onSms }); + const incoming = box.next(); const delivered = peer.send({ cmdName: 'deliver_sm', params: { @@ -813,8 +852,9 @@ describe('receiving', () => { // 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(resolve => { session.on('sms', resolve); }); + const box = inbox(); + const { peer } = await inbound(t, {}, { onSms: box.onSms }); + const incoming = box.next(); const text = 'the whole body, carried in the TLV'; const delivered = peer.send({ cmdName: 'deliver_sm', @@ -841,8 +881,9 @@ 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(resolve => { session.on('sms', resolve); }); + const box = inbox(); + const { peer } = await inbound(t, {}, { onSms: box.onSms }); + const incoming = box.next(); const smsId = '0199e0f1-6c31-7a44-9d02-4b7e51c3a806'; const delivered = peer.send({ cmdName: 'data_sm', @@ -867,12 +908,10 @@ describe('receiving', () => { }); test('hands a client a receipt carried on data_sm as a dlr', async t => { - const { peer, session } = await inbound(t); + let messages = 0; + const { peer, session } = await inbound(t, {}, { onSms: () => { messages++; } }); const reported = once(resolve => { session.on('dlr', resolve); }); const smsId = '0199e0f1-b8a2-7f19-8c63-2d5041fb9e77'; - let messages = 0; - - session.on('sms', () => { messages++; }); const delivered = peer.send({ cmdName: 'data_sm', @@ -903,18 +942,12 @@ describe('receiving', () => { // 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(); - }); - }); + smpp.on('session', peer => { peer.on('dlr', dlr => { reports.push(dlr); }); }); const { session } = await connect(t, smpp, { bindType: 'transmitter' }); @@ -965,8 +998,9 @@ describe('receiving', () => { }); test('reassembles a concatenated message whose segments arrived in message_payload', async t => { - const { peer, session } = await inbound(t); - const incoming = once(resolve => { session.on('sms', resolve); }); + const box = inbox(); + const { peer } = await inbound(t, {}, { onSms: box.onSms }); + const incoming = box.next(); const text = 'A body in the TLV is still numbered by its UDH. '.repeat(6); const segments = splitMessage(text, { reference: 0x3B }); @@ -994,7 +1028,7 @@ describe('receiving', () => { assert.ok(sms, 'the segments join into one message wherever their bodies were carried'); assert.equal(sms.message, text); - assert.equal(sms.answeredOnArrival, true); + assert.equal(sms.answered, true); assert.deepEqual(answers.map(answer => answer.cmdStatus), answers.map(() => 'ESME_ROK')); assert.deepEqual( answers.map(answer => paramText(answer.params.message_id)), @@ -1005,11 +1039,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(resolve => { session.on('dlr', resolve); }); let messages = 0; - - session.on('sms', () => { messages++; }); + const { peer, session } = await inbound(t, {}, { onSms: () => { messages++; } }); + const reported = once(resolve => { session.on('dlr', resolve); }); const delivered = peer.send({ cmdName: 'deliver_sm', @@ -1085,8 +1117,9 @@ describe('receiving', () => { test('reassembles a multipart inbound SMS before the sms event', async t => { const message = 'Inbound lorem ipsum dolor sit amet consectetur, '.repeat(6); - const { peer, session } = await inbound(t); - const incoming = once(resolve => { session.on('sms', resolve); }); + const box = inbox(); + const { peer } = await inbound(t, {}, { onSms: box.onSms }); + const incoming = box.next(); const segments = splitMessage(message, { reference: 42 }); assert.equal(segments.length, 2); @@ -1124,8 +1157,9 @@ 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(resolve => { smpp.on('session', peer => peer.on('sms', resolve)); }); + const box = inbox(); + const smpp = await startServer(t, { onSms: box.onSms }); + const incoming = box.next(); const { session } = await connect(t, smpp, { bindType: 'transmitter', responseTimeout: 1000 }); assert.ok(session); @@ -1153,7 +1187,7 @@ describe('receiving', () => { assert.ok(sms, 'the sar_* TLVs tie the three submissions into one message'); assert.equal(sms.message, parts.join('')); - assert.equal(sms.answeredOnArrival, true); + assert.equal(sms.answered, true); assert.deepEqual( answers.map(answer => paramText(answer.params.message_id)), [1, 2, 3].map(part => `${sms.smsId}-${String(part)}`), @@ -1161,8 +1195,9 @@ 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(resolve => { session.on('sms', resolve); }); + const box = inbox(); + const { peer } = await inbound(t, { responseTimeout: 1000 }, { onSms: box.onSms }); + const incoming = box.next(); const parts = ['first ', 'second ', 'third']; for (const index of [2, 0, 1]) { @@ -1187,8 +1222,9 @@ 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(resolve => { session.on('sms', resolve); }); + const box = inbox(); + const { peer } = await inbound(t, { responseTimeout: 1000 }, { onSms: box.onSms }); + const incoming = box.next(); const parts = ['in the mandatory field, ', 'and in the TLV']; for (const [index, part] of parts.entries()) { @@ -1218,15 +1254,13 @@ describe('receiving', () => { assert.ok(sms, 'a segment carries its body where any other message may carry one'); assert.equal(sms.message, parts.join('')); - assert.equal(sms.answeredOnArrival, true); + assert.equal(sms.answered, true); }); // 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 }, { onSms: sms => { messages.push(sms); } }); const udhText = 'the message numbered by its user data header. '.repeat(5); const udhSegments = splitMessage(udhText, { reference: 5 }); @@ -1271,8 +1305,9 @@ 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(resolve => { session.on('sms', resolve); }); + const box = inbox(); + const { peer } = await inbound(t, { responseTimeout: 1000 }, { onSms: box.onSms }); + const incoming = box.next(); const message = 'both spellings on every segment of it, and the UDH decides. '.repeat(4); const segments = splitMessage(message, { reference: 7 }); @@ -1301,12 +1336,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 { peer, session } = await inbound(t, { responseTimeout: 1000 }, { onSms: () => { messages++; } }); + const reports: Dlr[] = []; session.on('dlr', dlr => { reports.push(dlr); }); - session.on('sms', () => { messages++; }); const marked = '0199e1a4-6c3f-7d21-9a80-5b1e2f7c4d63'; const unmarked = '0199e1a4-b70e-7c55-8f42-9d3a1c86e70b'; @@ -1350,10 +1384,9 @@ describe('receiving', () => { describe('delivery reports', () => { test('reaches the sender as a dlr event', async t => { - const smpp = await startServer(t); - const incoming = once(resolve => { - smpp.on('session', session => session.on('sms', resolve)); - }); + const box = inbox(); + const smpp = await startServer(t, { onSms: box.onSms }); + const incoming = box.next(); const { session } = await connect(t, smpp); assert.ok(session); @@ -1383,10 +1416,9 @@ describe('delivery reports', () => { }); test('reports a failure with the spec status code', async t => { - const smpp = await startServer(t); - const incoming = once(resolve => { - smpp.on('session', session => session.on('sms', resolve)); - }); + const box = inbox(); + const smpp = await startServer(t, { onSms: box.onSms }); + const incoming = box.next(); const { session } = await connect(t, smpp); assert.ok(session); @@ -1417,10 +1449,9 @@ 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(resolve => { - smpp.on('session', session => session.on('sms', resolve)); - }); + const box = inbox(); + const smpp = await startServer(t, { onSms: box.onSms }); + const incoming = box.next(); const peer = rawPeer(t, smpp.port); peer.write(bindOf(0x33)); @@ -1454,10 +1485,9 @@ describe('delivery reports', () => { }); test('merges nothing for a message that asked for no receipt', async t => { - const smpp = await startServer(t); - const incoming = once(resolve => { - smpp.on('session', session => session.on('sms', resolve)); - }); + const box = inbox(); + const smpp = await startServer(t, { onSms: box.onSms }); + const incoming = box.next(); const { session } = await connect(t, smpp); assert.ok(session); @@ -1497,10 +1527,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 { - const smpp = await startServer(t); - const incoming = once(resolve => { - smpp.on('session', session => session.on('sms', resolve)); - }); + const box = inbox(); + const smpp = await startServer(t, { onSms: box.onSms }); + const incoming = box.next(); const sock = net.connect({ port: smpp.port }, () => { sock.write(Buffer.from('0000002100000009000000000000002f666f6f0062617200736d70700034000000', 'hex')); @@ -1598,19 +1627,15 @@ describe('robustness', () => { }); 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 { session } = await connect(t, smpp, { maxOutstanding: 2 }); @@ -1653,12 +1678,7 @@ describe('robustness', () => { }); 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 smpp = await startServer(t, { onSms: () => undefined }); const { session } = await connect(t, smpp, { reconnect: { maxDelay: 50, minDelay: 10 } }); assert.ok(session); @@ -1731,10 +1751,8 @@ 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 smpp = await startServer(t, { onSms: stuck }); const { session } = await connect(t, smpp, { maxOutstanding: 1, responseTimeout: 5000 }); @@ -1756,45 +1774,6 @@ 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 { - if (opened.length === 1) return Promise.resolve({ err: new Error('bind refused') }); - - throw new Error('bind exploded'); - } - - const loop = new ReconnectLoop({ - connect: () => { - const sock = new net.Socket(); - - opened.push(sock); - - return Promise.resolve({ sock }); - }, - log: silentLog, - maxDelay: 10, - minDelay: 1, - onConnected, - }); - - t.after(() => { - loop.stop(); - - for (const sock of opened) { - sock.destroy(); - } - }); - loop.schedule(); - - const destroyed = await waitFor(() => opened.length >= 2 - && opened[0]?.destroyed === true - && opened[1]?.destroyed === true); - - assert.ok(destroyed, 'a failed setup should leave no socket open'); - }); }); describe('a PDU the codec cannot read', () => { @@ -1972,13 +1951,10 @@ 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 session error, and refuses its message', async t => { + const smpp = await startServer(t, { onSms: () => { throw new Error('handler exploded'); } }); const failed = once(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 }); @@ -1992,17 +1968,17 @@ describe('application hooks that throw or reject', () => { 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/, 'a handler that failed decided nothing, so the peer keeps the message'); + 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. + // The guard for a failing handler 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 }); @@ -2018,18 +1994,17 @@ describe('application hooks that throw or reject', () => { 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 onSms handler threw into a session error', async t => { const reason: unknown = null; - const failed = once(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 failed = once(resolve => { + smpp.on('session', session => { session.on('sessionError', resolve); }); }); const { session } = await connect(t, smpp, { responseTimeout: 200 }); @@ -2042,17 +2017,16 @@ describe('application hooks that throw or reject', () => { }); 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, 'a handler that answered and then failed leaves its answer alone'); + 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 }); @@ -2099,11 +2073,7 @@ 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 smpp = await startServer(t, { log, onSms: () => undefined }); const { session } = await connect(t, smpp, { log }); @@ -2130,12 +2100,7 @@ describe('application hooks that throw or reject', () => { }); 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: sms => sms.sendResp({ status: 'ESME_RMSGQFUL' }) }); const peer = rawPeer(t, smpp.port); peer.write(bindOf(0x34)); @@ -2158,103 +2123,40 @@ describe('application hooks that throw or reject', () => { assert.deepEqual(refused.params, {}); }); - test('keeps the reconnect loop alive when connect throws', async t => { - let attempts = 0; - const loop = new ReconnectLoop({ - connect: () => { - attempts++; - - throw new Error('connect exploded'); - }, - log: silentLog, - maxDelay: 10, - minDelay: 1, - onConnected: () => Promise.resolve({}), - }); - - t.after(() => { loop.stop(); }); - loop.schedule(); - - const retried = await waitFor(() => attempts >= 2); - - assert.ok(retried, 'a throwing connect 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 => { - const clock = { now: 0 }; - const delays: number[] = []; - const noop = (): void => undefined; - const log: SmppLog = { - debug: noop, - error: noop, - info: (msg, metadata) => { - if (msg === 'reconnect - retrying') delays.push(Number(metadata?.delay)); - }, - verbose: noop, - warn: noop, - }; - let up = 0; - const loop = new ReconnectLoop({ - connect: () => Promise.resolve({ sock: new net.Socket() }), - log, - maxDelay: 80, - minDelay: 10, - now: () => clock.now, - onConnected: () => { - up++; - - return Promise.resolve({}); - }, - }); - - t.after(() => { loop.stop(); }); - - for (let died = 0; died < 4; died++) { - loop.schedule(); - await waitFor(() => up === died + 1); - 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); + // 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[] = []; + const first = new net.Socket(); + const session = new Session({ + reconnect: { + connect: () => { + const sock = new net.Socket(); - assert.deepEqual(delays, [10, 20, 40, 80, 10]); - }); + opened.push(sock); - test('starts only one reconnect attempt at a time', async t => { - let attempts = 0; - let finish: (() => void) | undefined; - const loop = new ReconnectLoop({ - connect: () => { - attempts++; + return Promise.resolve({ sock }); + }, + maxDelay: 10, + minDelay: 1, + onConnected: () => { + if (opened.length === 1) return Promise.resolve({ err: new Error('bind refused') }); - return new Promise(resolve => { - finish = () => { resolve({ err: new Error('no socket') }); }; - }); + throw new Error('bind exploded'); + }, }, - log: silentLog, - maxDelay: 5, - minDelay: 1, - onConnected: () => Promise.resolve({}), + sock: first, }); - t.after(() => { - loop.stop(); - finish?.(); - }); - loop.schedule(); - - assert.ok(await waitFor(() => attempts === 1)); + closeAfter(t, session); + t.after(() => { for (const sock of opened) sock.destroy(); }); + first.destroy(); - // A second drop landing while the first attempt is still inside connect(). - loop.schedule(); - await delay(30); + const destroyed = await waitFor(() => opened.length >= 2 + && opened[0]?.destroyed === true + && opened[1]?.destroyed === true); - assert.equal(attempts, 1); + assert.ok(destroyed, 'a failed setup should leave no socket open'); + assert.equal(session.state, 'down'); }); }); @@ -2269,6 +2171,7 @@ describe('the server\'s onRequest hook', () => { } 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) => { if (!isCommand(pduObj, 'submit_sm')) return false; @@ -2277,10 +2180,8 @@ describe('the server\'s onRequest hook', () => { return true; }, + onSms: () => { messages++; }, }); - let messages = 0; - - smpp.on('session', bound => bound.on('sms', () => { messages++; })); const { session } = await connect(t, smpp); @@ -2332,23 +2233,24 @@ 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 onSms, and offers the keepalive and the unbind too', async t => { const seen: string[] = []; + const received: Sms[] = []; const smpp = await startServer(t, { onRequest: (_bound, pduObj) => { seen.push(pduObj.cmdName); return false; }, - }); - const incoming = once(resolve => { - smpp.on('session', bound => bound.on('sms', async sms => { - resolve(sms); + onSms: async sms => { + received.push(sms); await sms.sendResp({ smsId: answeredId }); - })); + }, }); const { session } = await connect(t, smpp); assert.ok(session); const answered = await submitTo(session, '46709771337', 'declined by the hook'); - const sms = await incoming; + const [sms] = received; + + assert.ok(sms); await session.send({ cmdName: 'enquire_link' }); await session.unbind(); @@ -2361,15 +2263,14 @@ 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(resolve => { smpp.on('session', bound => { bound.on('sessionError', resolve); }); }); - let messages = 0; - - smpp.on('session', bound => bound.on('sms', () => { messages++; })); const { session } = await connect(t, smpp, { responseTimeout: 300 }); @@ -2384,15 +2285,14 @@ 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(resolve => { smpp.on('session', bound => { bound.on('sessionError', resolve); }); }); - let messages = 0; - - smpp.on('session', bound => bound.on('sms', () => { messages++; })); const { session } = await connect(t, smpp, { responseTimeout: 300 }); diff --git a/test/tls.test.ts b/test/tls.test.ts index 1e311f4..fe2daaf 100644 --- a/test/tls.test.ts +++ b/test/tls.test.ts @@ -104,8 +104,9 @@ function createCertificate(): { cert: string; key: string } { const certificate = createCertificate(); -async function startServer(t: TestContext): Promise { +async function startServer(t: TestContext, options: Parameters[0] = {}): Promise { const { err, server: smpp } = await server({ + ...options, port: 0, tls: { cert: certificate.cert, key: certificate.key }, }); @@ -123,10 +124,8 @@ function once(register: (resolve: (value: T) => void) => void): Promise { describe('tls', () => { test('binds over a verified handshake and delivers an SMS', async t => { - const smpp = await startServer(t); - const incoming = once(resolve => { - smpp.on('session', session => session.on('sms', resolve)); - }); + const received: Sms[] = []; + const smpp = await startServer(t, { onSms: sms => sms.sendResp({ smsId: 'tls-id' }).then(() => { received.push(sms); }) }); const { err, session } = await client({ host, port: smpp.port, @@ -144,15 +143,10 @@ describe('tls', () => { assert.ok(sock.authorized); 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' }), - ]); + const sent = await session.sendSms({ from: 'MyBrand', message: 'hello over tls', to: '46709771337' }); + const [sms] = received; + assert.ok(sms); assert.equal(sms.message, 'hello over tls'); assert.equal(sms.to, '46709771337'); assert.equal(sent.err, undefined); diff --git a/test/unsendable.test.ts b/test/unsendable.test.ts index 3108910..5e5ad01 100644 --- a/test/unsendable.test.ts +++ b/test/unsendable.test.ts @@ -56,12 +56,12 @@ describe('an alphabet the caller named that cannot carry the message', () => { }); // Å is in the GSM table at 0x0E; ï is the one the encoder flattened to a space. - test('refuses an ASCII send of a character GSM 03.38 has no code for, naming that one', async () => { + test('refuses a GSM7 send of a character GSM 03.38 has no code for, naming that one', async () => { const attempts: PduObjectInput[] = []; - const sent = await submitSms(recordingDeps(attempts), { encoding: 'ASCII', from, message: 'Åsa naïve', to }); + const sent = await submitSms(recordingDeps(attempts), { encoding: 'GSM7', from, message: 'Åsa naïve', to }); assert.ok(sent.err instanceof Error); - assert.match(sent.err.message, /ASCII/); + assert.match(sent.err.message, /GSM7/); assert.match(sent.err.message, /"ï"/); assert.match(sent.err.message, /U\+00EF/); assert.match(sent.err.message, /index 6/); @@ -151,7 +151,7 @@ describe('a body the PDU\'s own data_coding cannot carry', () => { }); assert.ok(built.err instanceof Error, String(dataCoding)); - assert.match(built.err.message, /ASCII/); + assert.match(built.err.message, /GSM7/); assert.match(built.err.message, /"ï"/); assert.match(built.err.message, /U\+00EF/); assert.match(built.err.message, /index 6/); diff --git a/todo.md b/todo.md index 3e5b9c7..2e12fd0 100644 --- a/todo.md +++ b/todo.md @@ -25,13 +25,15 @@ 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: 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, }); +smpp.on('session', session => { /* session.userData, session.on('dlr', …) */ }); await smpp.close(); ``` @@ -268,14 +270,9 @@ below. README.md or in MIGRATION.md — write it in the same change as the rule, so it is worded once. From the stability and product-owner reviews of #18. -- [ ] **Build every error from a thrown value through `errorFrom()`.** `reconnect-loop.ts` (in - `run()`'s catch and in `bringUp()`) and `client.ts`'s connect still call `String(thrown)`, - which throws on a null-prototype object; in `run()` that lands as an unhandled rejection from - a `void`ed promise, against hard rule 1. From the 2026-09-28 scoring run. - -- [ ] **Leave `sms.smsId` alone when `sendResp()` fails.** `sms.ts` assigns `options.smsId` before - the lost-link check and before the write, so a failed answer still renames the message and a - later `sendDlr()` names an id the peer was never given. From the 2026-09-28 scoring run. +- [ ] **Build every error from a thrown value through `errorFrom()`.** `client.ts`'s connect still + calls `String(thrown)`, which throws on a null-prototype object, against hard rule 1. From the + 2026-09-28 scoring run. - [ ] **Refuse a timeout past 2³¹−1 ms, as `connectTimeout` already is.** `checkLimits()` bounds `responseTimeout`, `idleTimeout`, `shutdownTimeout` and `reassemblyTimeout` from below only, @@ -285,11 +282,6 @@ below. so `"\x1B("` is detected as GSM, goes out as 0x1B 0x28 and arrives as `{`. From the 2026-09-28 scoring run. -- [ ] **Count an `sms` listener that throws as one giving up, as a rejection already is.** A - synchronous throw makes `Session.emit` return false, and `HeldMessages.offer()` then releases - the hold at once, so a drain stops waiting on an async listener still answering beside it. - Goal 2. From the stability review of #45. - ### Throughput — goal 6, and the default window is where we are slowest - [ ] **Close the gap to jsmpp at `maxOutstanding: 10`.** Measured 2026-09-20 against the same sink,