Compare commits

1 Commits

Author SHA1 Message Date
lilleman fb139ab5ee Measure what this library sustains, and against Jasmin and SMPPSim
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Successful in 30s
Test / test (20) (pull_request) Successful in 31s
Test / test (22) (pull_request) Successful in 36s
Test / test (24) (pull_request) Successful in 30s
Test / test (26) (pull_request) Successful in 30s
Mirror / push (push) Successful in 5s
2026-09-20 22:57:16 +02:00
34 changed files with 412 additions and 1692 deletions
+21 -17
View File
@@ -12,7 +12,7 @@ not for structure or style.
## Goals ## Goals
The goals, in priority order, live in The nine goals, in priority order, live in
[README.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/README.md#goals) — they say where this library is heading, which an outside [README.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/README.md#goals) — they say where this library is heading, which an outside
reader judges it by. The README states the audience alongside them. Everything below cites a goal by reader judges it by. The README states the audience alongside them. Everything below cites a goal by
number. number.
@@ -70,7 +70,6 @@ src/
reassembly.ts Reassembler: capped, expiring multipart groups reassembly.ts Reassembler: capped, expiring multipart groups
reconnect-loop.ts ReconnectLoop: backoff, retry timer, stopped-ness reconnect-loop.ts ReconnectLoop: backoff, retry timer, stopped-ness
result.ts Result<T> — the shape every fallible call returns result.ts Result<T> — the shape every fallible call returns
retained-pdu.ts A PDU copied off the wire so holding it pins nothing else, and what holding it costs
send-sms.ts submitSms composition and the submitSmParams builder send-sms.ts submitSms composition and the submitSmParams builder
send-window.ts SendWindow: the maxOutstanding semaphore send-window.ts SendWindow: the maxOutstanding semaphore
session-options.ts SessionOptions, ReconnectOptions, bind direction and the session defaults session-options.ts SessionOptions, ReconnectOptions, bind direction and the session defaults
@@ -83,13 +82,12 @@ src/
constants.ts consts + constsById, and the SMPP version constants constants.ts consts + constsById, and the SMPP version constants
encodings.ts GSM 03.38, LATIN1, UCS2, detection, data_coding resolution encodings.ts GSM 03.38, LATIN1, UCS2, detection, data_coding resolution
errors.ts errors + errorsById (ESME_*) errors.ts errors + errorsById (ESME_*)
tlvs.ts TLV definitions, tlvsById, the input shape, and reading and writing a TLV stream tlvs.ts TLV definitions, tlvsById, the input shape, and writing a TLV stream
types.ts Wire types: int8/int16/int32/string/cstring/buffer/arrays types.ts Wire types: int8/int16/int32/string/cstring/buffer/arrays
``` ```
Imports point one way: `defs` knows nothing above it but `result.ts`, `pdu` uses `defs`, `session` Dependency direction is one way: `defs` knows nothing above it, `pdu` uses `defs`, `session` uses
uses `pdu`, and `client`/`server` use `session`. The one way back up is the `Session` handed to `pdu`, and `client`/`server` use `session`. Nothing reaches back up.
`IncomingRequests` and `createSms()`, imported as a type only.
**Parameter order is wire order.** The key order inside `cmds.*.params` is the order the fields are **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 written to and read from the buffer. Never sort those alphabetically — the alphabetical-ordering
@@ -105,12 +103,20 @@ docker compose run --rm node npm test
docker compose run --rm node npm run build docker compose run --rm node npm run build
``` ```
- Tests are `.ts` and run directly under Node's type stripping — no build step in the dev loop.
- Source imports use `.ts` extensions; `rewriteRelativeImportExtensions` emits `.js` into `dist`. - Source imports use `.ts` extensions; `rewriteRelativeImportExtensions` emits `.js` into `dist`.
- `erasableSyntaxOnly` is on, so no enums, no namespaces, no parameter properties. Use `as const` - `erasableSyntaxOnly` is on, so no enums, no namespaces, no parameter properties. Use `as const`
objects plus union types. objects plus union types.
- The published floor is Node 18; the dev container runs Node 24 because type stripping needs it. - The published floor is Node 18, but the dev container runs Node 24 (type stripping needs it). CI
compiles the tests and runs them on 18, every LTS above it, and current, so the floor is
verified rather than asserted.
- `typescript` is pinned to the 6.x line because `typescript-eslint` peer-requires `<6.1.0`. Move to - `typescript` is pinned to the 6.x line because `typescript-eslint` peer-requires `<6.1.0`. Move to
TypeScript 7 once that constraint lifts. TypeScript 7 once that constraint lifts.
- GitHub mirrors Gitea through `.gitea/workflows/mirror.yaml`, which never prunes, and
`mirror-delete.yaml`, one run per deleted ref. A delete run that fails or outlives Gitea's queue
timeout, or a push run that cloned before the delete, leaves the ref on GitHub until the delete
run is re-run. Accepted: a stale ref there is harmless, and refs only GitHub has must survive.
Maintainer's call, 2026-09-14; valid while nothing deploys from GitHub.
## Defects found in 0.4.0 ## Defects found in 0.4.0
@@ -146,6 +152,13 @@ Confirmed by reading the 0.4.0 source; each row has a regression test naming the
| `ESME_RINVBCASTCHANIND` typo | Defined as `0x011`, three hex digits; the spec value is `0x0112` | | `ESME_RINVBCASTCHANIND` typo | Defined as `0x011`, three hex digits; the spec value is `0x0112` |
| Every response carries a message id | `session.js` builds `params = {'message_id': …}` for every response it sends, `deliver_sm_resp` included; SMPP 3.4 4.6.2 makes that field unused and NULL, and Jasmin closes the connection on one | | Every response carries a message id | `session.js` builds `params = {'message_id': …}` for every response it sends, `deliver_sm_resp` included; SMPP 3.4 4.6.2 makes that field unused and NULL, and Jasmin closes the connection on one |
## Multipart sends
`sendSms` puts every segment of a message on the wire together instead of waiting for each response
in turn, so a long message costs one round trip rather than one per segment. Nothing on the
receiving side forces the order either way: this library answers each inbound segment as it arrives,
so a peer that dispatches one request at a time is never left waiting on us.
## GSM 7-bit is sent unpacked ## GSM 7-bit is sent unpacked
Over SMPP the ESME puts one GSM character per octet in `short_message` and the SMSC packs it into Over SMPP the ESME puts one GSM character per octet in `short_message` and the SMSC packs it into
@@ -236,6 +249,7 @@ the file.
- `Session` is publicly constructible, which is what makes `SessionOptions` and `ReconnectOptions` - `Session` is publicly constructible, which is what makes `SessionOptions` and `ReconnectOptions`
public too. public too.
- `acceptsOptionalParams()` and `bindAllows()` are predicates, not chokepoints. - `acceptsOptionalParams()` and `bindAllows()` are predicates, not chokepoints.
- `session.sock` is a getter over `PduTransport`.
- Both emitters re-declare their listener methods to accept a promise. - Both emitters re-declare their listener methods to accept a promise.
- `PduRefusedError` is exported, and `sessionError` names it in the event's type. - `PduRefusedError` is exported, and `sessionError` names it in the event's type.
- `bitCount()`, `encodeMessage()` and `splitMessage()` keep their total signatures, because - `bitCount()`, `encodeMessage()` and `splitMessage()` keep their total signatures, because
@@ -278,8 +292,6 @@ the file.
- A string body is written in the alphabet its own `data_coding` names, and one that alphabet cannot - A string body is written in the alphabet its own `data_coding` names, and one that alphabet cannot
carry is refused by the codec — `message_payload` on the same terms as `short_message`. carry is refused by the codec — `message_payload` on the same terms as `short_message`.
- A GSM 03.38 message declares `data_coding` 0x00, and an inbound 0x01 is still read as GSM. - A GSM 03.38 message declares `data_coding` 0x00, and an inbound 0x01 is still read as GSM.
- Every text field on the wire is latin1, and what the field cannot carry is refused rather than
truncated.
### [The session's life](docs/decisions.md#the-sessions-life) ### [The session's life](docs/decisions.md#the-sessions-life)
@@ -294,18 +306,12 @@ the file.
`false` is the one way to turn it off. `false` is the one way to turn it off.
- A stream this library cannot frame is a dead link; one PDU it cannot parse is not. - 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. - 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 - 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. application's own signal rather than the peer's answer.
- `server()` composes the application's `onRequest` after its own bind handling, and offers it every - `server()` composes the application's `onRequest` after its own bind handling, and offers it every
request that handling did not answer. request that handling did not answer.
- The drain waits on the messages the application holds, and `sendResp()` is what says it is done - The drain waits on the messages the application holds, and `sendResp()` is what says it is done
with one. with one.
- The drain's wait on the application ignores `shutdownTimeout: 0`.
- What the application holds unanswered is capped on constants, and a message past the cap is
refused.
- A store at its bound answers `ESME_RTHROTTLED` to a submission and `ESME_RX_T_APPN` to a delivery,
a `data_sm` by whichever it stands in for.
- A reconnect keeps the delivery-receipt merges; everything else the link held is dropped. - A reconnect keeps the delivery-receipt merges; everything else the link held is dropped.
- A message id base is merged at most once. - 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 that never reached the socket waits for the next link; one that did is counted, not resent.
@@ -323,5 +329,3 @@ the file.
- `test/` stays flat too, and a file there is named for the question it answers rather than for the - `test/` stays flat too, and a file there is named for the question it answers rather than for the
module it covers. module it covers.
- CI tests on Linux only; `src/` keeps off what is known to break on macOS or Windows. - CI tests on Linux only; `src/` keeps off what is known to break on macOS or Windows.
- GitHub mirrors Gitea without pruning, and a ref deleted on Gitea is deleted on GitHub by a run of
its own.
-53
View File
@@ -6,59 +6,6 @@
one that expires as an ordinary connect failure, so `reconnect` retries it on its usual backoff. 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 A connect previously waited the operating system out, around 130 s on Linux against a host that
drops SYNs. `connectTimeout` retunes the bound, and `connectTimeout: false` restores the old wait. drops SYNs. `connectTimeout` retunes the bound, and `connectTimeout: false` restores the old wait.
- Addresses, ids and every other text field on the wire are read and written as latin1. A
`source_addr` of `Kaffeé` previously reached the application as `Kaffei`, because the codec wrote
the octet and then masked bit 7 reading it back; `destination_addr`, `system_id`, `message_id`,
`password`, `service_type` and the C-Octet String TLVs were affected the same way. A character past
`U+00FF` in one of those fields is now refused, where it used to go out as its low octet.
**A server comparing `systemId` or `password` could be impersonated.** Masking bit 7 folded 127 of
the 255 non-zero octets onto a character a low octet also reaches, so the bind credentials your
`authenticate` received were not unique to the octets the peer sent: one refused as `admin` could
bind as `\xE1dmin` and match the same string. latin1 is one-to-one over the octets, so two
different wire values no longer arrive as one. Read 0.5.0 bind logs for a `systemId` you did not
issue.
**Check what you stored before you roll this out.** Values your application persisted under 0.5.0
were read with bit 7 masked, so an address or a `message_id` carrying an octet above `0x7F` is
spelled differently now: a stored id will not match the receipt it belongs to, and a stored address
will not match the sender it came from. Ids most SMSCs issue are digits or hex and are unaffected.
- A `U+0000` inside a C-Octet String — `source_addr`, `message_id`, `system_id` and the rest — is
refused. An Octet String carries a NULL as before.
- A non-finite number — `NaN`, `Infinity`, `-Infinity` — is refused where a text field on the wire
takes one. `sendSms({ from: NaN })` put the literal sender `NaN` on the wire and resolved as a
successful send; `message_id`, `source_addr` and the string TLVs took such a number the same way.
The call now resolves with `err` naming the field — `from: Expected a finite number, got NaN` — so
a caller that reads only `smsIds` meets a failure it has not met before. A whole number in an
address or an id still spells its digits, so `message_id: 123` is unchanged. The integer fields
name a refused `NaN` too, where the refusal used to read `null`.
- An `alert_notification` or an `outbind` from the peer is logged and left unanswered, as SMPP 3.4
gives neither a response. Each one used to emit `sessionError`, `"alert_notification" has no
response command`.
- `maxOctets` charges each held segment 1000 octets beyond its own, 300 more per TLV on it, and 300
per occurrence of a repeatable one. Segments of empty fields or thousands of empty TLVs used to
count as next to nothing, so a peer could hold far more than the cap. **Raise a `maxOctets` you
tuned low**: it now holds several times fewer segments, and an incomplete message evicted over
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.
- 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
other limits. `server({ maxOctets: 0 })` used to start and then refuse every multipart message.
- `callback_num`, `callback_num_atag`, `callback_num_pres_ind`, `broadcast_area_identifier` and
`broadcast_error_status`, the TLVs SMPP allows more than once in a PDU, keep every occurrence in
wire order. A PDU carrying two of one used to keep only the last.
**Reading one of these now needs an index.** `pduObj.tlvs.callback_num?.tagValue` is a `Buffer[]`
even where one arrived (a `number[]` for `callback_num_pres_ind` and `broadcast_error_status`), so a
`Buffer.isBuffer()` or `typeof` check written for 0.5.0 now reads it as absent. Read `tagValue[0]`
for the first occurrence. `objToPdu()`, `session.send()` and `session.sendReturn()` take
`{ tagValue: [value] }` for them and refuse a lone value before anything goes out.
- `cmds.broadcast_sm_resp.tlvMap` is removed; nothing read it.
## 0.5.0 ## 0.5.0
-3
View File
@@ -70,9 +70,6 @@ have for these:
- Binary TLVs (`message_payload`, `network_error_code`, `callback_num` and the rest) were parsed into - Binary TLVs (`message_payload`, `network_error_code`, `callback_num` and the rest) were parsed into
a hex string and written back as the ASCII of that string, so every round trip corrupted them. a hex string and written back as the ASCII of that string, so every round trip corrupted them.
They are `Buffer`s in both directions now; drop any hex encoding of your own. They are `Buffer`s in both directions now; drop any hex encoding of your own.
- `callback_num`, `callback_num_atag`, `callback_num_pres_ind`, `broadcast_area_identifier` and
`broadcast_error_status` may repeat within a PDU, and each is an array of every occurrence in both
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 - 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 `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 as `sms` for you to answer.
+32 -67
View File
@@ -101,10 +101,9 @@ session.on('sms', async sms => {
}); });
``` ```
Call `sendResp()` for every message, multipart included: until you do, it counts toward the bound Call `sendResp()` for every message; it is part of the protocol. Delivery receipts reach you as
past which the peer's messages are refused. Delivery receipts reach you as `dlr` events, not here. A `dlr` events, not here. A multipart message arrives reassembled and already answered segment by
multipart message arrives reassembled and already answered segment by segment, so `sendResp()` there segment, so `sendResp()` there only says you are done with it: [Receiving in depth](#receiving-in-depth).
puts nothing on the wire and releases it: [Receiving in depth](#receiving-in-depth).
## Run an SMPP server ## Run an SMPP server
@@ -162,6 +161,7 @@ await smpp.close(); // stop listening, then drain and close every live sess
`sendDlr('UNDELIVERABLE')` any other state: [Server in depth](#server-in-depth). `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 - 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. takes no `smsId` or refusing `status`. `sms.answeredOnArrival` says which case you are in.
- `smpp.close()` stops listening, then drains and closes every live session.
## Errors ## Errors
@@ -262,7 +262,7 @@ All optional. Timeouts are milliseconds.
| `tls` | `false` | A `tls.TlsOptions` object with your certificate and key. A bare `true` is refused. | | `tls` | `false` | A `tls.TlsOptions` object with your certificate and key. A bare `true` is refused. |
| `idleTimeout` | `40000` | Drop a peer that has been silent this long. | | `idleTimeout` | `40000` | Drop a peer that has been silent this long. |
| `maxReassembly` | `1000` | Incomplete multipart messages held per session. | | `maxReassembly` | `1000` | Incomplete multipart messages held per session. |
| `maxOctets` | `67108864` | Roughly the memory incomplete multipart messages may hold per session: each held segment counts its octets plus 1000, 300 more per TLV on it, and 300 per occurrence of a repeatable one. | | `maxOctets` | `67108864` | Bytes of incomplete multipart messages held per session. |
| `reassemblyTimeout` | `300000` | How long a late segment can still join an incomplete message. | | `reassemblyTimeout` | `300000` | How long a late segment can still join an incomplete message. |
| `responseTimeout`, `shutdownTimeout`, `maxOutstanding`, `log`, `signal` | as for the client | | | `responseTimeout`, `shutdownTimeout`, `maxOutstanding`, `log`, `signal` | as for the client | |
@@ -288,11 +288,7 @@ await session.sendSms({
``` ```
**Addresses.** `sourceAddrTon` and `destinationAddrTon` default to 5 for an alphanumeric address **Addresses.** `sourceAddrTon` and `destinationAddrTon` default to 5 for an alphanumeric address
and 1 for a numeric one; the NPI fields default to 0. An address is latin1, so `é` is one octet on and 1 for a numeric one; the NPI fields default to 0.
the wire and an address you received always sends back. One outside `/^[\u0001-\u00FF]*$/` is
refused, naming the character and its index — strip or transliterate it first. An SMSC may still
refuse a non-ASCII sender of its own accord, which reaches you as a refusal such as
`ESME_RINVSRCADR`.
**Encoding.** **Encoding.**
@@ -322,10 +318,9 @@ const { err, pduObjs, smsIds, unanswered } = await session.sendSms({ from, messa
- One id per segment. `smsIds` is positional with `pduObjs`, and an entry is `undefined` where the - One id per segment. `smsIds` is positional with `pduObjs`, and an entry is `undefined` where the
SMSC took the segment without naming an id; some name one for the first segment only. No receipt SMSC took the segment without naming an id; some name one for the first segment only. No receipt
ever carries an empty id, so an unnamed entry matches nothing. ever carries an empty id, so an unnamed entry matches nothing.
- `err` is set when the SMSC refuses a segment, naming the status, or leaves one unanswered. Every - `err` is set when the SMSC refuses a segment, naming the status. Every segment goes out together,
segment goes out together, so `pduObjs` and `smsIds` then hold only the accepted segments, in send so `pduObjs` and `smsIds` then hold what was accepted: enough to reconcile a later receipt, not
order: enough to reconcile a later receipt, not enough to resend the rest. Treat a partial failure enough to resend the rest. Treat a partial failure as a failed message.
as a failed message; its accepted segments report through `dlr` alone.
- `unanswered` counts segments that went out and were never answered. The SMSC may have taken each - `unanswered` counts segments that went out and were never answered. The SMSC may have taken each
and lost only the response, so a message with `unanswered` above zero cannot be resent without and lost only the response, so a message with `unanswered` above zero cannot be resent without
risking a duplicate. risking a duplicate.
@@ -347,11 +342,9 @@ you formatted. Refused before anything goes out: an invalid `Date`, `NaN`, `Infi
count, and a count past 99 days 23:59:59, since a count in seconds is spelled in days and below. count, and a count past 99 days 23:59:59, since a count in seconds is spelled in days and below.
Name a later instant as a `Date`, which goes out absolute. Name a later instant as a `Date`, which goes out absolute.
**What gets checked.** The library checks what it composes: an address you gave as `from` or `to`, **What gets checked.** The library checks what it composes: an alphabet or a time you named, a string
an alphabet or a time you named, a string body under a `data_coding` you named. What you formed body under a `data_coding` you named. What you formed yourself, a `Buffer` body or a stamp you
yourself, a `Buffer` body or a stamp you formatted, passes through as written, except that a text formatted, passes through as written. The same rule holds for `session.send()`.
field is still checked: [PDUs and the low-level API](#pdus-and-the-low-level-api). The same rule
holds for `session.send()`.
## Session ## Session
@@ -384,7 +377,8 @@ holds for `session.send()`.
message has failed. Answering through `sendReturn()` instead leaves the wait running. message has failed. Answering through `sendReturn()` instead leaves the wait running.
3. Tear down what is left, resolving to an `err` that says what was lost. 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. At most 1000 unanswered messages are held, for five minutes each; what falls out of either bound is
dropped with a warning on the log and waited for no longer. Neither bound is an option.
`close({ signal })` cuts the wait short. `unbind()` takes no signal, and waits a further `close({ signal })` cuts the wait short. `unbind()` takes no signal, and waits a further
`responseTimeout` for its own response. `responseTimeout` for its own response.
@@ -432,12 +426,6 @@ const { err, pduObj } = await session.send({
each is two messages. each is two messages.
- **Answered on arrival.** Each segment was answered as it landed, before you see the message: - **Answered on arrival.** Each segment was answered as it landed, before you see the message:
[Server in depth](#server-in-depth). [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.
- **Where the body is.** A body in the `message_payload` TLV, SMPP's way of carrying up to 64 KB and - **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 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`. and receipts included. A PDU filling both is read from `short_message`.
@@ -486,20 +474,19 @@ carrying the worst status of the segments and each of them under `segments`. An
report never counts. Merging needs the SMSC to number its segment ids `<base>-<n>`, this library's report never counts. Merging needs the SMSC to number its segment ids `<base>-<n>`, this library's
own server's convention; an SMSC that hands out unrelated ids per segment never fires it. A base is own server's convention; an SMSC that hands out unrelated ids per segment never fires it. A base is
merged once: a later message the SMSC gives the same ids is reported through `dlr` alone, and an merged once: a later message the SMSC gives the same ids is reported through `dlr` alone, and an
earlier one still collecting loses its merged report. A send that returned an `err` never fires `messageDlr`, earlier one still collecting loses its merged report.
even where the SMSC took some of its segments; their receipts still arrive as `dlr`.
## Server in depth ## Server in depth
**Multipart is answered on arrival.** Each segment is answered as it lands, because a relaying SMSC **Multipart is answered on arrival.** Each segment is answered as it lands, because a relaying SMSC
will not send the next until the last is answered. The answer is `ESME_ROK`, unless the segment 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 numbers itself into no message this session can join, which refuses it, or the reassembly buffer is
the unanswered messages are at their bound, which asks the SMSC to keep it and try again. full, which asks the SMSC to keep it and try again. `sms.answeredOnArrival` says whether the message
`sms.answeredOnArrival` says whether the message you hold was answered that way; a segment count you hold was answered that way; a segment count cannot, since a peer may number a message one part
cannot, since a peer may number a message one part of one. of one.
- The id was fixed with the first segment, so `sendResp()` there puts nothing on the wire and - The id was fixed with the first segment, so `sendResp()` there only says you are done, and
releases the message, and returns `err` for an `smsId` or a refusing `status`. returns `err` for an `smsId` or a refusing `status`.
- `sms.smsId` is the base. `sendDlr()` names `<smsId>-1`, `<smsId>-2` and so on: the ids the - `sms.smsId` is the base. `sendDlr()` names `<smsId>-1`, `<smsId>-2` and so on: the ids the
`submit_sm` responses carried. `submit_sm` responses carried.
- A `deliver_sm` is answered with no id at all, since SMPP marks that field unused, so an inbound - A `deliver_sm` is answered with no id at all, since SMPP marks that field unused, so an inbound
@@ -613,10 +600,6 @@ if (isCommand(pduObj, 'submit_sm')) {
and hands back the UDH where the PDU carries one. and hands back the UDH where the PDU carries one.
- `concatOf(pduObj)`: the `part`, `total` and `reference` a PDU declares and the `spelling` that - `concatOf(pduObj)`: the `part`, `total` and `reference` a PDU declares and the `spelling` that
carried them, `'udh'` or `'sar'`, or `undefined` for a whole message. carried them, `'udh'` or `'sar'`, or `undefined` for a whole message.
- `callback_num`, `callback_num_atag`, `callback_num_pres_ind`, `broadcast_area_identifier` and
`broadcast_error_status` may repeat in one PDU, so each reads as an array of every occurrence in wire
order: `number[]` for `callback_num_pres_ind` and `broadcast_error_status`, `Buffer[]` for the rest.
A `broadcast_sm_resp`'s `failed_broadcast_area_identifier` reads as `broadcast_area_identifier`.
- `messageClassOf(dataCoding)`: `0` for the flash class, `1`, `2` and `3` for the ME-, SIM- and - `messageClassOf(dataCoding)`: `0` for the flash class, `1`, `2` and `3` for the ME-, SIM- and
TE-specific ones, `undefined` where that `data_coding`'s coding group carries no class. TE-specific ones, `undefined` where that `data_coding`'s coding group carries no class.
@@ -625,10 +608,6 @@ if (isCommand(pduObj, 'submit_sm')) {
- A string `short_message` or `message_payload` is encoded in the alphabet the PDU's `data_coding` - A string `short_message` or `message_payload` is encoded in the alphabet the PDU's `data_coding`
names, detected from the text where you name none. One that alphabet cannot carry is refused, names, detected from the text where you name none. One that alphabet cannot carry is refused,
naming the character, its code point and where it is. naming the character, its code point and where it is.
- Every text field is latin1: addresses, `system_id`, `message_id`, `service_type` and the C-Octet
String TLVs. A character past `U+00FF` is refused, as is a `U+0000` in a C-Octet String.
- The five repeatable TLVs take an array, written as one TLV per element; a lone value or an empty
array is refused.
- A `Buffer` goes out exactly as given under any `data_coding`: binary payloads, hand-built user - A `Buffer` goes out exactly as given under any `data_coding`: binary payloads, hand-built user
data headers, deliberately malformed bodies. data headers, deliberately malformed bodies.
- `session.send()` and `session.sendReturn()` build through the same codec and refuse the same bodies. - `session.send()` and `session.sendReturn()` build through the same codec and refuse the same bodies.
@@ -662,17 +641,15 @@ See [MIGRATION.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/MIGRAT
## Goals ## Goals
In priority order, and the order is the point: where two of them pull against each other, the earlier In priority order, and the order is the point: where two of them pull against each other, the earlier
one wins. They do not override the [hard rules](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/AGENTS.md#hard-rules). one wins. They do not override the hard rules below.
1. **Correct on the wire.** SMPP 3.4 as SMSCs actually run it. Every other goal yields to this one; 1. **Correct on the wire.** SMPP 3.4 as SMSCs actually run it. Every other goal yields to this one;
the [defect table](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/AGENTS.md#defects-found-in-040) is what the alternative costs. the defect table below is what the alternative costs.
2. **Never give the application a wrong answer about what happened.** An outcome we cannot determine 2. **Never give the application a wrong answer about what happened.** An outcome we cannot determine
is reported as undetermined rather than guessed; a report the peer marked as not final settles is reported as undetermined rather than guessed; a report the peer marked as not final settles
nothing, so nothing the library concludes may rest on one; a request the peer may already have nothing, so nothing the library concludes may rest on one; a request the peer may already have
taken is never re-sent on the library's own initiative; work the peer has no reason to send again taken is never re-sent on the library's own initiative; work the peer has no reason to send again
is not dropped; a call that reports a message as sent asserts that the wire carried what the caller is not dropped.
wrote, so a value we cannot send as given is refused before anything goes out; each message gets
one outcome as a whole, so a send that fails is that outcome and no merged report follows it.
3. **Strict in what we send, generous in what we read.** The library's own senders follow 3.4, and 3. **Strict in what we send, generous in what we read.** The library's own senders follow 3.4, and
the codec parses whatever arrives. Where the letter of the spec would discard traffic a real SMSC the codec parses whatever arrives. Where the letter of the spec would discard traffic a real SMSC
sends, keep the traffic. sends, keep the traffic.
@@ -683,30 +660,24 @@ one wins. They do not override the [hard rules](https://gitea.larvit.se/larvit/s
about a message the application sent reaches it as a report rather than as an inbound message, and about a message the application sent reaches it as a report rather than as an inbound message, and
says whether it is final, so nothing has to read the PDU to tell those apart. An option retunes a says whether it is final, so nothing has to read the PDU to tell those apart. An option retunes a
default or opts out of it; an option does not switch on the thing the caller obviously wanted. default or opts out of it; an option does not switch on the thing the caller obviously wanted.
6. **Fast enough that this library is never the bottleneck.** Throughput over a few sessions rather 6. **Configurable and extendable, never at the defaults' expense.** Where an application needs other
than many idle ones, which is why this exists on Node at all. On four cores or more, one bound
session sustains at least 5,000 `submit_sm`/s at a send window of 1, 20,000 at the default window
of 10, and 30,000 at 50 or above, and asking for delivery receipts costs nothing measurable.
Memory is bounded per session, never per process. [benchmarks/](benchmarks/README.md) is how those
floors are checked; every release re-measures them and asks what it would take to go faster.
7. **Configurable and extendable, never at the defaults' expense.** Where an application needs other
than the default and cannot build it from what is exported — a rate limit counted per PDU, an than the default and cannot build it from what is exported — a rate limit counted per PDU, an
alphabet, a receipt format — it gets an option or a hook rather than a fork. A call that passes no alphabet, a receipt format — it gets an option or a hook rather than a fork. A call that passes no
options stays exactly as easy and as safe, and a hook is a seam the library calls, never a way into options stays exactly as easy and as safe, and a hook is a seam the library calls, never a way into
its internals. its internals.
8. **A small, stable public surface over reshapeable internals.** Only what `src/index.ts` exports is 7. **A small, stable public surface over reshapeable internals.** Only what `src/index.ts` exports is
published. A new option has to beat "the application can do this itself", and has to keep a published. A new option has to beat "the application can do this itself", and has to keep a
promise this library can verify. The low-level surface is a passthrough: policy binds what the promise this library can verify. The low-level surface is a passthrough: policy binds what the
library composes, never what the caller wrote. library composes, never what the caller wrote.
9. **State wider than one session goes through one store.** A pool of sessions, a limit shared 8. **State wider than one session goes through one store.** A pool of sessions, a limit shared
between processes, and what has to survive a restart — receipts still awaited, a message half between processes, and what has to survive a restart — receipts still awaited, a message half
reassembled — are held through a store interface and never beside it. Without a store the reassembled — are held through a store interface and never beside it. Without a store the
application supplies, that state is in memory and ends with the process, and the defaults need application supplies, that state is in memory and ends with the process, and the defaults need
none. The interface carries the library's own versioned records, never an internal shape handed none. The interface carries the library's own versioned records, never an internal shape handed
to the application to persist. Coordinating processes any other way is declined without a fresh to the application to persist. Coordinating processes any other way is declined without a fresh
argument each time. argument each time.
10. **It builds, tests and runs the same everywhere.** Container-only toolchain, no runtime 9. **It builds, tests and runs the same everywhere.** Container-only toolchain, no runtime
dependencies, the Node 18 floor verified in CI, every README example executed dependencies, the Node 18 floor verified in CI rather than asserted, every README example executed
by the suite. by the suite.
## Audience ## Audience
@@ -715,21 +686,15 @@ Who depends on this library, and what they may rely on.
- **The public npm audience.** Only what `src/index.ts` exports is public; everything behind it is - **The public npm audience.** Only what `src/index.ts` exports is public; everything behind it is
reshaped freely. reshaped freely.
- **Node 18 and newer, ESM only, no runtime dependencies.** - **Node 18 and newer, ESM only, no runtime dependencies.** The floor is verified in CI rather than
asserted, so the library drops into a service or a container without pulling a tree behind it.
- **Real SMSCs and ESMEs as operators actually run them**, not a reference implementation. Jasmin, - **Real SMSCs and ESMEs as operators actually run them**, not a reference implementation. Jasmin,
SMPPSim, Kannel, jsmpp, Cloudhopper, python-smpplib and php-smpp are the interop targets, and what SMPPSim, Kannel, jsmpp, Cloudhopper, python-smpplib and php-smpp are the interop targets, and what
they do in practice outranks what the specification says they should do. they do in practice outranks what the specification says they should do.
- **The SMSC operator on the far end**, who never sees this API but carries what it does to their - **The SMSC operator on the far end**, who never sees this API but carries what it does to their
link. A peer they have to complain about is a defect however well the library reads. link. A peer they have to complain about is a defect however well the library reads.
- **The developer building the SMPP edge of something else.** What binds to `server()` in practice is
an aggregator's customer-facing edge, a bridge putting SMPP in front of a modern transport, or a
test double standing in for an SMSC. This is not a store-and-forward SMSC and will not become one:
spooling, scheduling, retry policy and billing belong to whatever this is the edge of, and state
shared between instances goes through the store in goal 9.
- **Pre-1.0, so the minor is the breaking unit** and a patch never breaks. What a 0.4.0 consumer has - **Pre-1.0, so the minor is the breaking unit** and a patch never breaks. What a 0.4.0 consumer has
to change is in [MIGRATION.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/MIGRATION.md); to change is in [MIGRATION.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/MIGRATION.md).
what each later minor changes is in
[CHANGELOG.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/CHANGELOG.md).
Personas this README serves, in order: Personas this README serves, in order:
-47
View File
@@ -17,26 +17,9 @@ docker compose -f compose.yaml -f interop-tests/compose.jasmin.yaml run --rm nod
--count=20000 --concurrency=50 --count=20000 --concurrency=50
``` ```
`GATE=1` fails the run when a window falls under goal 6's floor, and refuses to judge at all on
fewer than four cores.
**A short run measures the JIT, not the library.** At 2,000 messages per point the same build **A short run measures the JIT, not the library.** At 2,000 messages per point the same build
reported 16k/s where 100,000 messages reported 40k/s. Give each point several seconds. reported 16k/s where 100,000 messages reported 40k/s. Give each point several seconds.
**Cores matter, though the library is single-threaded.** The run is two Node processes, and past
them V8 marks and compiles on threads of its own while the kernel carries loopback TCP. Window 200,
100,000 messages:
| Cores | msgs/s |
| --- | --- |
| 1 | 20,476 |
| 2 | 29,603 |
| 4 | 32,869 |
| 8 | 40,046 |
A window of 1 is unmoved by any of it (7,280–8,659 throughout) because it waits on the round trip
rather than the CPU. The windowed figures are for the pair: one process alone reaches about half.
## Results, 2026-09-20 ## Results, 2026-09-20
Single host, 8 cores, Node 24.18.0 in the project container, loopback. Client and SMSC are separate Single host, 8 cores, Node 24.18.0 in the project container, loopback. Client and SMSC are separate
@@ -62,36 +45,6 @@ Against real peers, same driver, window 50, 20,000 messages:
| Jasmin 0.10 | 2,207 | 0 | | Jasmin 0.10 | 2,207 | 0 |
| SMPPSim 3.0.0 | — | 19,000 of 20,000 | | SMPPSim 3.0.0 | — | 19,000 of 20,000 |
## Against the other client libraries
Same sink, same 100,000 single-segment messages, same host. This is the comparison that means
something: every client is measured pushing into *our* server, so the server's work is common to all
three and only the client differs.
```bash
docker compose -f compose.yaml -f benchmarks/compose.jsmpp.yaml up -d --build
docker compose -f compose.yaml -f benchmarks/compose.jsmpp.yaml run --rm node \
node benchmarks/peer-load.ts --driver=http://jsmpp:8080 --count=100000 --concurrency=50
```
| Window | this library | jsmpp 3.0.3 | Cloudhopper 5.0.10 |
| --- | --- | --- | --- |
| 10 | 25,358 | 30,771 | 27,945 |
| 50 | 38,675 | 40,934 | 32,384 |
| 200 | 40,046 | 42,105 | 25,497 |
**We are slowest at the default window**, which is the setting most callers will ever run — 25,358
against jsmpp's 30,771. That is the throughput work worth doing, and it is worth doing there.
Two things the table does not show. This library does it on one event loop where both Java peers
spend one OS thread per in-flight request, which is why Cloudhopper falls off at 200 threads and we
do not. And all three are pushing into the same Node sink, whose own cost is in every number, so the
differences between clients are compressed rather than exaggerated here.
Kannel is absent deliberately: it is a gateway rather than a client library, wired here as an ESME
that forwards from its own spool, so loading it would measure its HTTP frontend and queue rather
than an SMPP client. The number would not belong in this table.
Jasmin routes and persists where the sink does neither, so the gap is not an efficiency ratio Jasmin routes and persists where the sink does neither, so the gap is not an efficiency ratio
between two comparable things — what it establishes is that this library is not the bottleneck between two comparable things — what it establishes is that this library is not the bottleneck
against a production SMSC, by more than an order of magnitude. SMPPSim's store fills at roughly a against a production SMSC, by more than an order of magnitude. SMPPSim's store fills at roughly a
-25
View File
@@ -1,25 +0,0 @@
x-log-limits: &log-limits
logging:
driver: json-file
options:
max-file: "3"
max-size: 20m
# The peer dials the host it was given at build time, "node", so the sink answers under that name.
services:
cloudhopper:
build: ./interop-tests/peers/cloudhopper
image: interop-cloudhopper-load:5.0.10-ae6485a
command: ["node", "2775"]
<<: *log-limits
healthcheck:
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080'"]
interval: 1s
retries: 30
timeout: 2s
node:
command: ["node", "benchmarks/smsc-sink.ts"]
environment:
NPM_CONFIG_CACHE: /tmp/npm-cache
PORT: "2775"
-25
View File
@@ -1,25 +0,0 @@
x-log-limits: &log-limits
logging:
driver: json-file
options:
max-file: "3"
max-size: 20m
# The peer dials the host it was given at build time, "node", so the sink answers under that name.
services:
jsmpp:
build: ./interop-tests/peers/jsmpp
image: interop-jsmpp-load:3.0.3-a24db96
command: ["node", "2775"]
<<: *log-limits
healthcheck:
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080'"]
interval: 1s
retries: 30
timeout: 2s
node:
command: ["node", "benchmarks/smsc-sink.ts"]
environment:
NPM_CONFIG_CACHE: /tmp/npm-cache
PORT: "2775"
-33
View File
@@ -1,33 +0,0 @@
/**
* Drives a peer's HTTP control surface through the same load the local driver runs, so the number
* that comes back is that library's own rate against our sink rather than ours against theirs.
*/
function arg(name: string, fallback: string): string {
const found = process.argv.find(one => one.startsWith(`--${name}=`));
return found === undefined ? fallback : found.slice(name.length + 3);
}
const driver = arg('driver', 'http://jsmpp:8080');
const count = arg('count', '20000');
const concurrency = arg('concurrency', '50');
async function call(path: string): Promise<unknown> {
const response = await fetch(`${driver}${path}`);
return response.json();
}
// Cloudhopper's window defaults to 1 and is set at bind, so threads alone would serialise it.
const bound = await call(`/bind?systemId=bench&password=benchpw&windowSize=${concurrency}`);
if (typeof bound !== 'object' || bound === null || !('ok' in bound) || bound.ok !== true) {
process.stdout.write(`${JSON.stringify({ bind: bound })}\n`);
process.exit(1);
}
const loaded = await call(`/load?count=${count}&concurrency=${concurrency}`);
process.stdout.write(`${JSON.stringify(loaded)}\n`);
await call('/unbind');
-32
View File
@@ -1,14 +1,7 @@
import { availableParallelism } from 'node:os';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { once } from 'node:events'; import { once } from 'node:events';
import { createInterface } from 'node:readline'; import { createInterface } from 'node:readline';
/** Goal 6's floors, per send window. Set GATE=1 to fail the run instead of only reporting. */
const floors: Record<number, number> = { 1: 5000, 10: 20_000, 50: 30_000, 200: 30_000 };
/** Below this, client and sink contend for one core and the windowed floors are unreachable. */
const gateCores = 4;
/** /**
* Node rather than Python, unlike the repo's other standalone scripts: it spawns the two processes * Node rather than Python, unlike the repo's other standalone scripts: it spawns the two processes
* it measures, and they must run on the same runtime this library is measured under. * it measures, and they must run on the same runtime this library is measured under.
@@ -68,28 +61,3 @@ for (const row of rows) {
process.stdout.write(`${dlr}${window}${rate}${String(row.seconds)}\n`); process.stdout.write(`${dlr}${window}${rate}${String(row.seconds)}\n`);
} }
const cores = availableParallelism();
process.stdout.write(`\n${String(cores)} cores\n`);
if (process.env.GATE !== '1') process.exit(0);
if (cores < gateCores) {
process.stdout.write(`refusing to gate on ${String(cores)} cores; goal 6 states four or more\n`);
process.exit(1);
}
const short = rows.filter(row => {
const floor = floors[Number(row.concurrency)];
return floor !== undefined && Number(row.perSecond) < floor;
});
for (const row of short) {
const floor = String(floors[Number(row.concurrency)]);
process.stdout.write(`below goal 6: window ${String(row.concurrency)} ran ${String(row.perSecond)}/s, floor is ${floor}\n`);
}
process.exit(short.length === 0 ? 0 : 1);
+54 -88
View File
@@ -17,6 +17,9 @@ rule and an index of the titles below.
sending them. Only `submit_sm`, `deliver_sm` and `data_sm` are policed by bind direction — the sending them. Only `submit_sm`, `deliver_sm` and `data_sm` are policed by bind direction — the
three the library dispatches by it, of which it sends the first two. three the library dispatches by it, of which it sends the first two.
- **`session.sock` is a getter over `PduTransport`.** Reading it is unchanged; assigning it no longer
compiles, which never rewired the handlers and so never worked.
- **Both emitters re-declare their listener methods to accept a promise.** Maintainer's call, - **Both emitters re-declare their listener methods to accept a promise.** Maintainer's call,
2026-08-27: `EventEmitter` types every listener as void-returning, so the 2026-08-27: `EventEmitter` types every listener as void-returning, so the
`session.on('sms', async sms => …)` README documents reads as a misused promise in any strict `session.on('sms', async sms => …)` README documents reads as a misused promise in any strict
@@ -33,7 +36,7 @@ rule and an index of the titles below.
- **`PduRefusedError` is exported, and `sessionError` names it in the event's type.** Maintainer's - **`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 call, 2026-09-05, from a product review: one event carries both a PDU the peer malformed and the
session's own failure, and `instanceof` is the only way to separate them that hard rule 4 allows — session's own failure, and `instanceof` is the only way to separate them that hard rule 4 allows —
without the class as a value an application is left string-matching `err.message`. Goal 8 is paid by without the class as a value an application is left string-matching `err.message`. Goal 7 is paid by
exporting the discriminant and the struct it carries and nothing else: `PduHeader` is named because exporting the discriminant and the struct it carries and nothing else: `PduHeader` is named because
an application that logs or forwards a header wants a name for it, `PduRefusalReason` is not an application that logs or forwards a header wants a name for it, `PduRefusalReason` is not
because `reason` is compared against string literals, and an accessor because `reason` is compared against string literals, and an accessor
@@ -60,10 +63,10 @@ rule and an index of the titles below.
runtime: `Object.hasOwn(encodings, x)` is the only test the published surface offered and it runtime: `Object.hasOwn(encodings, x)` is the only test the published surface offered and it
narrows nothing, so `isEncodingName()` is exported beside `isCommandName()` and `isErrorName()`, narrows nothing, so `isEncodingName()` is exported beside `isCommandName()` and `isErrorName()`,
which serve their own tables that way. Rejected: a `Result` signature on all three, which costs which serve their own tables that way. Rejected: a `Result` signature on all three, which costs
every typed consumer a narrow forever — goal 8, and the tag is the last cheap chance to spend it — every typed consumer a narrow forever — goal 7, and the tag is the last cheap chance to spend it —
to guard a state the compiler refuses. Where the domain really is open the check is already there: to guard a state the compiler refuses. Where the domain really is open the check is already there:
`sendSms()` widens `encoding`, `messagingMode` and the two time options to `unknown` and refuses `sendSms()` takes its options as `unknown` and refuses `encoding` by name, which is what a caller
each by name, which is what a caller without types gets. `smppTime.encode()` is where that reasoning lands the other way and is recorded without types gets. `smppTime.encode()` is where that reasoning lands the other way and is recorded
under [The wire](#the-wire): `Date | number | string` is not a closed set, so it is a `Result`. under [The wire](#the-wire): `Date | number | string` is not a closed set, so it is a `Result`.
- **A segment the SMSC took and named no id for is `undefined` in `smsIds`, not an empty string.** - **A segment the SMSC took and named no id for is `undefined` in `smsIds`, not an empty string.**
@@ -122,7 +125,7 @@ rule and an index of the titles below.
phase: SMPP 3.4 5.3.2.32 makes the TLV the alternative for a body the mandatory field cannot phase: SMPP 3.4 5.3.2.32 makes the TLV the alternative for a body the mandatory field cannot
carry, several SMSCs use it, and Jasmin relays one faithfully — reading `short_message` alone carry, several SMSCs use it, and Jasmin relays one faithfully — reading `short_message` alone
handed the application an empty message handed the application an empty message
([interop-tests/findings/03-jasmin.md](../interop-tests/findings/03-jasmin.md)). `messageOctets()` is ([interop-tests/findings/03-jasmin.md](interop-tests/findings/03-jasmin.md)). `messageOctets()` is
the single answer to where a body is, so the message path, the reassembler and `dlrFromPdu()` the single answer to where a body is, so the message path, the reassembler and `dlrFromPdu()`
cannot disagree about it, and `esm_class` still says whether that body starts with a UDH wherever cannot disagree about it, and `esm_class` still says whether that body starts with a UDH wherever
it was carried, which leaves concatenation reading exactly as before. Filling both contradicts the it was carried, which leaves concatenation reading exactly as before. Filling both contradicts the
@@ -130,7 +133,8 @@ rule and an index of the titles below.
rule purely additive: no PDU that parsed before reads differently now. Rejected: preferring the rule purely additive: no PDU that parsed before reads differently now. Rejected: preferring the
TLV, which re-reads every message a peer echoes into both. Rejected: refusing a PDU carrying both, TLV, which re-reads every message a peer echoes into both. Rejected: refusing a PDU carrying both,
which discards a message that is almost certainly present twice over, where goal 3 keeps the which discards a message that is almost certainly present twice over, where goal 3 keeps the
traffic. traffic. The reassembler's octet cap already counts TLV values, so a 64 KB payload is bounded like
any other segment.
- **A segment's concatenation is read from its UDH, or from the `sar_*` TLVs where it declares none, - **A segment's concatenation is read from its UDH, or from the `sar_*` TLVs where it declares none,
and each spelling groups in a reference space of its own.** Maintainer's call, 2026-09-06, from and each spelling groups in a reference space of its own.** Maintainer's call, 2026-09-06, from
@@ -138,7 +142,7 @@ rule and an index of the titles below.
`sar_msg_ref_num`/`sar_total_segments`/`sar_segment_seqnum` the other way to say what a UDH says, `sar_msg_ref_num`/`sar_total_segments`/`sar_segment_seqnum` the other way to say what a UDH says,
Jasmin documents it as its own segmentation and jsmpp writes it, and reading the UDH alone handed Jasmin documents it as its own segmentation and jsmpp writes it, and reading the UDH alone handed
the application one `sms` per fragment the application one `sms` per fragment
([interop-tests/findings/05-java-clients.md](../interop-tests/findings/05-java-clients.md)). ([interop-tests/findings/05-java-clients.md](interop-tests/findings/05-java-clients.md)).
`concatOf()` is the single answer to how a PDU says it is a segment, as `messageOctets()` is to `concatOf()` is the single answer to how a PDU says it is a segment, as `messageOctets()` is to
where a body is, and both are exported for the same reason: an application on the low-level where a body is, and both are exported for the same reason: an application on the low-level
surfaces would otherwise rewrite the read this fixed. It carries the spelling beside the surfaces would otherwise rewrite the read this fixed. It carries the spelling beside the
@@ -164,13 +168,13 @@ rule and an index of the titles below.
the suite ran took the 0x40 this library sends on a concatenated segment, but Route Mobile and the suite ran took the 0x40 this library sends on a concatenated segment, but Route Mobile and
Kaleyra both document `esm_class` 0x43 for one, and a caller facing either had to hand-build every Kaleyra both document `esm_class` 0x43 for one, and a caller facing either had to hand-build every
segment through `send()` — giving up the split, the per-segment ids, the send window and the segment through `send()` — giving up the split, the per-segment ids, the send window and the
receipt merge, which is what goal 8 means by beating "the application can do this itself". The four receipt merge, which is what goal 7 means by beating "the application can do this itself". The four
modes of SMPP 3.4 5.2.12 are a `MESSAGING_MODE` constant group and the option takes one of their modes of SMPP 3.4 5.2.12 are a `MESSAGING_MODE` constant group and the option takes one of their
names, so 0x43 is a composition this library makes rather than a value a caller states, and the UDH names, so 0x43 is a composition this library makes rather than a value a caller states, and the UDH
indicator a segment carrying a header needs cannot be cleared by anything the option can express. indicator a segment carrying a header needs cannot be cleared by anything the option can express.
It takes three of those four: 2.10.3 carries transaction mode on `data_sm` alone, and none goes out It takes three of those four: 2.10.3 carries transaction mode on `data_sm` alone, and none goes out
of here, so `FORWARD` stays in the group that mirrors the spec table and `sendSms()` refuses it by of here, so `FORWARD` stays in the group that mirrors the spec table and `sendSms()` refuses it by
that reason rather than as an unknown name — a mode this library cannot deliver is a promise goal 8 that reason rather than as an unknown name — a mode this library cannot deliver is a promise goal 7
will not let it make. `DATAGRAM` with `dlr: true` is refused on the same footing: 2.10.2 defines the will not let it make. `DATAGRAM` with `dlr: true` is refused on the same footing: 2.10.2 defines the
report away, so arming `DlrMerger` for one is goal 2's wrong answer, where the mode alone and a report away, so arming `DlrMerger` for one is goal 2's wrong answer, where the mode alone and a
report under any other mode both go out untouched. Those three names left `ESM_CLASS`, where they report under any other mode both go out untouched. Those three names left `ESM_CLASS`, where they
@@ -220,14 +224,14 @@ rule and an index of the titles below.
ME-, SIM- and TE-specific classes immediate display and missed the 0xF0 group entirely — the only ME-, SIM- and TE-specific classes immediate display and missed the 0xF0 group entirely — the only
one SMPP 3.4 5.2.19 names, since it marks 0x0F to 0xBF reserved and hands 0xF0 to 0xFF to GSM one SMPP 3.4 5.2.19 names, since it marks 0x0F to 0xBF reserved and hands 0xF0 to 0xFF to GSM
03.38, and the one SMPPSim demonstrated 03.38, and the one SMPPSim demonstrated
([interop-tests/findings/02-smppsim.md](../interop-tests/findings/02-smppsim.md), C17). ([interop-tests/findings/02-smppsim.md](interop-tests/findings/02-smppsim.md), C17).
`messageClassOf()` is the single answer to whether a `data_coding` carries a class and which, as `messageClassOf()` is the single answer to whether a `data_coding` carries a class and which, as
`concatOf()` is to how a PDU says it is a segment: 03.38 section 4 puts the class in bits 1-0, `concatOf()` is to how a PDU says it is a segment: 03.38 section 4 puts the class in bits 1-0,
carried where bit 4 says so in every group below 0x80 and always in the 0xF0 group, and carried where bit 4 says so in every group below 0x80 and always in the 0xF0 group, and
`encodingByDataCoding()` reads the alphabet off that same test rather than repeating the group `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 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 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` event, which pays goal 7 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 — 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 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 its declared alphabet makes of it — but bit 5 does not move the class bits, so 0x30 is read as
@@ -322,7 +326,7 @@ rule and an index of the titles below.
Java-client interoperability phase: accepting any parse that merely did not error answered Java-client interoperability phase: accepting any parse that merely did not error answered
`ESME_ROK` to a `deliver_sm` whose three trailing octets were never read, dropping the `ESME_ROK` to a `deliver_sm` whose three trailing octets were never read, dropping the
`receipted_message_id` that makes a receipt a receipt `receipted_message_id` that makes a receipt a receipt
([interop-tests/findings/05-java-clients.md](../interop-tests/findings/05-java-clients.md)). Goal 2 ([interop-tests/findings/05-java-clients.md](interop-tests/findings/05-java-clients.md)). Goal 2
settles it against goal 3: octets this codec cannot name are a PDU it did not read, so a region settles it against goal 3: octets this codec cannot name are a PDU it did not read, so a region
that does not end on `command_length` — the padded read included — is refused with the `tlvs` that does not end on `command_length` — the padded read included — is refused with the `tlvs`
reason and `ESME_RINVTLVSTREAM` a truncated TLV value already gets. What the rule costs is paid reason and `ESME_RINVTLVSTREAM` a truncated TLV value already gets. What the rule costs is paid
@@ -355,8 +359,8 @@ rule and an index of the titles below.
it. There is one budget, 140 less the UDH, and the alphabet decides only what it is counted in, so it. There is one budget, 140 less the UDH, and the alphabet decides only what it is counted in, so
Latin-1 and UCS2 both take those 134 octets — 134 characters and 67 — and it is GSM 7-bit's 153 Latin-1 and UCS2 both take those 134 octets — 134 characters and 67 — and it is GSM 7-bit's 153
that is the odd number rather than the other way round. `Record<EncodingName, number>` is what makes that is the odd number rather than the other way round. `Record<EncodingName, number>` is what makes
a fourth alphabet state its own. Rejected: 134 for GSM 7-bit too, which is the mistake a fourth alphabet state its own. Rejected: 134 for GSM 7-bit too, which is the mistake the
[GSM 7-bit is sent unpacked](../AGENTS.md#gsm-7-bit-is-sent-unpacked) exists to stop. Accepted: a Latin-1 message past the 140 characters unpacked-alphabet section above exists to stop. Accepted: a Latin-1 message past the 140 characters
one SMS holds now costs more segments than it did, and `smsIds` is that much longer. one SMS holds now costs more segments than it did, and `smsIds` is that much longer.
- **An alphabet the caller named has to carry the message, and a time the format cannot express is - **An alphabet the caller named has to carry the message, and a time the format cannot express is
@@ -409,14 +413,14 @@ rule and an index of the titles below.
returned `42 44 46` — `"BDF"` — reported as built, while a string `message_payload` was cut to its returned `42 44 46` — `"BDF"` — reported as built, while a string `message_payload` was cut to its
low octets whatever `data_coding` said. Goal 2 owns it, as it owns the `sendSms()` guard above. low octets whatever `data_coding` said. Goal 2 owns it, as it owns the `sendSms()` guard above.
The line falls at the string: a `Buffer` is octets the caller already chose and goes out as given The line falls at the string: a `Buffer` is octets the caller already chose and goes out as given
under any `data_coding`, which is what keeps goal 8's escape hatch open — the raw UDH, 8-bit binary under any `data_coding`, which is what keeps goal 7's escape hatch open — the raw UDH, 8-bit binary
and deliberately malformed bodies `interop-tests/` builds are all still buildable — and a string and deliberately malformed bodies `interop-tests/` builds are all still buildable — and a string
with no `data_coding` is untouched, detection carrying every character it was picked for. The with no `data_coding` is untouched, detection carrying every character it was picked for. The
guard is `unencodable()` again rather than a second reading, and `unencodableText()` is the guard is `unencodable()` again rather than a second reading, and `unencodableText()` is the
character, its code point and its index said once for both refusals — unexported where character, its code point and its index said once for both refusals — unexported where
`unencodable()` is published, since wording `{ char, index }` into a sentence rewrites no read a `unencodable()` is published, since wording `{ char, index }` into a sentence rewrites no read a
caller would get wrong, where asking the codec is, and publishing it would freeze this library's caller would get wrong, where asking the codec is, and publishing it would freeze this library's
error prose as API for an application whose own refusal should read like itself. Goal 8, from the error prose as API for an application whose own refusal should read like itself. Goal 7, from the
architecture review of [#99](https://github.com/larvit/larvitsmpp/pull/99), 2026-09-09. It is architecture review of [#99](https://github.com/larvit/larvitsmpp/pull/99), 2026-09-09. It is
reached through reached through
`encodeBody()` in `message.ts`, which is where the `data_coding`-to-text pair already lives: `encodeBody()` in `message.ts`, which is where the `data_coding`-to-text pair already lives:
@@ -439,8 +443,8 @@ rule and an index of the titles below.
the guard would be a second spelling that disagrees about correctness. Rejected: refusing a string the guard would be a second spelling that disagrees about correctness. Rejected: refusing a string
`message_payload` outright and demanding `message_payload` outright and demanding
octets, which contradicts `short_message` on the same PDU. Rejected: guarding every string-valued octets, which contradicts `short_message` on the same PDU. Rejected: guarding every string-valued
field against `data_coding`, which says nothing about them — a text field on the wire has an field, which `data_coding` says nothing about — an address is a C-Octet String and ASCII by 3.4's
alphabet of its own. own definition.
- **A GSM 03.38 message declares `data_coding` 0x00, and an inbound 0x01 is still read as GSM.** - **A GSM 03.38 message declares `data_coding` 0x00, and an inbound 0x01 is still read as GSM.**
Maintainer's call, 2026-09-09: `dataCodingFor()` and `encodeBody()` both resolved an alphabet Maintainer's call, 2026-09-09: `dataCodingFor()` and `encodeBody()` both resolved an alphabet
@@ -474,25 +478,6 @@ rule and an index of the titles below.
concatenation reference rather than on `data_coding` — so a receipt or a segment that crossed the concatenation reference rather than on `data_coding` — so a receipt or a segment that crossed the
change reads exactly as it did. change reads exactly as it did.
- **Every text field on the wire is latin1, and what the field cannot carry is refused rather than
truncated.** Maintainer's call, 2026-09-21, the refusals from the security and stability passes on
[#16](https://gitea.larvit.se/larvit/smpp-js/pulls/16). 3.4 calls these fields ASCII, so goal 3
settles the read alone — its generous clause is scoped to reading, and its sender clause is strict.
Goal 1 settles the write, being 3.4 as SMSCs actually run it: an operator routing an alphanumeric
sender through the upper half is traffic to keep, and Node's `ascii` write already put those octets
on the wire, so naming the write latin1 makes the round trip idempotent and no peer sees a change.
Goal 2 settles the two latin1 refusals, each a `size()` that would have agreed with a `write()`
that put something else on the wire: a character past `U+00FF` written as its low octet, and a caller's
own `U+0000`, which a mandatory field's reader takes as the end of the field. Goal 4 settles them
twice over: for one character in every 256 that low octet is `0x00`, and the PDU went out malformed
on the operator's parser. `wantText()` and `wantCstringText()` are the two places that decide the
refusals; every read and write spells `latin1` itself. Rejected: reading latin1 and leaving the write spelled ASCII, which leaves two halves agreeing
only by accident. Rejected: refusing the upper half on send
to stay strict to 3.4's ASCII, which would be a new restriction taking away traffic this library
already sends and operators already accept, on no defect. Rejected: refusing `U+0000` in every
text field, which would buy one spelling by taking a legitimate octet away from the
length-prefixed Octet String, whose length octet is what ends it.
## The session's life ## The session's life
- **A close arriving after our own `unbind` is a clean unbind, not an error.** Maintainer's call, - **A close arriving after our own `unbind` is a clean unbind, not an error.** Maintainer's call,
@@ -584,16 +569,12 @@ rule and an index of the titles below.
reports each session's unfinished drain through `serverError`, because its own result says nothing reports each session's unfinished drain through `serverError`, because its own result says nothing
but that the listener stopped. but that the listener stopped.
- **`sendSms()` puts every segment of a message on the wire together.** Goal 6: a long message costs
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 - **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 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 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 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 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 ([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 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 `<base>-<n>`, the notation `sms-id.ts` owns generated when it opens and each segment is answered `<base>-<n>`, the notation `sms-id.ts` owns
and `DlrMerger` reads back. The id is therefore fixed by the first segment, which is why an `smsId` and `DlrMerger` reads back. The id is therefore fixed by the first segment, which is why an `smsId`
@@ -603,7 +584,7 @@ rule and an index of the titles below.
untouched, and is where a caller-chosen id and a refusal live; `onRequest` is the escape hatch for 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()` 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 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 in miniature: the field that numbered it where the segment belongs to no group, `ESME_RMSGQFUL`
where the segment's own arrival overran the octet cap, since a peer told that still holds it. Rejected: 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 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 segments accepted and one refused with nothing in SMPP to retract the rest, and still cannot honour
@@ -614,7 +595,7 @@ rule and an index of the titles below.
dropped with the link — is traffic the peer will not send again, so each one reaches `sessionError` 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 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 `sms` ever fired 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 application could act on, and goal 7 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 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 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 `sessionError` — a peer that re-sends after the drop is the smaller risk than dropping a message
@@ -626,7 +607,7 @@ rule and an index of the titles below.
of the multipart change: `server()` filled the session's only `onRequest` slot, so the escape hatch of the multipart change: `server()` filled the session's only `onRequest` slot, so the escape hatch
the error above names was reachable only by hand-wiring a `Session` over a raw socket, giving up the error above names was reachable only by hand-wiring a `Session` over a raw socket, giving up
bind acceptance, `authenticate`, the session set and the drain `close()` runs over it — which is bind acceptance, `authenticate`, the session set and the drain `close()` runs over it — which is
what goal 8 means by beating "the application can do this itself". What the library verifies is the what goal 7 means by beating "the application can do this itself". What the library verifies is the
ordering rather than the hook's honesty about answering: the hook is consulted only for a non-bind ordering rather than the hook's honesty about answering: the hook is consulted only for a non-bind
request on a session already bound, so no bind — a second one on a live session included — and request on a session already bound, so no bind — a second one on a live session included — and
nothing a peer sends before one can be intercepted however the hook is written. One nothing a peer sends before one can be intercepted however the hook is written. One
@@ -664,40 +645,32 @@ rule and an index of the titles below.
down while the application was still answering a `submit_sm`, so the peer timed out and re-sent — 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 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 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()` message, so it is the one the drain waits for. Counting every inbound request until `sendReturn()` answered it was rejected —
answered it was rejected: an `onRequest` that deliberately answers nothing would then cost a full an `onRequest` that deliberately answers nothing would then cost a full `shutdownTimeout` on every
`shutdownTimeout` on every close. The response reaching the wire ends the wait, so a `sendResp()` close — and a message no listener took is released at once, since nothing is going to answer it.
the library refused or the socket would not carry leaves `close()` still reporting the message the A listener that failed before answering gives it up the same way, but only once every listener has:
peer is owed. a throw stops `emit()` where it stands, while a rejection leaves the others running, so the release
waits for the last of them rather than answering on their behalf. What ends the wait is the response
- **The drain's wait on the application ignores `shutdownTimeout: 0`.** Waiting forever is safe for reaching the wire, not the call — a `sendResp()` the library refused, or one the socket would not
the peer, whose every request is bounded by `responseTimeout` unless the caller set that to 0 as carry, leaves the message held, so `close()` still reports the one the peer is owed. Where the
well, and unsafe for the application, which nothing bounds — `close()` is what you reach for when segments were answered as they arrived there is no response left to write, so the call itself ends
the application is stuck, so it may not block on the application coming unstuck. That half falls the wait, an argument the library refuses excepted. `teardown()`
back to `responseTimeout`, the same answer the link gate's hold already takes — and to that drops what is still held for the same reason it drops inbound segments. The release is one turn
option's default where it is 0 as well, since neither option is an answer about the application. late, so a listener that sends its receipt straight after the response is still holding when the
drain looks; `sendDlr()` is the one send that goes out past the drain's refusal, and only while the
- **What the application holds unanswered is capped on constants, and a message past the cap is message is still held — past that it is an ordinary send, because the drain it would slip past is
refused.** A bound the application cannot raise is the point: an application that answers nothing no longer waiting for it. `shutdownTimeout: 0` does not carry over to this half:
would otherwise hold ever more messages, which goal 4 forbids. Reassembly's `maxOctets` is an waiting forever is safe for the peer, whose every request is bounded by `responseTimeout` unless the
option because it bounds what the peer sends; this bounds what the application leaves unanswered. caller set that to 0 as well, and unsafe for the application, which nothing bounds — `close()` is
Maintainer's call, 2026-09-26. Refusing leaves the message with the peer, which will send it again what you reach for when the application is stuck, so it may not block on the application coming
(goal 2). Rejected: dropping the oldest to make room, which frees nothing while the application unstuck. That half falls back to `responseTimeout`, the same answer the link gate's hold already
still holds its `Sms`, and stops the drain waiting for a message the peer is owed. Rejected: takes — and to that option's default where it is 0 as well, since neither option is an answer about
pausing the socket, which also stalls every answer and `enquire_link` on the link. Reaching the the application. What is held is capped and expiring like every other inbound store, on constants
bound shows only in the log (goal 8): an event or a public count would be surface for what the rather than options, because a bound the application cannot raise is the point: an application that
application already knows, since it is the one not answering. A message held past its timeout is answers nothing would otherwise grow it for the life of the link, which goal 4 forbids. A message
still dropped, so `close()` can report fewer unanswered than there were — accepted, because the that falls out of the bound is one the drain stops waiting for, so `close()` can report fewer
alternative is holding what nothing will answer. unanswered than there were — accepted, because the alternative is holding what nothing will answer,
and both exits are logged.
- **A store at its bound answers `ESME_RTHROTTLED` to a submission and `ESME_RX_T_APPN` to a
delivery, a `data_sm` by whichever it stands in for.** Maintainer's call, 2026-09-26, for
reassembly and held messages alike, so "keep it and retry" has one spelling per direction.
`ESME_RTHROTTLED` asks the sender to slow down, which is what the peer outrunning us needs, and
operators send it (Vonage, LINK Mobility, Route Mobile, Jasmin), so clients built against them
meet it (goal 1). Rejected: `ESME_RMSGQFUL`, which names an exhausted queue and no rate.
`ESME_RTHROTTLED` is the SMSC's to send, so an ESME answers with SMPP 3.4's temporary receiver
error, the one an SMSC retries on (goal 3).
- **A reconnect keeps the delivery-receipt merges; everything else the link held is dropped.** - **A reconnect keeps the delivery-receipt merges; everything else the link held is dropped.**
`onDelivery()` answers each receipt before the group it belongs to is complete, and `teardown()` `onDelivery()` answers each receipt before the group it belongs to is complete, and `teardown()`
@@ -797,7 +770,7 @@ rule and an index of the titles below.
dev image has no openssl. dev image has no openssl.
- **`src/` stays flat until a module has to move for another reason.** Architecture review, - **`src/` stays flat until a module has to move for another reason.** Architecture review,
2026-09-06: the grouping the [file map](../AGENTS.md#architecture) already implies — `wire/` for `pdu*` and `defs`, 2026-09-06: the grouping the file map above already implies — `wire/` for `pdu*` and `defs`,
`link/` for `link-*`, `reconnect-*`, `pdu-transport` and `send-window`, `messages/` for `sms*`, `link/` for `link-*`, `reconnect-*`, `pdu-transport` and `send-window`, `messages/` for `sms*`,
`dlr*`, `message*`, `reassembly` and `udh` — rewrites every import for no change to `dlr*`, `message*`, `reassembly` and `udh` — rewrites every import for no change to
`dist/index.js`, the one published entry. Valid while that map is what a reader navigates by. `dist/index.js`, the one published entry. Valid while that map is what a reader navigates by.
@@ -815,12 +788,5 @@ rule and an index of the titles below.
call, 2026-09-14. Nothing verifies either platform, so the code avoids what is known to differ there: call, 2026-09-14. Nothing verifies either platform, so the code avoids what is known to differ there:
shelling out, a path joined by hand, a signal Windows does not deliver, a Unix socket or a file mode. shelling out, a path joined by hand, a signal Windows does not deliver, a Unix socket or a file mode.
That binds what `dist/` runs; the container tooling, `interop-tests/` and the `package.json` scripts That binds what `dist/` runs; the container tooling, `interop-tests/` and the `package.json` scripts
run on Linux by goal 10. Rejected: macOS and Windows runners, on GitHub's mirror or as Gitea run on Linux by goal 9. Rejected: macOS and Windows runners, on GitHub's mirror or as Gitea
host-mode runners on a Windows VM and a Mac. host-mode runners on a Windows VM and a Mac.
- **GitHub mirrors Gitea without pruning, and a ref deleted on Gitea is deleted on GitHub by a run of
its own.** Maintainer's call, 2026-09-14; valid while nothing deploys from GitHub.
`.gitea/workflows/mirror.yaml` never prunes, and `mirror-delete.yaml` runs once per deleted ref. A
delete run that fails or outlives Gitea's queue timeout, or a push run that cloned before the
delete, leaves the ref on GitHub until the delete run is re-run. Accepted: a stale ref there is
harmless, and refs only GitHub has must survive.
+14 -24
View File
@@ -12,37 +12,27 @@ x-dumbclient-healthcheck: &dumbclient-healthcheck
timeout: 2s timeout: 2s
services: services:
# Network owner for every dumbclient-* service and the capture sidecar below: all four are pure # Network owner for every dumbclient-* service and the capture sidecar below (see the comment on
# outbound TCP clients, so sharing one netns is only a source-IP detail. It never exits, because a # `capture`): all four are pure outbound TCP clients with nothing of their own listening, so
# client that finishes early would take the namespace, and every other conversation, with it. # sharing one netns is only ever a source-IP detail, never a port collision.
dumbclient-netns:
image: nicolaka/netshoot:v0.16
<<: *log-limits
command: ["sleep", "infinity"]
init: true
dumbclient-w2000: dumbclient-w2000:
build: ./interop-tests/peers/dumbclient build: ./interop-tests/peers/dumbclient
image: interop-dumbclient:de0334b image: interop-dumbclient:de0334b
<<: *log-limits <<: *log-limits
network_mode: "service:dumbclient-netns"
command: ["conf/window2000.yml"] command: ["conf/window2000.yml"]
healthcheck: *dumbclient-healthcheck healthcheck: *dumbclient-healthcheck
depends_on:
dumbclient-netns:
condition: service_started
# S9's comparison run: window below maxHeldMessages (1000, session-options.ts), where nothing # S9's comparison run: window below maxHeldMessages (1000, session-options.ts), where nothing
# should ever be throttled - see findings/07-load.md. # should ever be evicted - see findings/07-load.md.
dumbclient-w500: dumbclient-w500:
build: ./interop-tests/peers/dumbclient build: ./interop-tests/peers/dumbclient
image: interop-dumbclient:de0334b image: interop-dumbclient:de0334b
<<: *log-limits <<: *log-limits
network_mode: "service:dumbclient-netns" network_mode: "service:dumbclient-w2000"
command: ["conf/window500.yml"] command: ["conf/window500.yml"]
healthcheck: *dumbclient-healthcheck healthcheck: *dumbclient-healthcheck
depends_on: depends_on:
dumbclient-netns: dumbclient-w2000:
condition: service_started condition: service_started
# S6: sends one message, then never speaks again - the no-ping binary (see the Dockerfile) sends # S6: sends one message, then never speaks again - the no-ping binary (see the Dockerfile) sends
@@ -51,13 +41,13 @@ services:
build: ./interop-tests/peers/dumbclient build: ./interop-tests/peers/dumbclient
image: interop-dumbclient:de0334b image: interop-dumbclient:de0334b
<<: *log-limits <<: *log-limits
network_mode: "service:dumbclient-netns" network_mode: "service:dumbclient-w2000"
environment: environment:
DUMBCLIENT_BIN: /app/smpp-dumb-client-noping DUMBCLIENT_BIN: /app/smpp-dumb-client-noping
command: ["conf/idle.yml"] command: ["conf/idle.yml"]
healthcheck: *dumbclient-healthcheck healthcheck: *dumbclient-healthcheck
depends_on: depends_on:
dumbclient-netns: dumbclient-w2000:
condition: service_started condition: service_started
# The long soak: the longest run the time-box allows, fast handler, watched for anything that # The long soak: the longest run the time-box allows, fast handler, watched for anything that
@@ -66,25 +56,25 @@ services:
build: ./interop-tests/peers/dumbclient build: ./interop-tests/peers/dumbclient
image: interop-dumbclient:de0334b image: interop-dumbclient:de0334b
<<: *log-limits <<: *log-limits
network_mode: "service:dumbclient-netns" network_mode: "service:dumbclient-w2000"
command: ["conf/soak.yml"] command: ["conf/soak.yml"]
healthcheck: *dumbclient-healthcheck healthcheck: *dumbclient-healthcheck
depends_on: depends_on:
dumbclient-netns: dumbclient-w2000:
condition: service_started condition: service_started
# Every dumbclient-* service shares dumbclient-netns's namespace (see above), so this one sidecar # Every dumbclient-* service shares dumbclient-w2000's netns (see above), so this one sidecar
# sees all four conversations with node:2775 - the same pattern compose.kannel.yaml uses for its # sees all four conversations with node:2775 - the same pattern compose.kannel.yaml uses for its
# four bearerbox variants, one namespace deeper. # four bearerbox variants, one namespace deeper.
capture: capture:
image: nicolaka/netshoot:v0.16 image: nicolaka/netshoot:v0.16
network_mode: "service:dumbclient-netns" network_mode: "service:dumbclient-w2000"
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN
- NET_RAW - NET_RAW
depends_on: depends_on:
dumbclient-netns: dumbclient-w2000:
condition: service_started condition: service_healthy
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/dumbclient.pcapng"] command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/dumbclient.pcapng"]
volumes: volumes:
- ./interop-tests/captures:/captures - ./interop-tests/captures:/captures
+22 -21
View File
@@ -206,26 +206,17 @@ after(async () => {
// S9 (target 11) and the backpressure-at-server scenario: window 2000 at a high rate against a // 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 // 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 // maxHeldMessages (1000, session-options.ts defaults.maxHeldMessages) - see findings/07-load.md for
// peer's window is answered ESME_RTHROTTLED. smpp-dumb-client counts a throttled message as sent // what that constant, rather than maxOutstanding, turns out to be the one that interacts with a
// and never resends it, so window 2000 accounts for 20,000 as answered plus throttled. // peer's window.
const throttleMessage = 'session - unanswered messages at their bound, asking the peer to retry';
// window500's peak (<=500) and the soak's never reach the 1000 default, so every refusal is
// necessarily from the w2000 session - the runs share one server and one log.
function throttled(name: string): number {
return name === 'dumb-w2000' ? logEntries.filter(entry => entry.message === throttleMessage).length : 0;
}
describe('S9 - bounded window against a slowed handler', () => { describe('S9 - bounded window against a slowed handler', () => {
for (const name of ['dumb-w500', 'dumb-w2000'] as const) { for (const [name, expectedCount] of [['dumb-w500', 20_000], ['dumb-w2000', 20_000]] as const) {
test(`${name}: every message answered or throttled exactly once, ordering holds`, async () => { test(`${name}: every message answered exactly once, ordering holds`, async () => {
const done = await waitFor(() => (statsFor(name).answered + throttled(name) >= 20_000 ? true : undefined), 180_000); const done = await waitFor(() => (statsFor(name).answered >= expectedCount ? true : undefined), 180_000);
assert.ok(done, `${name} did not account for 20000 messages within budget`); assert.ok(done, `${name} did not answer ${String(expectedCount)} messages within budget`);
const s = statsFor(name); const s = statsFor(name);
const expectedCount = 20_000 - throttled(name);
assert.equal(s.arrived, expectedCount); assert.equal(s.arrived, expectedCount);
assert.equal(s.answered, expectedCount); assert.equal(s.answered, expectedCount);
@@ -236,9 +227,17 @@ 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 maxHeldMessages (1000): the internal held-message cap evicts, window500 never does', async () => {
assert.ok(throttled('dumb-w2000') > 0, 'expected at least one ESME_RTHROTTLED under window 2000'); await waitFor(() => (statsFor('dumb-w2000').answered >= 20_000 ? true : undefined), 180_000);
assert.ok(statsFor('dumb-w2000').peakOutstanding <= 1000);
const evictions = logEntries.filter(entry => entry.message === 'heldMessages - buffer full, dropping the oldest message');
// window500's peak (<=500) never reaches the 1000 default, so any eviction observed is
// necessarily from the w2000 session - the two runs share one server and one log.
assert.ok(evictions.length > 0, 'expected at least one held-message eviction under window 2000');
// The peer's own window, respected exactly both runs (peakOutstanding read 500 and 2000 on
// the nose) - the lower bound is what distinguishes this from window500's own eviction-free run.
assert.ok(statsFor('dumb-w2000').peakOutstanding > 1000 && statsFor('dumb-w2000').peakOutstanding <= 2000);
assert.equal(statsFor('dumb-w500').peakOutstanding <= 500, true); assert.equal(statsFor('dumb-w500').peakOutstanding <= 500, true);
}); });
@@ -285,8 +284,10 @@ describe('S6 - idle peer, no enquire_link at all', () => {
}); });
// The long soak: the longest run the time-box allows, fast handler, watched for anything that // The long soak: the longest run the time-box allows, fast handler, watched for anything that
// grows without bound (held messages, listeners, memory). Bounded by wall-clock, and asserting that // grows without bound (held messages, listeners, memory). Bounded by wall-clock rather than a
// arrived/answered stay in lockstep. // target count: smpp-dumb-client's own TX-tracking window bookkeeping stalls under sustained load
// (findings/07-load.md, Peer quirks) well short of the configured count, on the client's side only
// - our own arrived/answered stay in lockstep throughout, which is what this asserts.
describe('Long soak', () => { describe('Long soak', () => {
const SOAK_DURATION_MS = 300_000; const SOAK_DURATION_MS = 300_000;
+53 -55
View File
@@ -39,33 +39,12 @@ Traced as far as `oserl`'s `smpp_pdu_syntax:pack/2` (the `trx_deadlock_fix_1` br
`rebar.config` pins), which builds the header as plain 32-bit bit-syntax `rebar.config` pins), which builds the header as plain 32-bit bit-syntax
(`<<Len:32, CmdId:32, 0:32, SeqNum:32>>`) - correct on inspection, so the corruption happens (`<<Len:32, CmdId:32, 0:32, SeqNum:32>>`) - correct on inspection, so the corruption happens
somewhere between that call and the socket write, not chased further given the time-box. Reproduced somewhere between that call and the socket write, not chased further given the time-box. Reproduced
Re-examined 2026-09-20 to see whether it could be unblocked for the throughput comparison in identically on three separate runs (byte-for-byte). Recorded as **blocked**; `smppload.test.ts`
`benchmarks/`. Four things are now established, and one earlier suspicion is ruled out:
- **It is one write, not a split one.** A raw listener that accumulates every chunk rather than
reading the first receives `40 octets across 1 chunks`, byte-identical to the 2026-09-06 capture,
with `command_length` reading 2,752,512. So the two leading zero octets are absent from the socket
write itself; nothing about our framing or the capture is involved.
- **The compiled `pack/2` is correct**, checked in the built tree rather than the repository:
`Len = size(BodyBin) + 16` written as `<<Len:32, CmdId:32, 0:32, SeqNum:32>>`, returned as the
iolist `[Header, BodyBin]`. For this bind that is 16 + 26 = 42.
- **The remaining suspect is `smpp_session.erl:158`**, which writes with `erlang:port_command/2`
rather than `gen_tcp:send/2` — an undocumented fast path in oserl code that predates OTP 27.
- **That suspect is untested.** Two attempts to swap it were both invalidated by rebar3 dep caching:
editing a fetched dependency's source does not rebuild its beam, and the `_checkouts/` route
re-verifies every dependency, which needs network and git in the build container. Whoever picks
this up should patch before the first compile, or force the dep to rebuild, and confirm the beam
actually changed before believing a result.
Enough for an upstream report — a reproducer needing no SMSC, the exact octets, and a named
suspect — but not enough for a patch, since the one-line candidate has never actually run.
Recorded as **blocked**; `smppload.test.ts`
keeps a live reproducer asserting what our server does when it receives it (refuses the stream as keeps a live reproducer asserting what our server does when it receives it (refuses the stream as
unframeable - see Scenarios) rather than removing the peer. `smpp-dumb-client` covers S9, and unframeable - see Scenarios) rather than removing the peer. `smpp-dumb-client` covers S9, and
substitutes for S6 and (partially) S8 - see below. substitutes for S6 and (partially) S8 - see below.
### smpp-dumb-client: builds and interoperates cleanly ### smpp-dumb-client: builds and interoperates cleanly; its own window bookkeeping stalls under sustained load
No build friction. Two binaries from the same pinned source: `smpp-dumb-client` (unmodified) and No build friction. Two binaries from the same pinned source: `smpp-dumb-client` (unmodified) and
`smpp-dumb-client-noping` (its two `enquireSender()` call sites in `smpp.go` commented out at build `smpp-dumb-client-noping` (its two `enquireSender()` call sites in `smpp.go` commented out at build
@@ -78,18 +57,25 @@ One integration snag, not a build one: `smpp.remote` in `config.yml` is fed stra
there directly. Fixed in the entrypoint: every `conf/*.yml` carries a `NODE_HOST` placeholder, there directly. Fixed in the entrypoint: every `conf/*.yml` carries a `NODE_HOST` placeholder,
resolved with `getent hosts` and substituted into a writable copy before the real binary starts. resolved with `getent hosts` and substituted into a writable copy before the real binary starts.
The four scenarios share one network namespace, owned by `dumbclient-netns`, a container that Four one-shot scenarios share `dumbclient-w2000`'s network namespace (`network_mode:
never exits - they are pure outbound clients with nothing of their own listening, so the only shared "service:dumbclient-w2000"`) - they are pure outbound clients with nothing of their own listening,
cost is a source IP, and one capture sidecar sees all four conversations with `node:2775` the same so the only shared cost is a source IP, and one capture sidecar sees all four conversations with
way `compose.kannel.yaml`'s does for its four bearerbox variants. `node:2775` the same way `compose.kannel.yaml`'s does for its four bearerbox variants.
Runs 1 and 2 had `dumbclient-w2000` own the namespace. It exits once it has sent its 20,000, which The long soak (below) surfaced a peer-side limit worth designing around rather than fighting: with
took every other client's network with it: the soak's responses stopped arriving, and its log filled a fast, immediate-response handler and a window of 100 - nothing our server should ever have
with `Expired TX packet` lines (`libsmpp`'s 7000ms `TX_MAX_TIMEOUT_MS`). Those runs read that as the trouble draining - the peer's own reported in-flight count (`GetTrackQueueSize`, read from
peer's own window bookkeeping stalling; run 3 (2026-09-26), with the namespace owned by a container `len(TrackTX)`) gets stuck pinned at the window within the first minute, and its log fills with
that outlives them all, reached 173,820 soak messages in 300s where run 2 reached 22,440. The soak `Expired TX packet` lines (`libsmpp`'s hardcoded, non-configurable 7000ms `TX_MAX_TIMEOUT_MS`) -
stays bounded by wall-clock (5 minutes), asserting every arrival answered, nothing duplicated, and throughput drops from ~500/s to a trickle of tens per second, gated by how many tracked entries
the memory shape. individually cross that 7s mark each second rather than by real responses being matched. Our own
server-side counters (`arrived`/`answered`/`peakOutstanding`, tracked independently in
`dumbclient.test.ts`) stay in lockstep throughout with a low peak - see Scenarios - which places the
stall entirely on the peer's own window bookkeeping, not on anything our server did or failed to
do. The soak test was redesigned around this: bounded by wall-clock (5 minutes) rather than a
target count, asserting the invariants that matter regardless of how much the peer's own bug lets
through (every arrival answered, nothing duplicated, memory shape), and reporting whatever
throughput was actually reached rather than requiring a specific one.
One test-harness bug found and fixed between the two runs below, not a library defect: the S6 test's One test-harness bug found and fixed between the two runs below, not a library defect: the S6 test's
first version attached its `session.on('close', ...)` listener lazily inside the test body, after first version attached its `session.on('close', ...)` listener lazily inside the test body, after
@@ -98,11 +84,11 @@ the S6 test ran, the idle session had already closed, and an `EventEmitter` neve
event to a listener added after it fired. Fixed by attaching every session's `close` listener at event to a listener added after it fired. Fixed by attaching every session's `close` listener at
`session`-creation time, recording it in the same per-scenario stats every other assertion reads. `session`-creation time, recording it in the same per-scenario stats every other assertion reads.
Three runs of `./interop-tests/run.py dumbclient`. Run 1 (the original 300,000-count soak) surfaced Two runs of `./interop-tests/run.py dumbclient`. Run 1 (the original 300,000-count soak) surfaced
the S6 harness bug above; run 2 fixed it; run 3, with the namespace owner above and the held-message both the peer's TX-tracking stall and the S6 harness bug above; run 2, after both fixes, is the one
throttle in `src/`, is the one Scenarios reports. The capture figures below are run 2's. reported below. `smppload.test.ts` passed on every run it was given (three, across the investigation
`smppload.test.ts` passed on every run it was given (three, across the investigation above); its one above); its one scenario needs no repeat - a second run reproduces the identical corrupted PDU,
scenario needs no repeat - a second run reproduces the identical corrupted PDU, adding nothing. adding nothing.
``` ```
dumbclient run 2: frames 111300, bind_transceiver 4/4, enquire_link 12 (enquire_link_resp 9 - the dumbclient run 2: frames 111300, bind_transceiver 4/4, enquire_link 12 (enquire_link_resp 9 - the
@@ -120,24 +106,29 @@ every session's own `arrived` exactly, and every session's own `answered` matche
## Throughput and memory ## Throughput and memory
Run 3. `dumb-w500` ran to its full 20,000 in ~44s against a handler serialised to answer roughly `dumb-w500` and `dumb-w2000` (S9) both ran to their full 20,000-message count in ~44s each,
one message every 2ms (`SLOW_HANDLER_DELAY_MS`), `peakOutstanding` exactly 500. `dumb-w2000`, run concurrently, against a handler serialised to answer roughly one message every 2ms
concurrently, held exactly 1000 and was throttled for the rest. (`SLOW_HANDLER_DELAY_MS`) - `peakOutstanding` read exactly 500 and exactly 2000, the two configured
windows, confirming the peer never let more than its own window ride at once.
The soak (fast, immediate-response handler; window 100) reached 173,820 `submit_sm` over its fixed The soak (fast, immediate-response handler; window 100) reached 22,440 `submit_sm` over its fixed
300s, about 580/s, `peakOutstanding` 15. Sampled every 5s across the whole run (69 samples over 300s observation window - about 75/s, well under the peer's own configured `rate: 500` and under
340s, all four scenarios combined, the harness's own per-message bookkeeping included): rss what our server can sustain (see Setup: `smpp-dumb-client`'s own TX-tracking bookkeeping is the
first=165MiB, min=165MiB, max=298MiB, last=298MiB, heapUsed at the last sample 81MiB. ceiling here, not our server - `peakOutstanding` stayed at 25 throughout). Sampled every 5s across
the whole run (69 samples over 340s, all four scenarios combined): rss first=170MiB, min=124MiB,
max=306MiB (during the two window runs' backlog), last=125MiB, heapUsed at the last sample 15MiB -
back below its own starting point once the backlog drained, not merely flat. No monotonic trend in
either direction.
## Scenarios (PLAN.md) ## Scenarios (PLAN.md)
| Id | Result | Evidence | | Id | Result | Evidence |
| --- | --- | --- | | --- | --- | --- |
| S6 (idleTimeout, no peer ever pings) | pass | `dumbclient.test.ts` "S6 - idle peer..." - dropped at idleTimeout, `linkTimers - closing an idle peer` logged, no response past the one owed | | S6 (idleTimeout, no peer ever pings) | pass | `dumbclient.test.ts` "S6 - idle peer..." - dropped at idleTimeout, `linkTimers - closing an idle peer` logged, no response past the one owed |
| S8 (throughput, long messages, receipts) | blocked (smppload) / partial substitute | smppload's own scenario is blocked - see Setup. The soak below gives a genuine submit_sm/s figure without long messages or receipts, which `smpp-dumb-client` does not support (`research/esme-clients-and-validators.md` section B) | | S8 (throughput, long messages, receipts) | blocked (smppload) / partial substitute | smppload's own scenario is blocked - see Setup. The soak below gives a genuine submit_sm/s figure without long messages or receipts, which `smpp-dumb-client` does not support (`research/esme-clients-and-validators.md` section B) - and is itself capped well below what our server can sustain by the peer's own TX-tracking stall, also see Setup |
| S9 (bounded window) | pass | Run 3: `dumbclient.test.ts` "S9 - bounded window..." - window 500 20,000/20,000 answered in ~44s, `peakOutstanding` exactly 500; window 2000 5,639 answered and 14,361 throttled, in arrival order, no duplicate ids | | S9 (bounded window) | pass | `dumbclient.test.ts` "S9 - bounded window..." - 20,000/20,000 answered on both window 500 and window 2000, in arrival order, no duplicate ids, `peakOutstanding` exactly 500 and exactly 2000 |
| Backpressure at the server | pass | Run 3: window 2000 holds exactly 1000 (`maxHeldMessages`, session-options.ts) and the rest is answered `ESME_RTHROTTLED`; smpp-dumb-client counts a throttled message as sent and never resends it; window 500 is never throttled | | Backpressure at the server | pass | Same run: `peakOutstanding` 2000 exceeds `maxHeldMessages` (1000, session-options.ts) and the eviction warning fires; window 500 (`peakOutstanding` 500) never does; memory sampled before/after the window runs (170MiB before, 306MiB after, 125MiB once the soak's own run had also settled) |
| Long soak | pass | Run 3: `dumbclient.test.ts` "Long soak" - 173,820 arrived, 173,820 answered, 0 duplicates, 0 unanswered errors, `close()` drains with no error; rss 165MiB first, 298MiB max and last, heapUsed 81MiB last | | Long soak | pass (run once at the redesigned, wall-clock-bounded shape - see Setup) | `dumbclient.test.ts` "Long soak" - 22,440 arrived, 22,440 answered, 0 duplicates, 0 unanswered errors, `close()` drains with no error |
| smppload bind corruption (not in PLAN.md - found this phase) | blocked | `smppload.test.ts` - our server refuses the unreadable stream instead of hanging | | smppload bind corruption (not in PLAN.md - found this phase) | blocked | `smppload.test.ts` - our server refuses the unreadable stream instead of hanging |
## Defects in @larvit/smpp ## Defects in @larvit/smpp
@@ -145,16 +136,19 @@ first=165MiB, min=165MiB, max=298MiB, last=298MiB, heapUsed at the last sample 8
None found. `smppload.test.ts`'s own scenario is smppload's defect, not ours: our server's reaction None found. `smppload.test.ts`'s own scenario is smppload's defect, not ours: our server's reaction
(refusing the stream as unframeable, per the decision in the root `AGENTS.md`, "A stream this (refusing the stream as unframeable, per the decision in the root `AGENTS.md`, "A stream this
library cannot frame...") is the documented behaviour working exactly as designed against a peer library cannot frame...") is the documented behaviour working exactly as designed against a peer
that never gets as far as a readable PDU. that never gets as far as a readable PDU. The soak's throughput ceiling is the peer's own defect
(see Setup) - our own `arrived`/`answered`/`peakOutstanding` counters stayed clean throughout every
run.
## Peer quirks ## Peer quirks
- **smppload's `bind_transceiver` is corrupted on the wire** - see Setup. Not chased past `oserl`'s - **smppload's `bind_transceiver` is corrupted on the wire** - see Setup. Not chased past `oserl`'s
`pack/2` (which is correct on inspection) given the time-box. `pack/2` (which is correct on inspection) given the time-box.
- **`smpp-dumb-client` treats `ESME_RTHROTTLED` as final** - a throttled message counts as sent - **`smpp-dumb-client`'s window bookkeeping stalls under sustained load, throttling its own
and is never resubmitted. Its `enquire_link` interval (10s once bound as an ESME) is hardcoded throughput far below what a promptly-answering server can sustain** - see Setup. Its `enquire_link`
(`smpp.go`, `enquireSender(10)`), not exposed through `config.yml` at all - the no-ping binary interval (10s once bound as an ESME) is also hardcoded (`smpp.go`, `enquireSender(10)`), not
built for S6 patches the call site out rather than configuring it. exposed through `config.yml` at all - the no-ping binary built for S6 patches the call site out
rather than configuring it.
- **`smpp.remote` takes a literal IP, never a hostname** (`net.ParseIP`, no DNS resolution) - see - **`smpp.remote` takes a literal IP, never a hostname** (`net.ParseIP`, no DNS resolution) - see
Setup. Setup.
@@ -162,3 +156,7 @@ that never gets as far as a readable PDU.
- Whether smppload's bind corruption is in `oserl`'s `gen_esme_session`/`smpp_session` send path - Whether smppload's bind corruption is in `oserl`'s `gen_esme_session`/`smpp_session` send path
(not reached, given the time-box) or something specific to this build's dependency versions. (not reached, given the time-box) or something specific to this build's dependency versions.
- Whether `smpp-dumb-client`'s stall is a sequence-number correlation bug (a response failing to
match its `TrackTX` entry, falling back to the 7s expiry) or something else in its own window
accounting - not chased past the observation in Setup, given the time-box and that the fault is
clearly on the peer's side (our own counters stayed clean throughout).
@@ -64,7 +64,6 @@ public final class Driver {
server.createContext("/bind", Driver::handleBind); server.createContext("/bind", Driver::handleBind);
server.createContext("/unbind", Driver::handleUnbind); server.createContext("/unbind", Driver::handleUnbind);
server.createContext("/submit", Driver::handleSubmit); server.createContext("/submit", Driver::handleSubmit);
server.createContext("/load", Driver::handleLoad);
server.createContext("/windowBurst", Driver::handleWindowBurst); server.createContext("/windowBurst", Driver::handleWindowBurst);
server.createContext("/sendWindowSize", Driver::handleSendWindowSize); server.createContext("/sendWindowSize", Driver::handleSendWindowSize);
server.setExecutor(null); server.setExecutor(null);
@@ -225,62 +224,6 @@ public final class Driver {
} }
} }
/**
* Pushes count messages and reports only the rate. Cloudhopper's submit blocks on the response,
* so the pool size is what puts requests in flight — the same shape as the other peers' load.
*/
private static void handleLoad(HttpExchange exchange) {
Map<String, String> p = queryParams(exchange);
SmppSession session = sessions.get(p.getOrDefault("session", "default"));
if (session == null) {
respond(exchange, 200, Json.write(Map.of("ok", false, "error", "no such session")));
return;
}
int count = Integer.parseInt(p.getOrDefault("count", "20000"));
int concurrency = Integer.parseInt(p.getOrDefault("concurrency", "50"));
long timeoutMs = Long.parseLong(p.getOrDefault("timeoutMs", "60000"));
String from = p.getOrDefault("from", "1000");
String to = p.getOrDefault("to", "2000");
AtomicInteger issued = new AtomicInteger();
AtomicInteger failed = new AtomicInteger();
ExecutorService pool = Executors.newFixedThreadPool(concurrency);
long started = System.nanoTime();
for (int worker = 0; worker < concurrency; worker++) {
pool.execute(() -> {
while (issued.getAndIncrement() < count) {
try {
session.submit(buildSubmit(from, to, "benchmark"), timeoutMs);
} catch (Exception e) {
failed.incrementAndGet();
}
}
});
}
pool.shutdown();
try {
if (!pool.awaitTermination(10, java.util.concurrent.TimeUnit.MINUTES)) pool.shutdownNow();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
double seconds = (System.nanoTime() - started) / 1e9;
Map<String, Object> result = new LinkedHashMap<>();
result.put("ok", true);
result.put("count", count);
result.put("concurrency", concurrency);
result.put("failed", failed.get());
result.put("seconds", Math.round(seconds * 1000d) / 1000d);
result.put("perSecond", Math.round(count / seconds));
respond(exchange, 200, Json.write(result));
}
/** Fires `count` submits at once, each tagged by index in its text, to probe window pressure. */ /** Fires `count` submits at once, each tagged by index in its text, to probe window pressure. */
private static void handleWindowBurst(HttpExchange exchange) { private static void handleWindowBurst(HttpExchange exchange) {
Map<String, String> p = queryParams(exchange); Map<String, String> p = queryParams(exchange);
@@ -38,10 +38,6 @@ import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/** /**
* An HTTP-driven jsmpp ESME: each request binds (if needed), performs one scenario action against * An HTTP-driven jsmpp ESME: each request binds (if needed), performs one scenario action against
@@ -68,7 +64,6 @@ public final class Driver {
server.createContext("/unbind", Driver::handleUnbind); server.createContext("/unbind", Driver::handleUnbind);
server.createContext("/enquireLink", Driver::handleEnquireLink); server.createContext("/enquireLink", Driver::handleEnquireLink);
server.createContext("/submit", Driver::handleSubmit); server.createContext("/submit", Driver::handleSubmit);
server.createContext("/load", Driver::handleLoad);
server.createContext("/querySm", exchange -> handleUnhandledCommand(exchange, "query")); server.createContext("/querySm", exchange -> handleUnhandledCommand(exchange, "query"));
server.createContext("/cancelSm", exchange -> handleUnhandledCommand(exchange, "cancel")); server.createContext("/cancelSm", exchange -> handleUnhandledCommand(exchange, "cancel"));
server.createContext("/replaceSm", exchange -> handleUnhandledCommand(exchange, "replace")); server.createContext("/replaceSm", exchange -> handleUnhandledCommand(exchange, "replace"));
@@ -272,70 +267,6 @@ public final class Driver {
} }
} }
/**
* Pushes count messages over an already-bound session and reports the rate. jsmpp's submit is
* blocking, so threads are what put requests in flight here — the window is the pool size.
*/
private static void handleLoad(HttpExchange exchange) {
Map<String, String> p = queryParams(exchange);
SMPPSession session = sessions.get(p.getOrDefault("session", "default"));
if (session == null) {
Map<String, Object> missing = new LinkedHashMap<>();
missing.put("ok", false);
missing.put("error", "no such session");
respondOk(exchange, missing);
return;
}
int count = Integer.parseInt(p.getOrDefault("count", "20000"));
int concurrency = Integer.parseInt(p.getOrDefault("concurrency", "50"));
String from = p.getOrDefault("from", "BENCH");
String to = p.getOrDefault("to", "46709771337");
String text = p.getOrDefault("text", "benchmark");
AtomicInteger issued = new AtomicInteger();
AtomicInteger failed = new AtomicInteger();
ExecutorService pool = Executors.newFixedThreadPool(concurrency);
long started = System.nanoTime();
for (int worker = 0; worker < concurrency; worker++) {
pool.execute(() -> {
while (issued.getAndIncrement() < count) {
try {
session.submitShortMessage("CMT",
TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, from,
TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, to,
new ESMClass(), (byte) 0, (byte) 1, null, null,
new RegisteredDelivery(SMSCDeliveryReceipt.DEFAULT), (byte) 0,
dataCoding("ascii"), (byte) 0, encode(text, "ascii"));
} catch (Exception e) {
failed.incrementAndGet();
}
}
});
}
pool.shutdown();
try {
if (!pool.awaitTermination(10, TimeUnit.MINUTES)) pool.shutdownNow();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
double seconds = (System.nanoTime() - started) / 1e9;
Map<String, Object> result = new LinkedHashMap<>();
result.put("ok", true);
result.put("count", count);
result.put("concurrency", concurrency);
result.put("failed", failed.get());
result.put("seconds", Math.round(seconds * 1000d) / 1000d);
result.put("perSecond", Math.round(count / seconds));
respondOk(exchange, result);
}
private static void submitPlain(SMPPSession session, String from, String to, String text, String encoding, private static void submitPlain(SMPPSession session, String from, String to, String text, String encoding,
java.util.List<Map<String, Object>> segments) throws Exception { java.util.List<Map<String, Object>> segments) throws Exception {
SubmitSmResult r = session.submitShortMessage("CMT", SubmitSmResult r = session.submitShortMessage("CMT",
+2
View File
@@ -4,6 +4,7 @@ import { buffer, cstring, dest_address_array, int8, unsuccess_sme_array } from '
type CommandSpec = { type CommandSpec = {
id: number; id: number;
params?: Record<string, WireType>; params?: Record<string, WireType>;
tlvMap?: Record<string, string>;
}; };
const bindParams = { const bindParams = {
@@ -58,6 +59,7 @@ const specs = {
broadcast_sm_resp: { broadcast_sm_resp: {
id: 0x80000111, id: 0x80000111,
params: { message_id: cstring }, params: { message_id: cstring },
tlvMap: { broadcast_area_identifier: 'failed_broadcast_area_identifier' },
}, },
cancel_broadcast_sm: { cancel_broadcast_sm: {
id: 0x00000113, id: 0x00000113,
+24 -106
View File
@@ -1,15 +1,18 @@
import type { ParamValue, TlvValue, WireType } from './types.ts'; import type { ParamValue, WireType } from './types.ts';
import type { Result } from '../result.ts'; import type { Result } from '../result.ts';
import { tlv } from './types.ts'; import { tlv } from './types.ts';
/** Only a tag read as octets or as a number may repeat, since its occurrences are listed as one of those. */ export type TlvDefinition = {
type Definition<Tag> = { id: number; multiple?: false; tag: Tag; type: WireType<Buffer | number | string> } id: number;
| { id: number; multiple: true; tag: Tag; type: WireType<Buffer> | WireType<number> }; multiple?: boolean;
tag: string;
export type TlvDefinition = Definition<string>; type: WireType;
};
/** The constraint keys every definition to its own name, so a `tag` that drifts fails to compile. */ /** The constraint keys every definition to its own name, so a `tag` that drifts fails to compile. */
const tlvSpecs = <T extends { [K in keyof T]: Definition<K> }>(definitions: T): T => definitions; const tlvSpecs = <T extends { [K in keyof T]: { id: number; multiple?: boolean; tag: K; type: WireType } }>(
definitions: T,
): T => definitions;
// Ordered by tag id, mirroring the SMPP 5.0 TLV table. // Ordered by tag id, mirroring the SMPP 5.0 TLV table.
const specs = tlvSpecs({ const specs = tlvSpecs({
@@ -95,18 +98,18 @@ for (const definition of Object.values<TlvDefinition>(specs)) {
} }
/** Fallback for tags this table does not know: keep the raw octets. */ /** Fallback for tags this table does not know: keep the raw octets. */
export const tlvDefault: WireType<Buffer> = tlv.buffer; export const tlvDefault: WireType = tlv.buffer;
export type Tlv = { export type Tlv = {
tagId: number; tagId: number;
tagName: string | undefined; tagName: string | undefined;
tagValue: TlvValue; tagValue: ParamValue;
}; };
export type TlvInput = { export type TlvInput = {
/** Resolved from the record key; pass it for a tag the TLV table does not define. */ /** Resolved from the record key; pass it for a tag the TLV table does not define. */
tagId?: number | undefined; tagId?: number | undefined;
tagValue: TlvValue; tagValue: ParamValue;
}; };
export function tagIdOf(name: string, input: TlvInput): Result<{ tagId: number }> { export function tagIdOf(name: string, input: TlvInput): Result<{ tagId: number }> {
@@ -132,115 +135,30 @@ export function writeTlvs(inputs: Record<string, TlvInput> | undefined): Result<
if (tag.err) return { err: tag.err }; if (tag.err) return { err: tag.err };
const definition = tlvsById[tag.tagId]; const type = tlvsById[tag.tagId]?.type ?? tlvDefault;
const values = occurrences(input.tagValue, definition?.multiple === true); const sized = type.size(input.tagValue);
if (values.err) return { err: new Error(`TLV "${name}": ${values.err.message}`) }; if (sized.err) {
return { err: new Error(`TLV "${name}": ${sized.err.message}`) };
for (const value of values.values) {
const chunk = writeTlv(tag.tagId, definition?.type ?? tlvDefault, value);
if (chunk.err) return { err: new Error(`TLV "${name}": ${chunk.err.message}`) };
chunks.push(chunk.chunk);
} }
}
return { chunks };
}
function occurrences(value: TlvValue, multiple: boolean): Result<{ values: ParamValue[] }> {
if (!multiple) {
return Array.isArray(value) ? { err: new Error('takes one value, not an array') } : { values: [value] };
}
if (!Array.isArray(value)) return { err: new Error('is repeatable, wrap it in an array: [value]') };
if (value.length === 0) return { err: new Error('holds no values, omit it instead') };
return { values: value };
}
function writeTlv(tagId: number, type: WireType, value: ParamValue): Result<{ chunk: Buffer }> {
const sized = type.size(value);
if (sized.err) return { err: sized.err };
if (sized.size > 0xffff) { if (sized.size > 0xffff) {
return { err: new Error(`${String(sized.size)} octets overflow the two octet length`) }; return { err: new Error(`TLV "${name}": ${String(sized.size)} octets overflow the two octet length`) };
} }
const chunk = Buffer.alloc(sized.size + 4); const chunk = Buffer.alloc(sized.size + 4);
chunk.writeUInt16BE(tagId, 0); chunk.writeUInt16BE(tag.tagId, 0);
chunk.writeUInt16BE(sized.size, 2); chunk.writeUInt16BE(sized.size, 2);
const written = type.write(value, chunk, 4); const written = type.write(input.tagValue, chunk, 4);
return written.err ? { err: written.err } : { chunk }; if (written.err) {
return { err: new Error(`TLV "${name}": ${written.err.message}`) };
} }
type Occurrence = { definition: TlvDefinition | undefined; tagId: number; value: Buffer | number | string }; chunks.push(chunk);
function readTlv(pdu: Buffer, offset: number): Result<{ octets: number; occurrence: Occurrence }> {
const tagId = pdu.readUInt16BE(offset);
const tagLength = pdu.readUInt16BE(offset + 2);
if (offset + 4 + tagLength > pdu.length) {
return { err: new Error(`TLV ${String(tagId)} runs past the end of the PDU`) };
} }
const definition = tlvsById[tagId]; return { chunks };
const read = (definition?.type ?? tlvDefault).read(pdu, offset + 4, tagLength);
if (read.err) return { err: read.err };
return { occurrence: { definition, tagId, value: read.value }, octets: 4 + tagLength };
}
/** Keyed by tag name, a repeatable tag listing every occurrence in wire order and any other keeping its last. */
function keyedTlvs(occurrences: Occurrence[]): Record<string, Tlv> {
const repeated = new Map<string, { tagId: number; values: Occurrence['value'][] }>();
const tlvs: Record<string, Tlv> = {};
for (const { definition, tagId, value } of occurrences) {
const key = definition?.tag ?? tagId.toString();
if (definition?.multiple === true) {
const entry = repeated.get(key) ?? { tagId, values: [] };
entry.values.push(value);
repeated.set(key, entry);
} else {
tlvs[key] = { tagId, tagName: definition?.tag, tagValue: value };
}
}
for (const [key, { tagId, values }] of repeated) {
const buffers = values.filter(value => Buffer.isBuffer(value));
tlvs[key] = {
tagId,
tagName: key,
tagValue: buffers.length === values.length ? buffers : values.filter(value => typeof value === 'number'),
};
}
return tlvs;
}
export function parseTlvs(pdu: Buffer, start: number): Result<{ offset: number; tlvs: Record<string, Tlv> }> {
const occurrences: Occurrence[] = [];
let offset = start;
while (offset + 4 <= pdu.length) {
const read = readTlv(pdu, offset);
if (read.err) return { err: read.err };
occurrences.push(read.occurrence);
offset += read.octets;
}
return { offset, tlvs: keyedTlvs(occurrences) };
} }
+21 -90
View File
@@ -1,5 +1,4 @@
import type { Result, VoidResult } from '../result.ts'; import type { Result, VoidResult } from '../result.ts';
import { unencodableText } from './encodings.ts';
export type DestAddress = export type DestAddress =
| { dest_addr_npi: number; dest_addr_ton: number; destination_addr: string } | { dest_addr_npi: number; dest_addr_ton: number; destination_addr: string }
@@ -14,34 +13,6 @@ export type UnsuccessSme = {
export type ParamValue = Buffer | DestAddress[] | UnsuccessSme[] | number | string; export type ParamValue = Buffer | DestAddress[] | UnsuccessSme[] | number | string;
/** A tag defined `multiple` holds every occurrence, in wire order; any other tag holds one value. */
export type TlvValue = Buffer | Buffer[] | number | number[] | string;
/** Octets a value holds, counting a string by its length. */
export function tlvOctets(value: TlvValue): number {
if (Buffer.isBuffer(value)) return value.length;
if (typeof value === 'string') return value.length;
if (typeof value === 'number') return 0;
let octets = 0;
for (const one of value) {
octets += typeof one === 'number' ? 0 : one.length;
}
return octets;
}
/** A value holding no view into the PDU it was read from. */
export function detachedTlv(value: TlvValue): TlvValue {
if (Buffer.isBuffer(value)) return Buffer.from(value);
if (!Array.isArray(value)) return value;
const buffers = value.filter(one => Buffer.isBuffer(one));
return buffers.length === value.length ? buffers.map(one => Buffer.from(one)) : value;
}
/** /**
* One field on the wire. `read` reports how many octets it consumed so callers never have to * One field on the wire. `read` reports how many octets it consumed so callers never have to
* re-derive a length that could disagree with what was actually written. * re-derive a length that could disagree with what was actually written.
@@ -54,10 +25,10 @@ export type WireType<T extends ParamValue = ParamValue> = {
}; };
/** Renders a parameter as text without ever falling back to "[object Object]". */ /** Renders a parameter as text without ever falling back to "[object Object]". */
export function paramText(value: ParamValue | TlvValue | undefined): string { export function paramText(value: ParamValue | undefined): string {
if (typeof value === 'string') return value; if (typeof value === 'string') return value;
if (typeof value === 'number') return value.toString(); if (typeof value === 'number') return value.toString();
if (Buffer.isBuffer(value)) return value.toString('latin1'); if (Buffer.isBuffer(value)) return value.toString('ascii');
return ''; return '';
} }
@@ -66,11 +37,6 @@ export function paramNumber(value: ParamValue | undefined, fallback: number): nu
return typeof value === 'number' ? value : fallback; return typeof value === 'number' ? value : fallback;
} }
/** Spells a refused value for the caller who wrote it; JSON spells NaN and the infinities `null`. */
export function valueText(value: ParamValue): string {
return typeof value === 'number' ? String(value) : JSON.stringify(value);
}
function outOfRange(buffer: Buffer, offset: number, needed: number): Error | undefined { function outOfRange(buffer: Buffer, offset: number, needed: number): Error | undefined {
if (offset < 0 || needed < 0 || offset + needed > buffer.length) { if (offset < 0 || needed < 0 || offset + needed > buffer.length) {
return new Error( return new Error(
@@ -83,7 +49,7 @@ function outOfRange(buffer: Buffer, offset: number, needed: number): Error | und
function wantInt(value: ParamValue, max: number): Result<{ int: number }> { function wantInt(value: ParamValue, max: number): Result<{ int: number }> {
if (typeof value !== 'number' || !Number.isInteger(value)) { if (typeof value !== 'number' || !Number.isInteger(value)) {
return { err: new Error(`Expected an integer, got ${valueText(value)}`) }; return { err: new Error(`Expected an integer, got ${JSON.stringify(value)}`) };
} }
if (value < 0 || value > max) { if (value < 0 || value > max) {
@@ -113,46 +79,11 @@ function writeInt32(value: ParamValue, buf: Buffer, offset: number): VoidResult
return {}; return {};
} }
function pastLatin1(text: string): { err: Error } | undefined {
const index = text.search(/[\u0100-\uFFFF]/);
if (index === -1) return undefined;
const char = String.fromCodePoint(text.codePointAt(index) ?? 0);
return {
err: new Error(
`latin1 cannot carry ${unencodableText({ char, index })}, and every text field on the wire is written in it; strip or transliterate it`,
),
};
}
function wantText(value: ParamValue): Result<{ text: string }> { function wantText(value: ParamValue): Result<{ text: string }> {
if (typeof value !== 'number' && typeof value !== 'string') { if (typeof value === 'string') return { text: value };
return { err: new Error(`Expected a string or a number, got ${typeof value}`) }; if (typeof value === 'number') return { text: value.toString() };
}
if (typeof value === 'number' && !Number.isFinite(value)) { return { err: new Error(`Expected a string, got ${typeof value}`) };
return { err: new Error(`Expected a finite number, got ${String(value)}`) };
}
const text = String(value);
return pastLatin1(text) ?? { text };
}
function wantCstringText(value: ParamValue): Result<{ text: string }> {
const { err, text } = wantText(value);
if (err) return { err };
const index = text.indexOf('\u0000');
if (index === -1) return { text };
return {
err: new Error(`U+0000 at index ${String(index)} would end the C-Octet String there`),
};
} }
function wantBytes(value: ParamValue): Result<{ bytes: Buffer }> { function wantBytes(value: ParamValue): Result<{ bytes: Buffer }> {
@@ -160,7 +91,7 @@ function wantBytes(value: ParamValue): Result<{ bytes: Buffer }> {
const { err, text } = wantText(value); const { err, text } = wantText(value);
return err ? { err } : { bytes: Buffer.from(text, 'latin1') }; return err ? { err } : { bytes: Buffer.from(text, 'ascii') };
} }
function isDestAddress(value: unknown): value is DestAddress { function isDestAddress(value: unknown): value is DestAddress {
@@ -236,7 +167,7 @@ function readCstring(buffer: Buffer, offset: number): Result<{ bytesRead: number
} }
} }
return { bytesRead: length + 1, value: buffer.toString('latin1', offset, offset + length) }; return { bytesRead: length + 1, value: buffer.toString('ascii', offset, offset + length) };
} }
function writeCstring(text: string, buffer: Buffer, offset: number): VoidResult { function writeCstring(text: string, buffer: Buffer, offset: number): VoidResult {
@@ -244,7 +175,7 @@ function writeCstring(text: string, buffer: Buffer, offset: number): VoidResult
if (err) return { err }; if (err) return { err };
buffer.write(text, offset, 'latin1'); buffer.write(text, offset, 'ascii');
buffer[offset + text.length] = 0; buffer[offset + text.length] = 0;
return {}; return {};
@@ -317,7 +248,7 @@ export const string: WireType<string> = {
if (err) return { err }; if (err) return { err };
return { bytesRead: length + 1, value: buffer.toString('latin1', offset + 1, offset + 1 + length) }; return { bytesRead: length + 1, value: buffer.toString('ascii', offset + 1, offset + 1 + length) };
}, },
size(value) { size(value) {
const { err, text } = wantText(value); const { err, text } = wantText(value);
@@ -340,7 +271,7 @@ export const string: WireType<string> = {
if (rangeErr) return { err: rangeErr }; if (rangeErr) return { err: rangeErr };
buffer.writeUInt8(text.length, offset); buffer.writeUInt8(text.length, offset);
buffer.write(text, offset + 1, 'latin1'); buffer.write(text, offset + 1, 'ascii');
return {}; return {};
}, },
@@ -357,12 +288,12 @@ export const cstring: WireType<string> = {
default: '', default: '',
read: readCstring, read: readCstring,
size(value) { size(value) {
const { err, text } = wantCstringText(value); const { err, text } = wantText(value);
return err ? { err } : { size: text.length + 1 }; return err ? { err } : { size: text.length + 1 };
}, },
write(value, buffer, offset) { write(value, buffer, offset) {
const { err, text } = wantCstringText(value); const { err, text } = wantText(value);
return err ? { err } : writeCstring(text, buffer, offset); return err ? { err } : writeCstring(text, buffer, offset);
}, },
@@ -470,7 +401,7 @@ export const dest_address_array: WireType<DestAddress[]> = {
if ('dl_name' in dest) { if ('dl_name' in dest) {
buf.writeUInt8(2, offset++); buf.writeUInt8(2, offset++);
const name = cstring.write(dest.dl_name, buf, offset); const name = writeCstring(dest.dl_name, buf, offset);
if (name.err) return { err: name.err }; if (name.err) return { err: name.err };
@@ -486,7 +417,7 @@ export const dest_address_array: WireType<DestAddress[]> = {
if (npi.err) return { err: npi.err }; if (npi.err) return { err: npi.err };
const addr = cstring.write(dest.destination_addr, buf, offset); const addr = writeCstring(dest.destination_addr, buf, offset);
if (addr.err) return { err: addr.err }; if (addr.err) return { err: addr.err };
@@ -574,7 +505,7 @@ export const unsuccess_sme_array: WireType<UnsuccessSme[]> = {
if (npi.err) return { err: npi.err }; if (npi.err) return { err: npi.err };
const addr = cstring.write(sme.destination_addr, buf, offset); const addr = writeCstring(sme.destination_addr, buf, offset);
if (addr.err) return { err: addr.err }; if (addr.err) return { err: addr.err };
@@ -607,15 +538,15 @@ export const tlv = {
? offset + length ? offset + length
: terminator; : terminator;
return { bytesRead: length, value: buf.toString('latin1', offset, end) }; return { bytesRead: length, value: buf.toString('ascii', offset, end) };
}, },
size(value: ParamValue) { size(value: ParamValue) {
const { err, text } = wantCstringText(value); const { err, text } = wantText(value);
return err ? { err } : { size: text.length + 1 }; return err ? { err } : { size: text.length + 1 };
}, },
write(value: ParamValue, buf: Buffer, offset: number) { write(value: ParamValue, buf: Buffer, offset: number) {
const { err, text } = wantCstringText(value); const { err, text } = wantText(value);
return err ? { err } : writeCstring(text, buf, offset); return err ? { err } : writeCstring(text, buf, offset);
}, },
@@ -628,7 +559,7 @@ export const tlv = {
read(buf: Buffer, offset: number, length = 0) { read(buf: Buffer, offset: number, length = 0) {
const err = outOfRange(buf, offset, length); const err = outOfRange(buf, offset, length);
return err ? { err } : { bytesRead: length, value: buf.toString('latin1', offset, offset + length) }; return err ? { err } : { bytesRead: length, value: buf.toString('ascii', offset, offset + length) };
}, },
size(value: ParamValue) { size(value: ParamValue) {
const { err, text } = wantText(value); const { err, text } = wantText(value);
@@ -644,7 +575,7 @@ export const tlv = {
if (rangeErr) return { err: rangeErr }; if (rangeErr) return { err: rangeErr };
buf.write(text, offset, 'latin1'); buf.write(text, offset, 'ascii');
return {}; return {};
}, },
+4 -4
View File
@@ -1,5 +1,5 @@
import type { MessageState } from './defs/constants.ts'; import type { MessageState } from './defs/constants.ts';
import type { TlvValue } from './defs/types.ts'; import type { ParamValue } from './defs/types.ts';
import type { PduObject } from './pdu.ts'; import type { PduObject } from './pdu.ts';
import type { SmsIdFormat } from './sms-id.ts'; import type { SmsIdFormat } from './sms-id.ts';
import { consts, constsById, hasUdh, messageTypeOf } from './defs/constants.ts'; import { consts, constsById, hasUdh, messageTypeOf } from './defs/constants.ts';
@@ -150,7 +150,7 @@ const smeMessageTypes: readonly number[] = [
consts.ESM_CLASS.USER_ACKNOWLEDGEMENT, consts.ESM_CLASS.USER_ACKNOWLEDGEMENT,
]; ];
function nonEmptyText(value: TlvValue | undefined): string | undefined { function nonEmptyText(value: ParamValue | undefined): string | undefined {
return typeof value === 'string' && value !== '' ? value : undefined; return typeof value === 'string' && value !== '' ? value : undefined;
} }
@@ -178,7 +178,7 @@ function receiptBody(pduObj: PduObject): string {
} }
function receiptId( function receiptId(
tlvId: TlvValue | undefined, tlvId: ParamValue | undefined,
receipt: Receipt | undefined, receipt: Receipt | undefined,
format: SmsIdFormat, format: SmsIdFormat,
): string | undefined { ): string | undefined {
@@ -198,7 +198,7 @@ function isMessageState(name: string | undefined): name is MessageState {
/** The state TLV wins where it names a state we know; an unnameable one leaves the body to say. */ /** The state TLV wins where it names a state we know; an unnameable one leaves the body to say. */
function receiptStatus( function receiptStatus(
tlvState: TlvValue | undefined, tlvState: ParamValue | undefined,
receipt: Receipt | undefined, receipt: Receipt | undefined,
): { statusId: number; statusMsg: MessageState | undefined } { ): { statusId: number; statusMsg: MessageState | undefined } {
const scraped = receiptStates[receipt?.stat?.toUpperCase() ?? '']; const scraped = receiptStates[receipt?.stat?.toUpperCase() ?? ''];
+24 -45
View File
@@ -2,12 +2,10 @@ import type { PduObject } from './pdu.ts';
import type { SmppLog } from './log.ts'; import type { SmppLog } from './log.ts';
import { ExpiringGroups } from './expiring-groups.ts'; import { ExpiringGroups } from './expiring-groups.ts';
import { IdleWaiters } from './idle-waiters.ts'; import { IdleWaiters } from './idle-waiters.ts';
import { retainedOctets } from './retained-pdu.ts';
export type HeldMessagesOptions = { export type HeldMessagesOptions = {
log: SmppLog; log: SmppLog;
max: number; max: number;
maxOctets: number;
/** Injected so expiry can be exercised without a wall clock. */ /** Injected so expiry can be exercised without a wall clock. */
now?: (() => number) | undefined; now?: (() => number) | undefined;
timeout: number; timeout: number;
@@ -20,18 +18,12 @@ function keyOf(pduObjs: PduObject[]): string | undefined {
return first ? String(first.seqNr) : undefined; return first ? String(first.seqNr) : undefined;
} }
type Held = {
octets: number;
pduObjs: PduObject[];
};
/** The messages handed to the application that it has not answered yet, held by their segments. */ /** The messages handed to the application that it has not answered yet, held by their segments. */
export class HeldMessages { export class HeldMessages {
private readonly held: ExpiringGroups<Held>; private readonly held: ExpiringGroups<PduObject[]>;
private readonly idleWaiters = new IdleWaiters(); private readonly idleWaiters = new IdleWaiters();
private readonly log: SmppLog; private readonly log: SmppLog;
private readonly maxOctets: number; private readonly max: number;
private octets = 0;
constructor(options: HeldMessagesOptions) { constructor(options: HeldMessagesOptions) {
this.held = new ExpiringGroups({ this.held = new ExpiringGroups({
@@ -41,24 +33,14 @@ export class HeldMessages {
timeout: options.timeout, timeout: options.timeout,
}); });
this.log = options.log; this.log = options.log;
this.maxOctets = options.maxOctets; this.max = options.max;
}
get octetsHeld(): number {
return this.octets;
} }
get size(): number { get size(): number {
return this.held.size; return this.held.size;
} }
/** Whether a message arriving now is past the bound, once the expired are swept. */ /** An application that answers no message at all may not grow this without end. */
full(): boolean {
this.sweep();
return this.held.full || this.octets >= this.maxOctets;
}
hold(pduObjs: PduObject[]): void { hold(pduObjs: PduObject[]): void {
const key = keyOf(pduObjs); const key = keyOf(pduObjs);
@@ -66,42 +48,35 @@ export class HeldMessages {
this.sweep(); this.sweep();
const replaced = this.held.get(key); if (this.held.get(key)) {
if (replaced) {
this.log.warn('heldMessages - replacing a message on a re-used sequence number', { seqNr: Number(key) }); this.log.warn('heldMessages - replacing a message on a re-used sequence number', { seqNr: Number(key) });
this.delete(key, replaced); } else if (this.held.full) {
this.dropOldest();
} }
const octets = pduObjs.reduce((sum, pduObj) => sum + retainedOctets(pduObj), 0); this.held.set(key, pduObjs);
this.held.set(key, { octets, pduObjs });
this.octets += octets;
} }
/** Whether a drain is still waiting for this message to be answered. */ /** Whether a drain is still waiting for this message to be answered. */
has(pduObjs: PduObject[]): boolean { has(pduObjs: PduObject[]): boolean {
const key = keyOf(pduObjs); const key = keyOf(pduObjs);
return key !== undefined && this.held.get(key)?.pduObjs === pduObjs; return key !== undefined && this.held.get(key) === pduObjs;
} }
release(pduObjs: PduObject[]): void { release(pduObjs: PduObject[]): void {
const key = keyOf(pduObjs); const key = keyOf(pduObjs);
// Identity, not the key: a wrapped sequence number must not release someone else's message. // Identity, not the key: a wrapped sequence number must not release someone else's message.
const held = key === undefined ? undefined : this.held.get(key); if (key === undefined || this.held.get(key) !== pduObjs) return;
if (key === undefined || held?.pduObjs !== pduObjs) return; this.held.delete(key);
this.delete(key, held);
this.settle(); this.settle();
} }
/** Drops every message: their segments went with the link, so no answer of ours correlates now. */ /** Drops every message: their segments went with the link, so no answer of ours correlates now. */
clear(): void { clear(): void {
this.held.takeAll(); this.held.takeAll();
this.octets = 0;
this.idleWaiters.settle(); this.idleWaiters.settle();
} }
@@ -110,27 +85,31 @@ export class HeldMessages {
return this.idleWaiters.wait(() => this.held.size, timeout, signal); return this.idleWaiters.wait(() => this.held.size, timeout, signal);
} }
private dropOldest(): void {
const oldest = this.held.takeOldest();
if (!oldest) return;
const [seqNr] = oldest;
this.log.warn('heldMessages - buffer full, dropping the oldest message', {
max: this.max,
seqNr: Number(seqNr),
});
}
/** Drops every message past its deadline. Runs before each hold and on its own timer. */ /** Drops every message past its deadline. Runs before each hold and on its own timer. */
sweep(): void { sweep(): void {
const expired = this.held.takeExpired(); const expired = this.held.takeExpired();
if (expired.length === 0) return; if (expired.length === 0) return;
for (const [, held] of expired) {
this.octets -= held.octets;
}
this.log.warn('heldMessages - messages the application never answered', { this.log.warn('heldMessages - messages the application never answered', {
messages: expired.length, messages: expired.length,
}); });
this.settle(); this.settle();
} }
private delete(key: string, held: Held): void {
this.held.delete(key);
this.octets -= held.octets;
}
private settle(): void { private settle(): void {
if (this.held.size === 0) this.idleWaiters.settle(); if (this.held.size === 0) this.idleWaiters.settle();
} }
+3 -52
View File
@@ -13,17 +13,11 @@ import { Reassembler, decodeSegments } from './reassembly.ts';
import { bindCommands, defaults, standsInFor } from './session-options.ts'; import { bindCommands, defaults, standsInFor } from './session-options.ts';
import { concatOf } from './concat.ts'; import { concatOf } from './concat.ts';
import { createSms } from './sms.ts'; import { createSms } from './sms.ts';
import { detach } from './retained-pdu.ts';
import { dlrFromPdu } from './dlr.ts'; import { dlrFromPdu } from './dlr.ts';
import { paramText } from './defs/types.ts'; import { paramText } from './defs/types.ts';
import { respIdParams, segmentId } from './sms-id.ts'; import { respIdParams, segmentId } from './sms-id.ts';
import { respNameFor } from './defs/commands.ts';
/** Asks the peer to keep the message and retry. */
function throttledStatus(carriedAs: string): ErrorName {
return carriedAs === 'submit_sm' ? 'ESME_RTHROTTLED' : 'ESME_RX_T_APPN';
}
/** SMPP 3.4 lists ESME_RMSGQFUL under submit_sm_resp only; 4.6.2's retryable code is another. */
export function refusedSegmentStatus( export function refusedSegmentStatus(
carriedAs: string, carriedAs: string,
refusal: Refusal, refusal: Refusal,
@@ -34,7 +28,7 @@ export function refusedSegmentStatus(
return spelling === 'sar' ? 'ESME_RINVTLVVAL' : 'ESME_RINVESMCLASS'; return spelling === 'sar' ? 'ESME_RINVTLVVAL' : 'ESME_RINVESMCLASS';
} }
return throttledStatus(carriedAs); return carriedAs === 'submit_sm' ? 'ESME_RMSGQFUL' : 'ESME_RX_T_APPN';
} }
const lostReasons: Record<LostGroup['reason'], string> = { const lostReasons: Record<LostGroup['reason'], string> = {
@@ -71,14 +65,12 @@ export class IncomingRequests {
private readonly smsIdFormat: SmsIdFormat; private readonly smsIdFormat: SmsIdFormat;
private readonly systemId: string; private readonly systemId: string;
private linkGeneration = 0; private linkGeneration = 0;
private refusing = false;
constructor(options: IncomingRequestsOptions) { constructor(options: IncomingRequestsOptions) {
this.dlrMerger = options.dlrMerger; this.dlrMerger = options.dlrMerger;
this.held = new HeldMessages({ this.held = new HeldMessages({
log: options.log, log: options.log,
max: defaults.maxHeldMessages, max: defaults.maxHeldMessages,
maxOctets: defaults.maxHeldOctets,
timeout: defaults.heldMessageTimeout, timeout: defaults.heldMessageTimeout,
}); });
this.log = options.log; this.log = options.log;
@@ -149,7 +141,6 @@ export class IncomingRequests {
/** 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 of every one still held. */
clear(): void { clear(): void {
this.linkGeneration++; this.linkGeneration++;
this.refusing = false;
this.held.clear(); this.held.clear();
this.reassembler.clear(); this.reassembler.clear();
} }
@@ -180,12 +171,6 @@ export class IncomingRequests {
return; return;
} }
if (!respNameFor(pduObj.cmdName)) {
this.log.verbose('session - ignoring a command SMPP gives no response', { cmdName: pduObj.cmdName });
return;
}
this.log.info('session - no handler for command', { cmdName: pduObj.cmdName }); this.log.info('session - no handler for command', { cmdName: pduObj.cmdName });
await this.session.sendReturn(pduObj, 'ESME_RINVCMDID'); await this.session.sendReturn(pduObj, 'ESME_RINVCMDID');
} }
@@ -213,49 +198,15 @@ export class IncomingRequests {
await this.session.sendReturn(pduObj); await this.session.sendReturn(pduObj);
} }
private async refusedAtBound(pduObj: PduObject): Promise<boolean> {
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', {
cmdName: pduObj.cmdName,
seqNr: pduObj.seqNr,
});
await this.session.sendReturn(pduObj, throttledStatus(this.carriedAs(pduObj)));
return true;
}
// 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 false;
}
/** /**
* A concatenated message is answered segment by segment as it arrives: a peer that dispatches * 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. * one request at a time never sends the second segment until the first has been answered.
*/ */
private async onMessage(pduObj: PduObject): Promise<void> { private async onMessage(pduObj: PduObject): Promise<void> {
if (await this.refusedAtBound(pduObj)) return;
const concat = concatOf(pduObj); const concat = concatOf(pduObj);
if (!concat) { if (!concat) {
this.emitSms([detach(pduObj)]); this.emitSms([pduObj]);
return; return;
} }
+1 -1
View File
@@ -68,7 +68,7 @@ export type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
export type { PduHeader } from './pdu-refusal.ts'; export type { PduHeader } from './pdu-refusal.ts';
export type { SplitOptions } from './message.ts'; export type { SplitOptions } from './message.ts';
export type { Tlv, TlvDefinition, TlvName } from './defs/tlvs.ts'; export type { Tlv, TlvDefinition, TlvName } from './defs/tlvs.ts';
export type { DestAddress, ParamValue, TlvValue, UnsuccessSme, WireType } from './defs/types.ts'; export type { DestAddress, ParamValue, UnsuccessSme, WireType } from './defs/types.ts';
/** The spec tables, grouped the way `larvitsmpp.defs` was in 0.4.0. */ /** The spec tables, grouped the way `larvitsmpp.defs` was in 0.4.0. */
export { defs } from './defs/index.ts'; export { defs } from './defs/index.ts';
+32 -3
View File
@@ -9,8 +9,8 @@ import { cmds, commandNameById, respNameFor } from './defs/commands.ts';
import { hasUdh } from './defs/constants.ts'; import { hasUdh } from './defs/constants.ts';
import { decodeMessage, encodeBody } from './message.ts'; import { decodeMessage, encodeBody } from './message.ts';
import { errorNameById, errors, isErrorName } from './defs/errors.ts'; import { errorNameById, errors, isErrorName } from './defs/errors.ts';
import { paramNumber, valueText } from './defs/types.ts'; import { paramNumber } from './defs/types.ts';
import { parseTlvs, tagIdOf, tlvs, writeTlvs } from './defs/tlvs.ts'; import { tagIdOf, tlvDefault, tlvs, tlvsById, writeTlvs } from './defs/tlvs.ts';
/** The highest sequence number this library hands out; SMPP 3.4 4.7.1 reserves 0x7fffffff. */ /** The highest sequence number this library hands out; SMPP 3.4 4.7.1 reserves 0x7fffffff. */
export const maxSeqNr = 2147483646; export const maxSeqNr = 2147483646;
@@ -234,7 +234,7 @@ function buildPdu(
} }
if (!Number.isInteger(seqNr) || seqNr < 0 || seqNr > maxWireSeqNr) { if (!Number.isInteger(seqNr) || seqNr < 0 || seqNr > maxWireSeqNr) {
return { err: new Error(`Invalid seqNr: ${valueText(seqNr)}`) }; return { err: new Error(`Invalid seqNr: ${JSON.stringify(seqNr)}`) };
} }
const built = buildBody(definition, cmdName, cmdStatus, params, tlvs); const built = buildBody(definition, cmdName, cmdStatus, params, tlvs);
@@ -262,6 +262,35 @@ export function objToPdu<C extends CommandName>(obj: PduObjectInput<C>): Result<
); );
} }
function parseTlvs(pdu: Buffer, start: number): Result<{ offset: number; tlvs: Record<string, Tlv> }> {
const tlvs: Record<string, Tlv> = {};
let offset = start;
while (offset + 4 <= pdu.length) {
const tagId = pdu.readUInt16BE(offset);
const tagLength = pdu.readUInt16BE(offset + 2);
if (offset + 4 + tagLength > pdu.length) {
return { err: new Error(`TLV ${String(tagId)} runs past the end of the PDU`) };
}
const definition = tlvsById[tagId];
const read = (definition?.type ?? tlvDefault).read(pdu, offset + 4, tagLength);
if (read.err) return { err: read.err };
tlvs[definition?.tag ?? tagId.toString()] = {
tagId,
tagName: definition?.tag,
tagValue: read.value,
};
offset += 4 + tagLength;
}
return { offset, tlvs };
}
function readParams( function readParams(
cmdName: CommandName, cmdName: CommandName,
pdu: Buffer, pdu: Buffer,
+48 -3
View File
@@ -1,9 +1,10 @@
import type { Concat } from './concat.ts'; import type { Concat } from './concat.ts';
import type { ParamValue } from './defs/types.ts';
import type { PduObject } from './pdu.ts'; import type { PduObject } from './pdu.ts';
import type { SmppLog } from './log.ts'; import type { SmppLog } from './log.ts';
import type { Tlv } from './defs/tlvs.ts';
import { ExpiringGroups } from './expiring-groups.ts'; import { ExpiringGroups } from './expiring-groups.ts';
import { decodeMessage } from './message.ts'; import { decodeMessage } from './message.ts';
import { detach, retainedOctets } from './retained-pdu.ts';
import { messageOctets } from './message-body.ts'; import { messageOctets } from './message-body.ts';
import { paramNumber, paramText } from './defs/types.ts'; import { paramNumber, paramText } from './defs/types.ts';
import { uuidv7 } from './uuid.ts'; import { uuidv7 } from './uuid.ts';
@@ -42,7 +43,7 @@ export type Collected =
whole?: PduObject[] | undefined; whole?: PduObject[] | undefined;
}; };
export const defaultMaxOctets = 64 * 1024 * 1024; const defaultMaxOctets = 64 * 1024 * 1024;
type Group = { type Group = {
octets: number; octets: number;
@@ -51,6 +52,50 @@ type Group = {
total: number; total: number;
}; };
/** Wire reads hand back views, so retaining one segment would pin the whole PDU it arrived in. */
function detach(pduObj: PduObject): PduObject {
const params: Record<string, ParamValue> = {};
const tlvs: Record<string, Tlv> = {};
for (const [name, value] of Object.entries(pduObj.params)) {
params[name] = Buffer.isBuffer(value) ? Buffer.from(value) : value;
}
for (const [name, tlv] of Object.entries(pduObj.tlvs)) {
tlvs[name] = Buffer.isBuffer(tlv.tagValue)
? { ...tlv, tagValue: Buffer.from(tlv.tagValue) }
: tlv;
}
// short_message holds the same octets wherever it was not decoded, so one copy covers both.
const octets = Buffer.isBuffer(params.short_message)
? params.short_message
: pduObj.shortMessageOctets && Buffer.from(pduObj.shortMessageOctets);
return { ...pduObj, params, shortMessageOctets: octets, tlvs };
}
// A cstring param arrives as a string, and source_addr alone can carry most of a 1 MiB PDU.
function sizeOf(value: unknown): number {
if (Buffer.isBuffer(value)) return value.length;
return typeof value === 'string' ? value.length : 0;
}
function octetsOf(pduObj: PduObject): number {
let octets = 0;
for (const value of Object.values(pduObj.params)) {
octets += sizeOf(value);
}
for (const tlv of Object.values(pduObj.tlvs)) {
octets += sizeOf(tlv.tagValue);
}
return octets;
}
// NUL: the one octet a C-Octet String address cannot hold, so no sender can forge another's key. // NUL: the one octet a C-Octet String address cannot hold, so no sender can forge another's key.
function groupKey(pduObj: PduObject, concat: Concat): string { function groupKey(pduObj: PduObject, concat: Concat): string {
return [ return [
@@ -120,7 +165,7 @@ export class Reassembler {
const group = existing ?? this.open(key, concat.total); const group = existing ?? this.open(key, concat.total);
const replaced = group.parts.get(concat.part); const replaced = group.parts.get(concat.part);
const segment = detach(pduObj); const segment = detach(pduObj);
const delta = retainedOctets(segment) - (replaced === undefined ? 0 : retainedOctets(replaced)); const delta = octetsOf(segment) - (replaced === undefined ? 0 : octetsOf(replaced));
group.parts.set(concat.part, segment); group.parts.set(concat.part, segment);
group.octets += delta; group.octets += delta;
-53
View File
@@ -1,53 +0,0 @@
import type { ParamValue } from './defs/types.ts';
import type { PduObject } from './pdu.ts';
import type { Tlv } from './defs/tlvs.ts';
import { detachedTlv, tlvOctets } from './defs/types.ts';
/** Wire reads hand back views, so retaining one PDU would pin the whole chunk it arrived in. */
export function detach(pduObj: PduObject): PduObject {
const params: Record<string, ParamValue> = {};
const tlvs: Record<string, Tlv> = {};
for (const [name, value] of Object.entries(pduObj.params)) {
params[name] = Buffer.isBuffer(value) ? Buffer.from(value) : value;
}
for (const [name, tlv] of Object.entries(pduObj.tlvs)) {
tlvs[name] = { ...tlv, tagValue: detachedTlv(tlv.tagValue) };
}
// short_message holds the same octets wherever it was not decoded, so one copy covers both.
const octets = Buffer.isBuffer(params.short_message)
? params.short_message
: pduObj.shortMessageOctets && Buffer.from(pduObj.shortMessageOctets);
return { ...pduObj, params, shortMessageOctets: octets, tlvs };
}
// Measured heap beyond the octets, so a PDU of empty fields or empty TLVs is not free.
const pduObjectOverhead = 1000;
const tlvObjectOverhead = 300;
// A cstring param arrives as a string, and source_addr alone can carry most of a 1 MiB PDU.
function sizeOf(value: ParamValue): number {
if (Buffer.isBuffer(value)) return value.length;
return typeof value === 'string' ? value.length : 0;
}
/** Roughly the heap a detached PDU holds. */
export function retainedOctets(pduObj: PduObject): number {
let octets = pduObjectOverhead;
for (const value of Object.values(pduObj.params)) {
octets += sizeOf(value);
}
for (const tlv of Object.values(pduObj.tlvs)) {
const listed = Array.isArray(tlv.tagValue) ? tlv.tagValue.length : 0;
octets += tlvOctets(tlv.tagValue) + (1 + listed) * tlvObjectOverhead;
}
return octets;
}
+3 -17
View File
@@ -7,10 +7,10 @@ import type { SmppLog } from './log.ts';
import type { SmsIdNotation } from './sms-id.ts'; import type { SmsIdNotation } from './sms-id.ts';
import { UnansweredError } from './unanswered-error.ts'; import { UnansweredError } from './unanswered-error.ts';
import { consts, defaultMessagingMode, isMessagingMode, isSubmitMessagingMode, submitMessagingModes } from './defs/constants.ts'; import { consts, defaultMessagingMode, isMessagingMode, isSubmitMessagingMode, submitMessagingModes } from './defs/constants.ts';
import { cstring, paramText } from './defs/types.ts';
import { dataCodingByEncoding, detect, encodingNames, isEncodingName, unencodable, unencodableText } from './defs/encodings.ts'; import { dataCodingByEncoding, detect, encodingNames, isEncodingName, unencodable, unencodableText } from './defs/encodings.ts';
import { namedValue } from './error-from.ts'; import { namedValue } from './error-from.ts';
import { normaliseSmsId } from './sms-id.ts'; import { normaliseSmsId } from './sms-id.ts';
import { paramText } from './defs/types.ts';
import { maxSegments, smppTime, splitMessage } from './message.ts'; import { maxSegments, smppTime, splitMessage } from './message.ts';
export type SendSmsOptions = { export type SendSmsOptions = {
@@ -211,23 +211,8 @@ function checkFlash(encoding: EncodingName, flash: boolean): Error | undefined {
return new Error('flash has no Latin-1 spelling: a message class carries GSM 7-bit, 8-bit data or UCS2, and 8-bit data is not text a handset will display, so send it as UCS2 or drop flash'); return new Error('flash has no Latin-1 spelling: a message class carries GSM 7-bit, 8-bit data or UCS2, and 8-bit data is not text a handset will display, so send it as UCS2 or drop flash');
} }
/** Asked of the wire type itself, so the codec cannot refuse an address this let through. */
function checkAddresses(sms: SendSmsInput): Error | undefined {
for (const option of ['from', 'to'] as const) {
const { err } = cstring.size(sms[option]);
if (err) return new Error(`${option}: ${err.message}`);
}
return undefined;
}
/** Every option a send can be refused for, so nothing is built for a message that will not go. */ /** Every option a send can be refused for, so nothing is built for a message that will not go. */
function checkOptions(sms: SendSmsInput): Result<CheckedOptions> { function checkOptions(sms: SendSmsInput): Result<CheckedOptions> {
const unwritable = checkAddresses(sms);
if (unwritable) return { err: unwritable };
const mode = checkMessagingMode(sms.messagingMode, sms.dlr === true); const mode = checkMessagingMode(sms.messagingMode, sms.dlr === true);
if (mode.err) return { err: mode.err }; if (mode.err) return { err: mode.err };
@@ -314,7 +299,8 @@ export async function submitSms(deps: SendSmsDeps, sms: SendSmsInput): Promise<S
deps.log.debug('sendSms() - sending', { encoding, segments: segments.length, to: sms.to }); deps.log.debug('sendSms() - sending', { encoding, segments: segments.length, to: sms.to });
// Segments go out together: a receiver that waits for every segment before answering would otherwise deadlock. // Segments go out together rather than one-after-a-response: a receiver that waits for every
// segment before answering — this library's own server does — would otherwise deadlock.
const sent = await Promise.all(segments.map(segment => deps.send({ const sent = await Promise.all(segments.map(segment => deps.send({
cmdName: 'submit_sm', cmdName: 'submit_sm',
params: submitSmParams(sms, segment, { params: submitSmParams(sms, segment, {
+1 -2
View File
@@ -13,7 +13,6 @@ import { defaultInterfaceVersion } from './defs/constants.ts';
import { errorFrom } from './error-from.ts'; import { errorFrom } from './error-from.ts';
import { paramText } from './defs/types.ts'; import { paramText } from './defs/types.ts';
import { guardedLog } from './log.ts'; import { guardedLog } from './log.ts';
import { respNameFor } from './defs/commands.ts';
export type AuthenticateResult = { userData?: unknown } | boolean; export type AuthenticateResult = { userData?: unknown } | boolean;
@@ -225,7 +224,7 @@ async function handleRequest(
return true; return true;
} }
if (pduObj.cmdName === 'unbind' || !respNameFor(pduObj.cmdName)) return false; if (pduObj.cmdName === 'unbind') return false;
session.log.debug('server - command before bind', { cmdName: pduObj.cmdName }); session.log.debug('server - command before bind', { cmdName: pduObj.cmdName });
await session.sendReturn(pduObj, 'ESME_RINVBNDSTS'); await session.sendReturn(pduObj, 'ESME_RINVBNDSTS');
-4
View File
@@ -9,7 +9,6 @@ import type { SmsIdFormat } from './sms-id.ts';
import type { Sms } from './sms.ts'; import type { Sms } from './sms.ts';
import type { Socket } from 'node:net'; import type { Socket } from 'node:net';
import { backoffDefaults } from './reconnect-loop.ts'; import { backoffDefaults } from './reconnect-loop.ts';
import { defaultMaxOctets } from './reassembly.ts';
import { isSmsIdNotation, smsIdNotations, smsIdPlaces } from './sms-id.ts'; import { isSmsIdNotation, smsIdNotations, smsIdPlaces } from './sms-id.ts';
import { namedValue } from './error-from.ts'; import { namedValue } from './error-from.ts';
@@ -128,7 +127,6 @@ export const defaults = {
heldMessageTimeout: 300_000, heldMessageTimeout: 300_000,
maxDlrMerges: 1000, maxDlrMerges: 1000,
maxHeldMessages: 1000, maxHeldMessages: 1000,
maxHeldOctets: 64 * 1024 * 1024,
maxOutstanding: 10, maxOutstanding: 10,
maxReassembly: 1000, maxReassembly: 1000,
reassemblyTimeout: 300_000, reassemblyTimeout: 300_000,
@@ -162,7 +160,6 @@ export function checkSessionOptions(options: CheckableOptions): VoidResult {
function limitsOf(options: CheckableOptions): [string, number, number][] { function limitsOf(options: CheckableOptions): [string, number, number][] {
return [ return [
['idleTimeout', options.idleTimeout ?? 0, 0], ['idleTimeout', options.idleTimeout ?? 0, 0],
['maxOctets', options.maxOctets ?? defaultMaxOctets, 1],
['maxOutstanding', options.maxOutstanding ?? defaults.maxOutstanding, 1], ['maxOutstanding', options.maxOutstanding ?? defaults.maxOutstanding, 1],
['maxReassembly', options.maxReassembly ?? defaults.maxReassembly, 1], ['maxReassembly', options.maxReassembly ?? defaults.maxReassembly, 1],
['reassemblyTimeout', options.reassemblyTimeout ?? defaults.reassemblyTimeout, 0], ['reassemblyTimeout', options.reassemblyTimeout ?? defaults.reassemblyTimeout, 0],
@@ -271,7 +268,6 @@ export type CheckableOptions = {
/** Not an option: the one spelling is inside reconnect, and this is where the other is refused. */ /** Not an option: the one spelling is inside reconnect, and this is where the other is refused. */
fromStart?: unknown; fromStart?: unknown;
idleTimeout?: number | undefined; idleTimeout?: number | undefined;
maxOctets?: number | undefined;
maxOutstanding?: number | undefined; maxOutstanding?: number | undefined;
maxReassembly?: number | undefined; maxReassembly?: number | undefined;
reassemblyTimeout?: number | undefined; reassemblyTimeout?: number | undefined;
-77
View File
@@ -51,11 +51,6 @@ describe('header', () => {
assert.equal(decode(encode({ cmdName: 'enquire_link', seqNr: 0x80000001 })).seqNr, 0x80000001); assert.equal(decode(encode({ cmdName: 'enquire_link', seqNr: 0x80000001 })).seqNr, 0x80000001);
assert.equal(decode(encode({ cmdName: 'enquire_link', seqNr: 0xFFFFFFFF })).seqNr, 0xFFFFFFFF); assert.equal(decode(encode({ cmdName: 'enquire_link', seqNr: 0xFFFFFFFF })).seqNr, 0xFFFFFFFF);
assert.ok(objToPdu({ cmdName: 'submit_sm', seqNr: 0x100000000 }).err instanceof Error); assert.ok(objToPdu({ cmdName: 'submit_sm', seqNr: 0x100000000 }).err instanceof Error);
const notANumber = objToPdu({ cmdName: 'submit_sm', seqNr: NaN });
assert.ok(notANumber.err instanceof Error);
assert.match(notANumber.err.message, /NaN/);
}); });
}); });
@@ -132,36 +127,6 @@ describe('parsing real PDUs', () => {
assert.equal(pduObj.params.short_message, 'hej 一'); assert.equal(pduObj.params.short_message, 'hej 一');
assert.equal(pduObj.tlvs.message_state?.tagValue, 2); assert.equal(pduObj.tlvs.message_state?.tagValue, 2);
}); });
test('keeps an alphanumeric sender whole through the wire and back', () => {
const pdu = encode({
cmdName: 'deliver_sm',
params: {
destination_addr: '46709771337',
short_message: 'hej',
source_addr: 'Kaffeé',
source_addr_ton: 5,
},
seqNr: 9,
});
assert.ok(pdu.includes(Buffer.from('Kaffeé', 'latin1')));
assert.equal(decode(pdu).params.source_addr, 'Kaffeé');
});
test('refuses an address the field cannot carry rather than truncating or coercing it', () => {
const smuggled = objToPdu({
cmdName: 'submit_sm',
params: { destination_addr: '46709771337', source_addr: '46701113311\u0000EVIL' },
});
assert.ok(smuggled.err instanceof Error);
assert.equal(smuggled.buffer, undefined);
assert.ok(objToPdu({ cmdName: 'deliver_sm', params: { source_addr: '一' } }).err instanceof Error);
assert.ok(objToPdu({ cmdName: 'deliver_sm', params: { source_addr: NaN } }).err instanceof Error);
assert.ok(objToPdu({ cmdName: 'deliver_sm', params: { source_addr: Infinity } }).err instanceof Error);
});
}); });
describe('encoding submit_sm', () => { describe('encoding submit_sm', () => {
@@ -463,48 +428,6 @@ describe('TLVs', () => {
assert.ok(err instanceof Error); assert.ok(err instanceof Error);
}); });
test('keeps every occurrence of a repeatable TLV, in wire order', () => {
const first = Buffer.from('0146709771337', 'hex');
const second = Buffer.from('0146701113311', 'hex');
const pduObj = decode(encode({
cmdName: 'submit_sm',
params: { destination_addr: '46709771337', short_message: 'hi', source_addr: '46701113311' },
tlvs: {
callback_num: { tagValue: [first, second] },
callback_num_pres_ind: { tagValue: [1] },
},
}));
assert.deepEqual(pduObj.tlvs.callback_num?.tagValue, [first, second]);
assert.deepEqual(pduObj.tlvs.callback_num_pres_ind?.tagValue, [1]);
});
test('reads the failed areas of a broadcast_sm_resp as broadcast_area_identifier', () => {
const areas = [Buffer.from('0001', 'hex'), Buffer.from('0002', 'hex')];
const pduObj = decode(encode({
cmdName: 'broadcast_sm_resp',
params: { message_id: '01a0d051-b588-76eb-a5c5-a8cb8b854e68' },
tlvs: { failed_broadcast_area_identifier: { tagValue: areas } },
}));
assert.deepEqual(pduObj.tlvs.broadcast_area_identifier?.tagValue, areas);
});
test('refuses a repeatable TLV given one value, and a lone TLV given several', () => {
const params = { destination_addr: '46709771337', short_message: 'hi', source_addr: '46701113311' };
for (const tlvs of [
{ callback_num: { tagValue: Buffer.from('01', 'hex') } },
{ callback_num: { tagValue: [] } },
{ source_port: { tagValue: [1234, 1235] } },
]) {
const { buffer, err } = objToPdu({ cmdName: 'submit_sm', params, tlvs });
assert.equal(buffer, undefined);
assert.ok(err instanceof Error);
}
});
test('round-trips a receipt with message_state and receipted_message_id', () => { test('round-trips a receipt with message_state and receipted_message_id', () => {
const receipt = 'id:450 sub:001 dlvrd:1 submit date:1504031342 done date:1504031342 stat:DELIVRD err:0 text:xxx'; const receipt = 'id:450 sub:001 dlvrd:1 submit date:1504031342 done date:1504031342 stat:DELIVRD err:0 text:xxx';
const pduObj = decode(encode({ const pduObj = decode(encode({
+25 -166
View File
@@ -24,7 +24,7 @@ import { Session } from '../src/session.ts';
import { DlrMerger } from '../src/dlr-merger.ts'; import { DlrMerger } from '../src/dlr-merger.ts';
import { PduRefusedError } from '../src/pdu-refusal.ts'; import { PduRefusedError } from '../src/pdu-refusal.ts';
import { objToPdu } from '../src/pdu.ts'; import { objToPdu } from '../src/pdu.ts';
import { checkSessionOptions, defaults, standsInFor } from '../src/session-options.ts'; import { checkSessionOptions, standsInFor } from '../src/session-options.ts';
import { client } from '../src/client.ts'; import { client } from '../src/client.ts';
import { closeAfter, closeListenerAfter } from './teardown.ts'; import { closeAfter, closeListenerAfter } from './teardown.ts';
import { concatOf } from '../src/concat.ts'; import { concatOf } from '../src/concat.ts';
@@ -1482,133 +1482,28 @@ describe('held message bounds', () => {
return [submitPdu(seqNr)]; return [submitPdu(seqNr)];
} }
test('is full at its count, and a re-used sequence number replaces rather than adding', () => { test('drops the message held longest rather than holding every one', () => {
const held = new HeldMessages({ log: silentLog, max: 2, maxOctets: 1_000_000, timeout: 10_000 }); const held = new HeldMessages({ log: silentLog, max: 2, timeout: 10_000 });
const first = message(1); const oldest = message(1);
held.hold(first); held.hold(oldest);
held.hold(message(2)); held.hold(message(2));
held.hold(message(2)); held.hold(message(2));
assert.equal(held.size, 2); assert.equal(held.size, 2, 'a re-used sequence number replaces rather than evicting');
assert.equal(held.full(), true); assert.equal(held.has(oldest), true);
assert.equal(held.has(first), true);
held.clear();
});
// submitPdu() holds 1026 octets by the maxOctets charge: its object, and the three text fields.
test('is full at its octet cap, until a message leaves by any way out', () => {
let now = 0;
const held = new HeldMessages({ log: silentLog, max: 10, maxOctets: 2000, now: () => now, timeout: 10_000 });
const answered = message(1);
held.hold(answered);
assert.equal(held.full(), false);
held.hold(message(2));
assert.equal(held.full(), true);
held.release(answered);
assert.equal(held.full(), false, 'after a release');
held.hold(message(3)); held.hold(message(3));
now = 20_000; assert.equal(held.size, 2);
held.sweep(); assert.equal(held.has(oldest), false);
now = 0;
assert.equal(held.full(), false, 'after a sweep');
held.hold(message(4));
held.hold(message(5));
held.clear(); held.clear();
assert.equal(held.full(), false, 'after a clear');
});
// Dropping one the application still holds frees nothing, and the drain stops waiting for it.
test('refuses what arrives past the bound with a status that asks the peer to retry', async t => {
const session = new Session({ sock: new net.Socket() });
closeAfter(t, session);
session.boundAs = 'transceiver';
const warnings: string[] = [];
const incoming = new IncomingRequests({
dlrMerger: new DlrMerger({ log: silentLog, max: 10, timeout: 10_000 }),
log: { ...silentLog, warn: message => { warnings.push(message); } },
sendPastDrain: () => Promise.resolve({ err: new Error('never sent') }),
session,
});
const answers: (ErrorName | undefined)[] = [];
const received: Sms[] = [];
session.sendReturn = (_pdu, status) => {
answers.push(status);
return Promise.resolve({});
};
session.on('sms', sms => { received.push(sms); });
for (let seqNr = 1; seqNr <= defaults.maxHeldMessages; seqNr++) {
await incoming.handle(submitPdu(seqNr));
}
assert.equal(received.length, defaults.maxHeldMessages);
await incoming.handle(submitPdu(defaults.maxHeldMessages + 1));
await incoming.handle(segment(7, 1, 2));
assert.equal(received.length, defaults.maxHeldMessages);
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();
await new Promise(resolve => { setImmediate(resolve); });
await incoming.handle(segment(7, 2, 2));
assert.equal(answers.at(-1), 'ESME_ROK');
assert.equal(received.length, defaults.maxHeldMessages);
// A peer keeping its window full crosses the bound on every answer, and that is still one warning.
await received[1]?.sendResp();
await new Promise(resolve => { setImmediate(resolve); });
await incoming.handle(submitPdu(defaults.maxHeldMessages + 2));
await incoming.handle(submitPdu(defaults.maxHeldMessages + 3));
await incoming.handle(submitPdu(defaults.maxHeldMessages + 4));
assert.equal(answers.at(-1), 'ESME_RTHROTTLED');
assert.equal(warnings.length, 1);
incoming.clear();
});
test('holds a message detached from the chunk it was read from', async t => {
const session = new Session({ sock: new net.Socket() });
closeAfter(t, session);
session.boundAs = 'transceiver';
const incoming = new IncomingRequests({
dlrMerger: new DlrMerger({ log: silentLog, max: 10, timeout: 10_000 }),
log: silentLog,
sendPastDrain: () => Promise.resolve({ err: new Error('never sent') }),
session,
});
const chunk = Buffer.alloc(64 * 1024);
const carried = submitPdu(1);
let received: Sms | undefined;
session.on('sms', sms => { received = sms; });
await incoming.handle({ ...carried, params: { ...carried.params, short_message: chunk.subarray(16, 20) } });
const retained = received?.pduObjs[0]?.params.short_message;
assert.ok(Buffer.isBuffer(retained));
assert.notEqual(retained.buffer, chunk.buffer);
incoming.clear();
}); });
test('gives up on a message the application never answers', () => { test('gives up on a message the application never answers', () => {
let now = 0; let now = 0;
const held = new HeldMessages({ log: silentLog, max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 }); const held = new HeldMessages({ log: silentLog, max: 10, now: () => now, timeout: 60 });
held.hold(message(1)); held.hold(message(1));
now = 61; now = 61;
@@ -1624,7 +1519,7 @@ describe('held message bounds', () => {
// Without this the drain sits out its whole budget before returning what a sweep already settled. // 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 () => { test('wakes a waiting drain when the last message expires', async () => {
let now = 0; let now = 0;
const held = new HeldMessages({ log: silentLog, max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 }); const held = new HeldMessages({ log: silentLog, max: 10, now: () => now, timeout: 60 });
held.hold(message(1)); held.hold(message(1));
@@ -1910,7 +1805,7 @@ describe('reassembly bounds', () => {
assert.deepEqual(lost, [], 'the peer holds the only segment there was, so nothing was lost'); assert.deepEqual(lost, [], 'the peer holds the only segment there was, so nothing was lost');
}); });
// The two addresses and the segment's and TLV's objects are 1322 octets, so only the 14 it carries can overrun 1330. // The two addresses are 22 octets, so only the 14 the TLV carries can overrun a cap of 30.
test('counts a body carried in message_payload against the octet cap', () => { test('counts a body carried in message_payload against the octet cap', () => {
function collectPayload(maxOctets: number): Collected { function collectPayload(maxOctets: number): Collected {
const reassembler = new Reassembler({ const reassembler = new Reassembler({
@@ -1925,45 +1820,8 @@ describe('reassembly bounds', () => {
return collectPdu(reassembler, payloadSegment(9, 1, 2)); return collectPdu(reassembler, payloadSegment(9, 1, 2));
} }
assert.equal(collectPayload(1330).kept, false, 'a TLV body the cap cannot hold is refused, not dropped later'); assert.equal(collectPayload(30).kept, false, 'a TLV body the cap cannot hold is refused, not dropped later');
assert.equal(collectPayload(1340).kept, true); assert.equal(collectPayload(40).kept, true);
});
test('counts the objects a segment and each of its TLVs hold against the octet cap, empty ones included', () => {
function collectTlvs(maxOctets: number, tlvs: PduObject['tlvs']): Collected {
const reassembler = new Reassembler({
log: silentLog,
max: 10,
maxOctets,
now: () => 0,
onLost: () => undefined,
timeout: 60_000,
});
return collectPdu(reassembler, { ...segment(9, 1, 2), tlvs });
}
function collectCallbacks(maxOctets: number, tagValue: Buffer[]): Collected {
return collectTlvs(maxOctets, { callback_num: { tagId: 0x0381, tagName: 'callback_num', tagValue } });
}
const unknownTags = Object.fromEntries(Array.from({ length: 10_000 }, (_, i) => {
const tagId = 0x4000 + i;
return [String(tagId), { tagId, tagName: undefined, tagValue: Buffer.alloc(0) }];
}));
const numbers = [Buffer.alloc(10_000, 0x31), Buffer.alloc(10_000, 0x32)];
assert.equal(collectCallbacks(20_000, numbers).kept, false);
assert.equal(collectCallbacks(30_000, numbers).kept, true);
assert.equal(
collectCallbacks(30_000, Array.from({ length: 10_000 }, () => Buffer.alloc(0))).kept,
false,
'an empty occurrence still holds an object',
);
// The segment itself is 1036 octets, so the tags must be charged 300 each to overrun 3,001,000.
assert.equal(collectTlvs(3_001_000, unknownTags).kept, false, 'an empty tag still holds an object');
assert.equal(collectTlvs(3_002_000, unknownTags).kept, true);
assert.equal(collectTlvs(1_000, {}).kept, false, 'a segment holds objects beyond its 36 octets');
assert.equal(collectTlvs(1_036, {}).kept, true);
}); });
// The segments before it were answered ESME_ROK, so dropping those is not the same as refusing one. // The segments before it were answered ESME_ROK, so dropping those is not the same as refusing one.
@@ -1972,8 +1830,8 @@ describe('reassembly bounds', () => {
const reassembler = new Reassembler({ const reassembler = new Reassembler({
log: silentLog, log: silentLog,
max: 10, max: 10,
// One segment is 1036 octets, so the second overruns a group already holding the first. // One segment is 36 octets, so the second overruns a group already holding the first.
maxOctets: 1050, maxOctets: 50,
now: () => 0, now: () => 0,
onLost: one => { lost.push(one); }, onLost: one => { lost.push(one); },
timeout: 60_000, timeout: 60_000,
@@ -2037,9 +1895,9 @@ describe('reassembly bounds', () => {
assert.equal(counted.size, 1, 'the second group evicted the first, as a UDH group would'); assert.equal(counted.size, 1, 'the second group evicted the first, as a UDH group would');
counted.clear(); counted.clear();
// A sar_* segment is the two 11-octet addresses, an 8-octet body, its own object and three TLVs'. // A sar_* segment is the two 11-octet addresses plus an 8-octet body, with no UDH to carry.
assert.equal(collectSar(capped(1920), 3, 1, 2).kept, false); assert.equal(collectSar(capped(20), 3, 1, 2).kept, false);
assert.equal(collectSar(capped(1930), 3, 1, 2).kept, true); assert.equal(collectSar(capped(30), 3, 1, 2).kept, true);
}); });
// Nothing else says a message the peer has already been answered for was thrown away. // Nothing else says a message the peer has already been answered for was thrown away.
@@ -2155,8 +2013,8 @@ describe('reassembly bounds', () => {
const reassembler = new Reassembler({ const reassembler = new Reassembler({
log: silentLog, log: silentLog,
max: 10, max: 10,
// One segment is 1036 octets: 14 of short_message, the two 11-octet addresses and its object. // One segment is 36 octets: 14 of short_message plus the two 11-octet addresses.
maxOctets: 2100, maxOctets: 80,
now: () => 0, now: () => 0,
onLost: () => undefined, onLost: () => undefined,
timeout: 60_000, timeout: 60_000,
@@ -2239,15 +2097,16 @@ describe('reassembly bounds', () => {
// Jasmin dispatches one request per connector at a time: holding a group unanswered until it was // Jasmin dispatches one request per connector at a time: holding a group unanswered until it was
// whole deadlocked every multi-segment message against it (interop-tests/findings/03-jasmin.md). // whole deadlocked every multi-segment message against it (interop-tests/findings/03-jasmin.md).
describe('the status a refused segment is answered with', () => { describe('the status a refused segment is answered with', () => {
// SMPP 3.4 lists ESME_RMSGQFUL under submit_sm_resp only; 4.6.2's retryable code is another.
test('names one the command the segment arrived on defines', () => { test('names one the command the segment arrived on defines', () => {
assert.equal(refusedSegmentStatus('submit_sm', 'full', 'udh'), 'ESME_RTHROTTLED'); assert.equal(refusedSegmentStatus('submit_sm', 'full', 'udh'), 'ESME_RMSGQFUL');
assert.equal(refusedSegmentStatus('deliver_sm', 'full', 'udh'), 'ESME_RX_T_APPN'); assert.equal(refusedSegmentStatus('deliver_sm', 'full', 'udh'), 'ESME_RX_T_APPN');
assert.equal(refusedSegmentStatus('submit_sm', 'unplaceable', 'udh'), 'ESME_RINVESMCLASS'); assert.equal(refusedSegmentStatus('submit_sm', 'unplaceable', 'udh'), 'ESME_RINVESMCLASS');
assert.equal(refusedSegmentStatus('deliver_sm', 'unplaceable', 'udh'), 'ESME_RINVESMCLASS'); assert.equal(refusedSegmentStatus('deliver_sm', 'unplaceable', 'udh'), 'ESME_RINVESMCLASS');
// esm_class is 0x00 on a sar_* segment and entirely valid: the TLV values are what cannot be honoured. // esm_class is 0x00 on a sar_* segment and entirely valid: the TLV values are what cannot be honoured.
assert.equal(refusedSegmentStatus('submit_sm', 'unplaceable', 'sar'), 'ESME_RINVTLVVAL'); assert.equal(refusedSegmentStatus('submit_sm', 'unplaceable', 'sar'), 'ESME_RINVTLVVAL');
assert.equal(refusedSegmentStatus('deliver_sm', 'unplaceable', 'sar'), 'ESME_RINVTLVVAL'); assert.equal(refusedSegmentStatus('deliver_sm', 'unplaceable', 'sar'), 'ESME_RINVTLVVAL');
assert.equal(refusedSegmentStatus('submit_sm', 'full', 'sar'), 'ESME_RTHROTTLED'); assert.equal(refusedSegmentStatus('submit_sm', 'full', 'sar'), 'ESME_RMSGQFUL');
}); });
// Which command that is, for the one that travels both ways, is what the end it arrived at says. // Which command that is, for the one that travels both ways, is what the end it arrived at says.
@@ -2257,7 +2116,7 @@ describe('the status a refused segment is answered with', () => {
assert.equal(standsInFor('deliver_sm', 'esme'), 'deliver_sm'); assert.equal(standsInFor('deliver_sm', 'esme'), 'deliver_sm');
assert.equal(standsInFor('submit_sm', 'smsc'), 'submit_sm'); assert.equal(standsInFor('submit_sm', 'smsc'), 'submit_sm');
assert.equal(standsInFor('enquire_link', 'smsc'), 'enquire_link'); assert.equal(standsInFor('enquire_link', 'smsc'), 'enquire_link');
assert.equal(refusedSegmentStatus(standsInFor('data_sm', 'smsc'), 'full', 'udh'), 'ESME_RTHROTTLED'); assert.equal(refusedSegmentStatus(standsInFor('data_sm', 'smsc'), 'full', 'udh'), 'ESME_RMSGQFUL');
assert.equal(refusedSegmentStatus(standsInFor('data_sm', 'esme'), 'full', 'udh'), 'ESME_RX_T_APPN'); assert.equal(refusedSegmentStatus(standsInFor('data_sm', 'esme'), 'full', 'udh'), 'ESME_RX_T_APPN');
}); });
}); });
+3 -56
View File
@@ -12,7 +12,6 @@ import { DlrMerger } from '../src/dlr-merger.ts';
import { PduFramer } from '../src/pdu-framer.ts'; import { PduFramer } from '../src/pdu-framer.ts';
import { ReconnectLoop } from '../src/reconnect-loop.ts'; import { ReconnectLoop } from '../src/reconnect-loop.ts';
import { Session, bindCommands } from '../src/session.ts'; import { Session, bindCommands } from '../src/session.ts';
import { checkSessionOptions } from '../src/session-options.ts';
import { client } from '../src/client.ts'; import { client } from '../src/client.ts';
import { closeAfter, closeListenerAfter } from './teardown.ts'; import { closeAfter, closeListenerAfter } from './teardown.ts';
import { consts } from '../src/defs/constants.ts'; import { consts } from '../src/defs/constants.ts';
@@ -317,25 +316,6 @@ describe('bind', () => {
assert.equal(await responded, '00000010800000150000000400000001'); assert.equal(await responded, '00000010800000150000000400000001');
}); });
test('leaves an outbind from an unbound peer unanswered', async t => {
const smpp = await startServer(t);
const errors: Error[] = [];
smpp.on('session', session => { session.on('sessionError', err => { errors.push(err); }); });
const peer = rawPeer(t, smpp.port);
peer.write({ cmdName: 'outbind', params: { password: 'pass', system_id: 'smsc' }, seqNr: 1 });
peer.write({ cmdName: 'enquire_link', seqNr: 2 });
const answered = await raceWithin(2000, peer.next());
assert.ok(answered, 'the peer was never answered');
assert.equal(answered.cmdStatus, 'ESME_RINVBNDSTS');
assert.equal(answered.seqNr, 2);
assert.deepEqual(errors, []);
});
test('answers the enquire_link a bound peer sends', async t => { test('answers the enquire_link a bound peer sends', async t => {
const smpp = await startServer(t); const smpp = await startServer(t);
const peer = rawPeer(t, smpp.port); const peer = rawPeer(t, smpp.port);
@@ -910,8 +890,8 @@ describe('receiving', () => {
// The refusal a submission gets is the one submit_sm_resp defines, whichever command carried it. // The refusal a submission gets is the one submit_sm_resp defines, whichever command carried it.
test('refuses a data_sm segment a server has no room for with the submit code', async t => { test('refuses a data_sm segment a server has no room for with the submit code', async t => {
// The two addresses and the objects are 1322 octets, so the 6-octet UDH and its text are what overrun 1330. // The two addresses are 22 octets, so the 6-octet UDH and its text are what overrun 30.
const smpp = await startServer(t, { maxOctets: 1330 }); const smpp = await startServer(t, { maxOctets: 30 });
const { session } = await connect(t, smpp, { bindType: 'transmitter' }); const { session } = await connect(t, smpp, { bindType: 'transmitter' });
assert.ok(session); assert.ok(session);
@@ -932,7 +912,7 @@ describe('receiving', () => {
assert.ok(refused.pduObj); assert.ok(refused.pduObj);
assert.equal(refused.pduObj.cmdName, 'data_sm_resp'); assert.equal(refused.pduObj.cmdName, 'data_sm_resp');
assert.equal(refused.pduObj.cmdStatus, 'ESME_RTHROTTLED'); assert.equal(refused.pduObj.cmdStatus, 'ESME_RMSGQFUL');
}); });
test('reassembles a concatenated message whose segments arrived in message_payload', async t => { test('reassembles a concatenated message whose segments arrived in message_payload', async t => {
@@ -1527,30 +1507,6 @@ describe('robustness', () => {
assert.ok(Date.now() - started < 5000, 'should have given up quickly'); assert.ok(Date.now() - started < 5000, 'should have given up quickly');
}); });
test('leaves an alert_notification and an outbind unanswered, since SMPP names no response', async t => {
const peer = await smscPeer(t);
const { session } = await client({ port: peer.port });
const errors: Error[] = [];
assert.ok(session);
closeAfter(t, session);
session.on('sessionError', err => { errors.push(err); });
peer.writeRaw(pduBytes({
cmdName: 'alert_notification',
params: { esme_addr: '46709771337', source_addr: '46701113311' },
seqNr: 10,
}));
peer.writeRaw(pduBytes({ cmdName: 'outbind', params: { password: 'pass', system_id: 'smsc' }, seqNr: 11 }));
peer.writeRaw(pduBytes({ cmdName: 'enquire_link', seqNr: 12 }));
const answered = await raceWithin(2000, peer.next());
assert.ok(answered, 'the peer was never answered');
assert.equal(answered.cmdName, 'enquire_link_resp');
assert.equal(answered.seqNr, 12);
assert.deepEqual(errors, []);
});
test('stops a connection attempt on an aborted signal', async () => { test('stops a connection attempt on an aborted signal', async () => {
const controller = new AbortController(); const controller = new AbortController();
@@ -2689,15 +2645,6 @@ describe('option validation', () => {
assert.match(hookRefused.err?.message ?? '', /onRequest must be a function/); assert.match(hookRefused.err?.message ?? '', /onRequest must be a function/);
}); });
test('refuses a reassembly octet cap below 1 at startup', async () => {
const listening = await server({ maxOctets: 0, port: 0 });
if (listening.server) await listening.server.close();
assert.match(listening.err?.message ?? '', /maxOctets must be 1 or more, got 0/);
assert.match(checkSessionOptions({ maxOctets: Infinity }).err?.message ?? '', /maxOctets must be 1 or more, got Infinity/);
});
test('returns an error rather than rejecting on an impossible port', async () => { test('returns an error rather than rejecting on an impossible port', async () => {
const listening = await server({ port: 70_000 }); const listening = await server({ port: 70_000 });
+2 -106
View File
@@ -1,8 +1,8 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test, { describe } from 'node:test'; import test, { describe } from 'node:test';
import type { DestAddress, UnsuccessSme } from '../src/defs/types.ts'; import type { DestAddress, UnsuccessSme } from '../src/defs/types.ts';
import { paramText, types } from '../src/defs/types.ts';
import { tlvs } from '../src/defs/tlvs.ts'; import { tlvs } from '../src/defs/tlvs.ts';
import { types } from '../src/defs/types.ts';
describe('integers', () => { describe('integers', () => {
test('int8 reads, sizes and writes one octet', () => { test('int8 reads, sizes and writes one octet', () => {
@@ -39,11 +39,6 @@ describe('integers', () => {
assert.ok(types.int8.write(-1, Buffer.alloc(1), 0).err instanceof Error); assert.ok(types.int8.write(-1, Buffer.alloc(1), 0).err instanceof Error);
assert.ok(types.int16.write(1.5, Buffer.alloc(2), 0).err instanceof Error); assert.ok(types.int16.write(1.5, Buffer.alloc(2), 0).err instanceof Error);
assert.ok(types.int8.write('nope', Buffer.alloc(1), 0).err instanceof Error); assert.ok(types.int8.write('nope', Buffer.alloc(1), 0).err instanceof Error);
const notANumber = types.int8.write(NaN, Buffer.alloc(1), 0);
assert.ok(notANumber.err instanceof Error);
assert.match(notANumber.err.message, /NaN/);
}); });
}); });
@@ -74,27 +69,6 @@ describe('string (Octet String)', () => {
assert.deepEqual(target, encoded); assert.deepEqual(target, encoded);
}); });
test('carries every latin1 octet, and refuses a character past it', () => {
const target = Buffer.alloc(4);
assert.deepEqual(types.string.read(Buffer.from([3, 0xE9, 0x80, 0xFF]), 0), {
bytesRead: 4,
value: 'é\u0080ÿ',
});
assert.deepEqual(types.string.write('é\u0080ÿ', target, 0), {});
assert.deepEqual(target, Buffer.from([3, 0xE9, 0x80, 0xFF]));
assert.ok(types.string.size('一').err instanceof Error);
assert.ok(types.string.write('一', Buffer.alloc(4), 0).err instanceof Error);
});
test('carries a NULL octet, which its length octet already bounds', () => {
const target = Buffer.alloc(4);
assert.deepEqual(types.string.write('a\u0000b', target, 0), {});
assert.deepEqual(target, Buffer.from([3, 0x61, 0x00, 0x62]));
});
}); });
describe('cstring (C-Octet String)', () => { describe('cstring (C-Octet String)', () => {
@@ -117,39 +91,13 @@ describe('cstring (C-Octet String)', () => {
assert.deepEqual(target, encoded); assert.deepEqual(target, encoded);
}); });
test('coerces a numeric value to its decimal string, and refuses a non-finite one', () => { test('coerces a numeric value to its decimal string', () => {
const target = Buffer.alloc(4); const target = Buffer.alloc(4);
types.cstring.write(123, target, 0); types.cstring.write(123, target, 0);
assert.deepEqual(target, Buffer.from([0x31, 0x32, 0x33, 0x00])); assert.deepEqual(target, Buffer.from([0x31, 0x32, 0x33, 0x00]));
assert.deepEqual(types.cstring.size(123), { size: 4 }); assert.deepEqual(types.cstring.size(123), { size: 4 });
for (const value of [NaN, Infinity, -Infinity]) {
assert.ok(types.cstring.size(value).err instanceof Error, String(value));
assert.ok(types.cstring.write(value, Buffer.alloc(9), 0).err instanceof Error, String(value));
assert.ok(types.string.write(value, Buffer.alloc(9), 0).err instanceof Error, String(value));
assert.ok(types.buffer.write(value, Buffer.alloc(9), 0).err instanceof Error, String(value));
assert.ok(types.tlv.string.write(value, Buffer.alloc(9), 0).err instanceof Error, String(value));
assert.ok(types.tlv.cstring.write(value, Buffer.alloc(9), 0).err instanceof Error, String(value));
}
});
test('carries every latin1 octet, and refuses a character past it', () => {
const address = Buffer.from([0x4B, 0x61, 0x66, 0x66, 0x65, 0xE9, 0x00]);
const target = Buffer.alloc(7);
assert.deepEqual(types.cstring.read(address, 0), { bytesRead: 7, value: 'Kaffeé' });
assert.deepEqual(types.cstring.write('Kaffeé', target, 0), {});
assert.deepEqual(target, address);
assert.ok(types.cstring.size('一').err instanceof Error);
assert.ok(types.cstring.write('一', Buffer.alloc(4), 0).err instanceof Error);
});
test('refuses a NULL of its own rather than ending the field early', () => {
assert.ok(types.cstring.size('46701113311\u0000EVIL').err instanceof Error);
assert.ok(types.cstring.write('46701113311\u0000EVIL', Buffer.alloc(17), 0).err instanceof Error);
}); });
test('refuses a string with no terminator rather than running off the end', () => { test('refuses a string with no terminator rather than running off the end', () => {
@@ -194,33 +142,6 @@ describe('integer TLVs', () => {
}); });
}); });
describe('text TLVs', () => {
const encoded = Buffer.from([0xE9, 0x80, 0xFF]);
test('carry every latin1 octet, and refuse a character past it', () => {
const target = Buffer.alloc(3);
assert.deepEqual(types.tlv.string.read(encoded, 0, 3), { bytesRead: 3, value: 'é\u0080ÿ' });
assert.deepEqual(types.tlv.string.write('é\u0080ÿ', target, 0), {});
assert.deepEqual(target, encoded);
assert.ok(types.tlv.string.size('一').err instanceof Error);
assert.ok(types.tlv.string.write('一', Buffer.alloc(3), 0).err instanceof Error);
});
test('carry them through a cstring tag too, terminator or none', () => {
const target = Buffer.alloc(4);
assert.deepEqual(types.tlv.cstring.read(encoded, 0, 3), { bytesRead: 3, value: 'é\u0080ÿ' });
assert.deepEqual(types.tlv.cstring.write('é\u0080ÿ', target, 0), {});
assert.deepEqual(target, Buffer.from([0xE9, 0x80, 0xFF, 0x00]));
assert.ok(types.tlv.cstring.size('一').err instanceof Error);
assert.ok(types.tlv.cstring.write('一', Buffer.alloc(4), 0).err instanceof Error);
assert.ok(types.tlv.cstring.write('a\u0000b', Buffer.alloc(4), 0).err instanceof Error);
});
});
describe('buffer', () => { describe('buffer', () => {
const expected = Buffer.from('abcd1234'); const expected = Buffer.from('abcd1234');
@@ -249,20 +170,6 @@ describe('buffer', () => {
assert.deepEqual(target, expected); assert.deepEqual(target, expected);
}); });
test('takes a string as the latin1 octets it stands for', () => {
const target = Buffer.alloc(3);
assert.deepEqual(types.buffer.size('é\u0080ÿ'), { size: 3 });
assert.deepEqual(types.buffer.write('é\u0080ÿ', target, 0), {});
assert.deepEqual(target, Buffer.from([0xE9, 0x80, 0xFF]));
});
});
describe('paramText()', () => {
test('renders a Buffer parameter as the latin1 text its octets spell', () => {
assert.equal(paramText(Buffer.from([0x4B, 0x61, 0x66, 0x66, 0x65, 0xE9])), 'Kaffeé');
});
}); });
describe('dest_address_array', () => { describe('dest_address_array', () => {
@@ -299,11 +206,6 @@ describe('dest_address_array', () => {
types.dest_address_array.write(expected, target, 0); types.dest_address_array.write(expected, target, 0);
assert.deepEqual(target, encoded); assert.deepEqual(target, encoded);
const smuggled: DestAddress[] = [{ dest_addr_npi: 1, dest_addr_ton: 1, destination_addr: '46\u0000EVIL' }];
assert.ok(types.dest_address_array.write(smuggled, Buffer.alloc(16), 0).err instanceof Error);
assert.ok(types.dest_address_array.write([{ dl_name: '一' }], Buffer.alloc(16), 0).err instanceof Error);
}); });
test('refuses a field value the wire cannot hold instead of throwing', () => { test('refuses a field value the wire cannot hold instead of throwing', () => {
@@ -343,12 +245,6 @@ describe('unsuccess_sme_array', () => {
types.unsuccess_sme_array.write(expected, target, 0); types.unsuccess_sme_array.write(expected, target, 0);
assert.deepEqual(target, encoded); assert.deepEqual(target, encoded);
const smuggled: UnsuccessSme[] = [
{ dest_addr_npi: 1, dest_addr_ton: 1, destination_addr: 'a\u0000b', error_status_code: 0 },
];
assert.ok(types.unsuccess_sme_array.write(smuggled, Buffer.alloc(16), 0).err instanceof Error);
}); });
test('refuses a field value the wire cannot hold instead of throwing', () => { test('refuses a field value the wire cannot hold instead of throwing', () => {
-25
View File
@@ -345,31 +345,6 @@ describe('a body the PDU\'s own data_coding cannot carry', () => {
}); });
}); });
describe('an address the field cannot carry', () => {
test('refuses it through sendSms(), with nothing reaching the socket', async t => {
const smsc = await dummySmsc(t);
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
const refusals: ['from' | 'to', string, RegExp][] = [
['from', '46701113311\u0000EVIL', /U\+0000 at index 11/],
['from', 'Kaffe一', /"一" \(U\+4E00\) at index 5/],
['from', '😀', /"😀" \(U\+1F600\) at index 0/],
['to', 'Kaffe一', /"一" \(U\+4E00\) at index 5/],
];
for (const [option, address, names] of refusals) {
const sent = await session.sendSms({ from, message: 'Hello world', to, [option]: address });
assert.ok(sent.err instanceof Error, address);
assert.match(sent.err.message, new RegExp(`^${option}: `), 'names the option the caller wrote');
assert.match(sent.err.message, names);
assert.deepEqual(sent.smsIds, [], address);
}
assert.deepEqual(smsc.octets, []);
});
});
describe('a time no peer can read', () => { describe('a time no peer can read', () => {
const invalid = new Date('nope'); const invalid = new Date('nope');
+13 -256
View File
@@ -9,9 +9,8 @@ govern it, and nothing here is a source anything else may cite.
## Status ## Status
The rewrite is **feature complete and green**: the suite, lint and typecheck are clean on Node 18 The rewrite is **feature complete and green**: the suite, lint and typecheck are clean on Node 18
to 26, and 0.5.0 is on npm. 0.6.0 is next, and it is a quality cut rather than a feature one — the to 26, and 0.5.0 is on npm. What is left is housekeeping around the release, a few things worth
comprehension gate and the defects under [0.6.0](#060) come first, then the gaps a comparison with adding, and the gaps a comparison with other SMPP libraries found.
other SMPP libraries found.
## The agreed API ## The agreed API
@@ -178,243 +177,8 @@ the rewrite, for a dependency added later. Maintainer's call, 2026-09-14.
- [x] [#8](https://github.com/larvit/larvitsmpp/issues/8) The socket's remote host and port on log - [x] [#8](https://github.com/larvit/larvitsmpp/issues/8) The socket's remote host and port on log
messages: under Worth doing, not blocking. Maintainer's call, 2026-09-14. messages: under Worth doing, not blocking. Maintainer's call, 2026-09-14.
## 0.6.0
A nine-reader comprehension panel read the whole project on 2026-09-20 and scored it 7 overall,
mean 6.8. Navigation (7–8) capped nobody. **Locality capped every unit reader at 5–6 and Shape
capped both architects at 6**, and those two are what this release lifts. The gate is 7 on all four
dimensions, higher where it is cheap. Maintainer's call, 2026-09-20. A systems-architect review the
same day returned ALIGN with one blocking-severity finding, which is the first item under Locality
and is also what the panel ranked hardest — two methods, one answer.
### Correctness, ahead of everything below
- [ ] **Type each known TLV's value by its tag, on read and on write, before 0.6.0 is cut.** Every
`tagValue` is `TlvValue`, so `{ callback_num: { tagValue: buf } }` compiles and is refused
only at runtime, and reading `receipted_message_id` has to narrow out arrays it can never
hold. Derive the types from the specs' own wire types and `multiple` flag. It has to land in
the same minor as the arrays, or narrowing the types is a second break. A test compiles the
CHANGELOG's `tagValue[0]` advice, which the union does not type-check today. From the
product-owner review of #25.
- [ ] **Settle what a repeated tag not marked `multiple` reads as, and pin it in a test.** A vendor
tag or a known single-value tag a peer sends twice keeps the last occurrence and drops the
rest silently, which goal 3 argues against; listing it would change every such tag's shape.
From the architecture review of #25.
- [ ] **Refuse a TLV input naming one tag under both its spellings.** `broadcast_area_identifier`
and `failed_broadcast_area_identifier` in one `tlvs` record both write, so the peer receives
the union of two lists the caller may have meant as one. From the architecture review of #25.
- [ ] **Test that a multipart send which errors never fires `messageDlr`.** Goal 2 now says so and
README promises it; `session-extras.test.ts` covers a drop *after* the send, not one during it.
- [ ] **Return an `err` where `message` is not a string, rather than throwing.**
`sendSms({ message: undefined })` — a forgotten property — reaches `value.replace()` in
`defs/encodings.ts` through the alphabet detection `checkOptions()` runs, and the `TypeError`
escapes `submitSms()` into the caller's process; `NaN` and `12345` do the same. README promises
"Never throws. Every fallible call resolves to `{ err?, … }`" and AGENTS.md hard rule 1 says it
again, so the docs are false for the likeliest caller mistake there is. From the stability
review of #18.
- [ ] **Derive `sm_length` for a numeric body, or refuse one.** `resolveBody()` in `pdu.ts` reads the
length only where the body is a Buffer or a string, so
`objToPdu({ cmdName: 'submit_sm', params: { short_message: 12345 } })` writes `sm_length: 0`,
then five octets after it, and reports success — and this library's own parser refuses what it
built, as "TLV 12594 runs past the end of the PDU". Goals 1 and 2. From the stability review
of #18.
- [ ] **Settle which numbers may spell a text field, refuse the rest, and say so where a consumer
reads it.** `wantText()` takes every finite number through `String()`, so `message_id: 1e21`
writes `1e+21`, `from: 0.1 + 0.2` writes `0.30000000000000004` and `source_addr: -5` writes
`-5` — none of them is the id or the address the caller meant, and all three are reported as
sent. The numeric branch exists for a digit sequence (`message_id: 123`); the product-owner
review of #18 recommends `Number.isSafeInteger(value) && value >= 0` with the refusal naming
the fix, since a 64-bit SMSC id loses digits to a JS number before this library ever sees it.
Goals 2 then 3: `from: 1e21` is reported as sent to an address that reaches nobody, which is
the wrong answer about what happened before it is laxness in what we send. That a number is
accepted at all reaches a consumer in no sentence either: only the type comment at
`defs/commands.ts:239`, and one CHANGELOG line that stops being visible when
0.7.0 is cut, while README's Building bullet reads as the whole rule for a text field. Whether
this is a supported spelling or 0.4.0 tolerance decides whether that sentence lands in
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.
### 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,
100,000 messages each: this library 25,358/s, jsmpp 30,771/s, Cloudhopper 27,945/s — we are
last at the one window most callers will ever run, while leading Cloudhopper and trailing jsmpp
by only 5% at 50 and 200. So the cost is not the codec, which the higher windows exercise just
as hard; it is something per-request that the window hides once enough requests overlap.
`benchmarks/` reproduces all three. Goal 6.
### Locality — 5–6 today, and the gate is 7
- [ ] **Give `IncomingRequests` a port instead of the `Session` it drives.** It holds its owner and
calls eight members of it 18 times, including `this.session.close()` on an inbound `unbind` —
a collaborator ending its owner's life. `OutgoingRequests` is the mirror half of the same
boundary and takes no session at all. AGENTS.md names this as the one way back up; the port
removes that exception, and `docs/decisions.md` already states the rule under The session's life: "a collaborator
that has to ask does not own its decision". It is also the missing test seam — inbound routing,
reassembly dispatch, `onRequest` ordering and bind-direction refusal have no unit test because
the class cannot be built without a live socket. Carry the eight members as `IncomingDeps`,
exactly as `sendPastDrain` is carried now. No public surface changes. **Do this before the
store (goal 9), or the back-edge is baked into the store's published interface.**
- [ ] **Route `sms.ts` through its handlers, all of it.** `createSms()` already injects
`handlers.send`, and then reaches `sms.session.sendReturn()`, `sms.session.bindAllows()` and
`sms.session.acceptsOptionalParams()` anyway — two channels to one collaborator. `Sms.session`
stays public as data the application reads. The cheaper half of the item above, and the one
that shows the shape.
- [ ] **Give the held-message protocol one name and one home.** `emitSms()` is the unit 8 of 9
readers named and 4 would least want to modify, and every one proposed the same fix. It runs
five mechanisms in one scope: a hold keyed by array identity, a `working` counter seeded from
`listenerCount('sms')`, a `WeakMap` keyed by the `Sms` object, a `setImmediate`-deferred
release, and a captured `linkGeneration` — with the counter decremented from `session.ts`'s
`captureRejectionSymbol` in another file. A `MessageHold` owning `hold/release/listenerGaveUp`
collapses three files into one readable object. Every way of getting it wrong is silent: a hung
shutdown, or a receipt refused.
- [ ] **Derive `Reassembler`'s octet total instead of maintaining it at five sites.** `this.octets`
and each `group.octets` must agree, adjusted in `collect`, `trim`, `takeOldest`, `sweep` and
`clear`, and `collect()` discovers its own eviction by re-reading the map by identity. Push the
budget into `ExpiringGroups` as a weighed capacity, and have `trim()` report whether the
current group survived. Named by 6 of 9 readers.
- [ ] **Let the two address arrays size a C-Octet String through `cstring.size()`.**
`sizeDestAddresses()` and `sizeUnsuccessSmes()` spell "len + 1" themselves, and each `offset +=`
after a write spells it a third time, so `dest_address_array` and `unsuccess_sme_array` each
know the cost in three places. Route both through `cstring.size()` and advance the offset by
what it returns. That also makes `size()` refuse where `write()` already does, so the error
arrives from the first call rather than the second; today the pair only fails closed because
`writeParams()` and `writeTlvs()` both bail on the write. From the stability review of #16.
- [ ] **Split the two questions `OutgoingRequests.linkDown()` answers.** `Session.drain()` calls it
twice for opposite conclusions — "nothing to drain, success" and "the link died under us,
failure" — and `outgoing-requests.ts` reads it a third way. Two named predicates. Named by 7
of 9 readers, who each reconstructed the ordering by hand.
- [ ] **Name `pastDrain()`'s retry condition and what makes the loop end.** The exit is a
three-term disjunction over two collaborators, whose comment covers the first term only, and
the method is named for what it bypasses. Do not change what it asks: `gate.isUp()` rather than
`linkDown()` is deliberate and recorded.
- [ ] **Replace `resolveBody`'s `settles` boolean with the decision it stands for.** One boolean
chooses both whether to overwrite `data_coding` and which params to read it from, across four
helpers all named some abstraction of "body". Return a named source — `'short_message' |
'payload' | 'caller'` — and branch once. Ranked hardest by three readers and picked by one as
the unit they would least want to touch, because a mistake here does not throw, does not fail
the types, and reaches the peer as somebody's message rendered wrong.
### Shape — 6 today, and the gate is 7
- [ ] **Group `src/` into a second level, and retire whichever record loses.** 34 files on one
plane, where `src/defs/` at 7 proves the shape is known one level down. `docs/decisions.md`
says "`src/` stays flat until a module has to move for another reason. Valid while that map is
what a reader navigates by" — and both architects reported that the map is now AGENTS.md rather
than the tree, which is that premise failing. `todo.md` already carries the opposite
instruction under Worth doing. Two records, opposite answers; one has to go. Do it in the same
change as the `IncomingRequests` port or the imports are rewritten twice.
- [ ] **Split `test/session-extras.test.ts` by the question each block answers.** 3,010 lines, 19
unrelated `describe` blocks whose names are already the file names they should be. With
`session.test.ts` it is 54% of all test code and 84% the size of `src/`. "extras" names neither
a question nor a module — it names the rest — and AGENTS.md's own convention forbids exactly
that. `max-lines` covers `src/**` only, so nothing has stopped it growing.
- [ ] **Collapse the three objects named `defaults`.** `client.ts`, `server.ts` and
`session-options.ts` each export or hold one; `port: 2775` is written twice and the idle
timeout is derived two ways to the same 40 000. "What is the default for X" has three answers
depending on the entrypoint, and nothing fails when they drift. Named by both architects as the
most likely first bug a new contributor ships.
- [ ] **Rename `EncodingName`'s `ASCII` to `GSM7`, with `ASCII` a deprecated alias for one minor.**
It is GSM 03.38, where `$` is 0x02 and `@` is 0x00, and `segmentUnits.ASCII = 153` is a septet
budget under a name that says octets. The 2026-09-09 decision removed `consts.ENCODING.ASCII`
for exactly this reason and left the option's own vocabulary carrying it. Pre-1.0 the minor is
the breaking unit, so this is as cheap as it will ever be, and `todo.md` already requires the
`consts.ENCODING` names settled before the custom-encoding registry — this is the other half.
- [ ] **Split `session-options.ts` into the things it is.** Option types and their validator, the
`SessionEvents` map, and the bind-direction rules (`bindCommands`, `bindTypeFromCommand`,
`standsInFor`, `bindCarries`) are three questions in one file, and the `defaults` table mixes
option defaults with four hard bounds that are not options. Both architects named it as where
the codebase rots first: at 34-wide it is where anything session-shaped lands.
- [ ] **Name the base-versus-segment distinction in the message id types.** `Sms.smsId` is a base,
`sendSms().smsIds[]` are segment ids, `Dlr.smsId` is a segment id and `MessageDlr.smsId` is a
base again — four fields, one type, `string`. The whole multipart receipt mechanism turns on
telling them apart and only `parseSegmentId()` knows.
### Self-sufficiency — 6–7 today, and the gate is 7
- [ ] **Move the one-line facts out of the decision log and back to the code.** Five of nine readers
independently reported being sent to `docs/decisions.md` for a question they hit while reading,
with no link from the code; one counted roughly fifty index redirects. The four worth inlining
as one line each: that `segmentUnits`' three numbers are in two units (septets and octets),
which body settles `data_coding`, that a receipt's body is read as octets whatever its
`data_coding` says, and the `<base>-<n>` id notation. The reasoning stays in the log; the
definition belongs at the code.
- [ ] **Document the two delivery-receipt merge bounds.** `maxDlrMerges` (1000) and
`dlrMergeTimeout` (24 h) are hardcoded, are not options, and appear in no README and no test —
while README states the equivalent held-message bounds explicitly ("Neither bound is an
option"). A sender with more than 1000 concurrent multipart `dlr: true` messages silently
evicts the oldest at `warn`. The inherited architect hit this on the 3am walk.
- [ ] **Add a ten-line SMPP glossary to the README.** Both juniors and the no-domain mid reported
the same largest cost: nothing in the repo says what a PDU, `esm_class`, `data_coding`, TON/NPI
or `submit_sm`-versus-`deliver_sm` are, and the inline spec citations mark a rule without
stating it. One of them put it at a third of their reading time. Four commands, three octets,
one sentence each.
### Doc claims this review falsified
- [ ] **Make `LinkGate.isUp()`'s doc true or its state match it.** It says a link attached but not
yet bound cannot carry a request, while `up` starts `true`, so the first link and a server
session are up before any bind. From the comprehension panel of #25.
- [ ] **Move `checkSessionOptions()`'s doc comment to what it describes.** It explains why a count
below 1 is refused, which is `checkLimits`' job, and says nothing of the function it heads.
From the comprehension panel of #25.
- [ ] **Log why `DlrMerger.expect()` registered no merge.** Ids with no common `<base>-<n>`
numbering return silently, the likeliest cause of a `messageDlr` that never fires and the one
that leaves no trace. From the comprehension panel of #25.
- [ ] **Make "every README example is executed by the suite" true, or stop claiming it.** Goal 10 and
the Done table both promise it; `test/readme.test.ts` transcribes the examples by hand and has
drifted — 15 fenced `javascript` blocks in the README against 10 tests, and the test named "the
documented sending options" passes none of the five options the README's example passes. Read
the fenced blocks at test time and assert each appears verbatim in the executed source, so an
edit to either fails the gate.
- [ ] **Narrow the `src/defs/*` lint exemption to the four table files.** Its stated reason — "the
spec tables are data: their length tracks the specification, not any complexity" — is false for
`defs/types.ts`, which is 595 lines of wire codec with 25 functions and is the file that parses
hostile input from the network. It carries more over-budget methods than any other file in the
repo, under a suppression written for something else.
- [ ] **Run the interop suite before cutting a minor, and date the claim.** README states
"Interoperable. Tested as a client against Jasmin and SMPPSim, and as a server against Kannel,
jsmpp, Cloudhopper, python-smpplib and php-smpp" in the present tense; `interop-tests/README.md`
is honest that the run was 2026-09-08. Nothing runs the peers on a schedule or before a tag, so
the claim rots silently. Goals 1 and 9.
## Worth doing, not blocking ## Worth doing, not blocking
- [ ] **Make the dumbclient soak's memory sample evidence of no library leak again.** Its rss ends at
its maximum (298 MiB, heapUsed 81 MiB after 173,820 messages), which the harness's own per-id
`Set` and `answerOrder` explain but cannot separate from a leak in `src/`: sample the heap
after the bookkeeping is cleared. From the stability review of #29.
- [ ] **Decide whether `alert_notification` reaches the application as more than `incomingPduObj`.**
It is the SMSC saying a handset it could not reach is reachable again (`esme_addr`,
`ms_availability_status`); a client has no `onRequest`, so the raw PDU event is the only way in.
Raised by the stability review of #21.
- [ ] **Cut the three teardown sentences `test/teardown.ts` already says.** Under AGENTS.md's - [ ] **Cut the three teardown sentences `test/teardown.ts` already says.** Under AGENTS.md's
Conventions, "`test/teardown.ts` covers a session, a server and a listener" restates its two Conventions, "`test/teardown.ts` covers a session, a server and a listener" restates its two
exported names, "Its close aborts rather than drains" restates `closeAfter`'s own doc comment, exported names, "Its close aborts rather than drains" restates `closeAfter`'s own doc comment,
@@ -422,18 +186,6 @@ and is also what the panel ranked hardest — two methods, one answer.
creation rule and the FIFO one, which nothing else states, and drop "CI's ten-minute cap" — creation rule and the FIFO one, which nothing else states, and drop "CI's ten-minute cap" —
that number lives in `.gitea/workflows/test.yaml`. Raised by the prose pass, 2026-09-20. that number lives in `.gitea/workflows/test.yaml`. Raised by the prose pass, 2026-09-20.
- [ ] **Leave AGENTS.md hard rule 1 the rule, and the decision log its reasoning.** Rule 1's fourth
sentence — "a function whose argument types are a closed set is guarded by the compiler and
stays total, which is why the encoding helpers return plainly, and the check belongs at
whichever boundary the argument arrives untyped at" — is the reasoning of the
`bitCount()`/`encodeMessage()`/`splitMessage()` entry in `docs/decisions.md`, which AGENTS.md's
own Documentation section makes a defect: it scopes AGENTS.md to an index of the decisions. It
also reads two ways — "wherever the types admit one" as an exemption for a typed field,
"the check belongs at whichever boundary the argument arrives untyped at" as a requirement at
`sendSms()` — and the `message` `TypeError` item sits exactly between them, so one rewrite
settles both. Maintainer's call, since it changes what a hard rule asks. From the prose pass
of #18.
- [ ] **Refuse a delay Node's timers cannot hold, in `checkLimits`.** `idleTimeout`, - [ ] **Refuse a delay Node's timers cannot hold, in `checkLimits`.** `idleTimeout`,
`reassemblyTimeout`, `responseTimeout` and `shutdownTimeout` take any integer, and `setTimeout` `reassemblyTimeout`, `responseTimeout` and `shutdownTimeout` take any integer, and `setTimeout`
fires after 1 ms for anything above 2147483647 — so a value in the wrong unit gets the inverse fires after 1 ms for anything above 2147483647 — so a value in the wrong unit gets the inverse
@@ -442,8 +194,7 @@ and is also what the panel ranked hardest — two methods, one answer.
`idleTimeout: '5000'` is refused with `got 5000` — a value the reader reads as correct — where `idleTimeout: '5000'` is refused with `got 5000` — a value the reader reads as correct — where
`connectTimeout` quotes it. `namedValue()`'s four sites — `messagingMode`, `encoding`, the time `connectTimeout` quotes it. `namedValue()`'s four sites — `messagingMode`, `encoding`, the time
options and `smsIdFormat` — are the same defect once more: there `true` and `'true'` both print options and `smsIdFormat` — are the same defect once more: there `true` and `'true'` both print
as `true`. One fix closes all three, and `valueText()` in `defs/types.ts` is the quoted as `true`. One fix closes all three. Raised by review, 2026-09-20.
spelling to take it from. Raised by review, 2026-09-20.
- [ ] **A send the codec will refuse waits for a link and a window slot first.** `refuse()` in - [ ] **A send the codec will refuse waits for a link and a window slot first.** `refuse()` in
`outgoing-requests.ts` runs `misuse()` and the abort check before the wait, precisely so a call `outgoing-requests.ts` runs `misuse()` and the abort check before the wait, precisely so a call
@@ -477,6 +228,12 @@ and is also what the panel ranked hardest — two methods, one answer.
each Gitea pull request to GitHub for it to review there. Maintainer's ask, 2026-09-14; not each Gitea pull request to GitHub for it to review there. Maintainer's ask, 2026-09-14; not
started until asked. started until asked.
- [ ] **Group the session's collaborators under `src/session/`.** `session.ts` imports
`dlr-merger`, `incoming-requests`, `link-timers`, `outgoing-requests`, `pdu-transport`,
`reconnect-loop` and `send-sms`, and nothing else does, so the directory would make that
boundary visible. The `OutgoingRequests` extraction this was to be done with landed on
2026-09-01, so it is the remaining half. Raised by review, 2026-09-01.
- [ ] **`leftOf()` and the link gate's own budget are one concept counted twice.** - [ ] **`leftOf()` and the link gate's own budget are one concept counted twice.**
`idle-waiters.ts` reads what is left of a budget as `Math.max(1, deadline - now)`, because 0 `idle-waiters.ts` reads what is left of a budget as `Math.max(1, deadline - now)`, because 0
means "forever" there; `link-gate.ts` runs the same subtraction and calls `<= 0` expired. means "forever" there; `link-gate.ts` runs the same subtraction and calls `<= 0` expired.
@@ -522,7 +279,7 @@ and is also what the panel ranked hardest — two methods, one answer.
From comparing 0.5.0 with `smpp`, `@semyonf/smpp`, `@leissner/node-red-smpp`, `node-smpp-next`, From comparing 0.5.0 with `smpp`, `@semyonf/smpp`, `@leissner/node-red-smpp`, `node-smpp-next`,
`smpp-js-sdk`, `smppjs`, cloudhopper-smpp, jsmpp, go-smpp, Kannel, Jasmin and php-smpp, 2026-09-14. `smpp-js-sdk`, `smppjs`, cloudhopper-smpp, jsmpp, go-smpp, Kannel, Jasmin and php-smpp, 2026-09-14.
Each lands under goal 7: an option or a hook, with the call that passes none unchanged. Each lands under goal 6: an option or a hook, with the call that passes none unchanged.
### Sending ### Sending
@@ -684,7 +441,7 @@ Each lands under goal 7: an option or a hook, with the call that passes none unc
- [ ] **Pooling, and state that survives a restart, through an optional store.** Maintainer's call, - [ ] **Pooling, and state that survives a restart, through an optional store.** Maintainer's call,
2026-09-14. It replaces two declines — merge state surviving a restart, and a pool of sessions — 2026-09-14. It replaces two declines — merge state surviving a restart, and a pool of sessions —
and goal 9 was rewritten for it. Big: design before code. and goal 8 was rewritten for it. Big: design before code.
- **What it holds.** Receipts still awaited and the groups `DlrMerger` collects. Segments of a - **What it holds.** Receipts still awaited and the groups `DlrMerger` collects. Segments of a
message already answered but not yet whole, which the peer will not send again (goal 2). The message already answered but not yet whole, which the peer will not send again (goal 2). The
concatenation reference, so a restart does not reuse one. For a pool, the ids every session concatenation reference, so a restart does not reuse one. For a pool, the ids every session
@@ -697,10 +454,10 @@ Each lands under goal 7: an option or a hook, with the call that passes none unc
in-memory store is enough; across processes the application supplies one. in-memory store is enough; across processes the application supplies one.
- **The interface.** Narrow, with keys and records of the library's own making, versioned, with - **The interface.** Narrow, with keys and records of the library's own making, versioned, with
expiry: a record from an older version is read or refused, never misread, and `DlrMerger`'s expiry: a record from an older version is read or refused, never misread, and `DlrMerger`'s
group shape is never published (goal 8). What processes share needs an atomic operation — group shape is never published (goal 7). What processes share needs an atomic operation —
compare-and-set or increment — since get-then-set races. compare-and-set or increment — since get-then-set races.
- **Adapters live elsewhere.** Redis, Postgres or SQLite stores are packages of their own; this - **Adapters live elsewhere.** Redis, Postgres or SQLite stores are packages of their own; this
one ships the interface and the in-memory store, and no runtime dependency (goal 10). one ships the interface and the in-memory store, and no runtime dependency (goal 9).
- **When the store fails.** Open: a send whose awaited receipt cannot be recorded is refused, or - **When the store fails.** Open: a send whose awaited receipt cannot be recorded is refused, or
sent and reported as undetermined (goal 2); a pool whose store is down stops, or falls back to sent and reported as undetermined (goal 2); a pool whose store is down stops, or falls back to
memory. Either way, an application that supplied no store never waits on one. memory. Either way, an application that supplied no store never waits on one.