Compare commits
54 Commits
fb139ab5ee
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 42b1239f3d | |||
| f2977638f2 | |||
| b9167073d4 | |||
| fb6b125aab | |||
| 855ccbe75f | |||
| d8232bcd67 | |||
| dba95d9524 | |||
| c3ebd6d6bc | |||
| d8d0947e1f | |||
| 72b400e661 | |||
| f1ef6b6643 | |||
| fd6ce16325 | |||
| f9c53aa576 | |||
| 92456b6f6e | |||
| 01210e734a | |||
| e83ed1451c | |||
| 11ff349333 | |||
| f2ee8aa016 | |||
| 2476684f24 | |||
| a885913ecb | |||
| befa648991 | |||
| cbfd6e0fbe | |||
| 5cdd3ccdae | |||
| f6a66336cd | |||
| 10877c9f5f | |||
| c5ef4c4703 | |||
| 80f77afa0f | |||
| 228b81aa5e | |||
| e725f62b95 | |||
| 0ee7bbbaf0 | |||
| b105752d53 | |||
| 65ac3a3fd7 | |||
| b54f5b02f3 | |||
| bdebef849c | |||
| 0620d205d6 | |||
| 3eb93303c3 | |||
| 0c4e6000b0 | |||
| bbfd1083b8 | |||
| faa5482c7a | |||
| 93d794e61b | |||
| 63e06856f2 | |||
| e7e71ca9bd | |||
| e2edd7e31e | |||
| 36a1c6ea7b | |||
| a7eb4923f5 | |||
| dc690b912f | |||
| f870b7530f | |||
| c1e0407942 | |||
| 7db242e375 | |||
| cbd1190b96 | |||
| b85d1f1dab | |||
| c5ed25f915 | |||
| 4c43f6748d | |||
| 36d32591c3 |
@@ -12,7 +12,7 @@ not for structure or style.
|
||||
|
||||
## Goals
|
||||
|
||||
The nine goals, in priority order, live in
|
||||
The 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
|
||||
reader judges it by. The README states the audience alongside them. Everything below cites a goal by
|
||||
number.
|
||||
@@ -70,6 +70,7 @@ src/
|
||||
reassembly.ts Reassembler: capped, expiring multipart groups
|
||||
reconnect-loop.ts ReconnectLoop: backoff, retry timer, stopped-ness
|
||||
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-window.ts SendWindow: the maxOutstanding semaphore
|
||||
session-options.ts SessionOptions, ReconnectOptions, bind direction and the session defaults
|
||||
@@ -82,12 +83,13 @@ src/
|
||||
constants.ts consts + constsById, and the SMPP version constants
|
||||
encodings.ts GSM 03.38, LATIN1, UCS2, detection, data_coding resolution
|
||||
errors.ts errors + errorsById (ESME_*)
|
||||
tlvs.ts TLV definitions, tlvsById, the input shape, and writing a TLV stream
|
||||
tlvs.ts TLV definitions, tlvsById, the input shape, and reading and writing a TLV stream
|
||||
types.ts Wire types: int8/int16/int32/string/cstring/buffer/arrays
|
||||
```
|
||||
|
||||
Dependency direction is one way: `defs` knows nothing above it, `pdu` uses `defs`, `session` uses
|
||||
`pdu`, and `client`/`server` use `session`. Nothing reaches back up.
|
||||
Imports point one way: `defs` knows nothing above it but `result.ts`, `pdu` uses `defs`, `session`
|
||||
uses `pdu`, and `client`/`server` use `session`. The one way back up is the `Session` handed to
|
||||
`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
|
||||
written to and read from the buffer. Never sort those alphabetically — the alphabetical-ordering
|
||||
@@ -103,20 +105,12 @@ docker compose run --rm node npm test
|
||||
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`.
|
||||
- `erasableSyntaxOnly` is on, so no enums, no namespaces, no parameter properties. Use `as const`
|
||||
objects plus union types.
|
||||
- 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.
|
||||
- The published floor is Node 18; the dev container runs Node 24 because type stripping needs it.
|
||||
- `typescript` is pinned to the 6.x line because `typescript-eslint` peer-requires `<6.1.0`. Move to
|
||||
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
|
||||
|
||||
@@ -152,13 +146,6 @@ 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` |
|
||||
| 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
|
||||
|
||||
Over SMPP the ESME puts one GSM character per octet in `short_message` and the SMSC packs it into
|
||||
@@ -249,7 +236,6 @@ the file.
|
||||
- `Session` is publicly constructible, which is what makes `SessionOptions` and `ReconnectOptions`
|
||||
public too.
|
||||
- `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.
|
||||
- `PduRefusedError` is exported, and `sessionError` names it in the event's type.
|
||||
- `bitCount()`, `encodeMessage()` and `splitMessage()` keep their total signatures, because
|
||||
@@ -292,6 +278,8 @@ the file.
|
||||
- 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`.
|
||||
- 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)
|
||||
|
||||
@@ -306,12 +294,18 @@ the file.
|
||||
`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 deliberate shutdown drains; an unusable link and an abort do not.
|
||||
- `sendSms()` puts every segment of a message on the wire together.
|
||||
- Every segment of a concatenated message is answered as it arrives, so `sendResp()` on one is the
|
||||
application's own signal rather than the peer's answer.
|
||||
- `server()` composes the application's `onRequest` after its own bind handling, and offers it every
|
||||
request that handling did not answer.
|
||||
- The drain waits on the messages the application holds, and `sendResp()` is what says it is done
|
||||
with one.
|
||||
- The drain's wait on the application ignores `shutdownTimeout: 0`.
|
||||
- What the application holds unanswered is capped on constants, and a message past the cap is
|
||||
refused.
|
||||
- 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 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.
|
||||
@@ -329,3 +323,5 @@ the file.
|
||||
- `test/` stays flat too, and a file there is named for the question it answers rather than for the
|
||||
module it covers.
|
||||
- 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.
|
||||
|
||||
@@ -6,6 +6,59 @@
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@@ -70,6 +70,9 @@ have for these:
|
||||
- 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.
|
||||
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
|
||||
`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.
|
||||
|
||||
@@ -101,9 +101,10 @@ session.on('sms', async sms => {
|
||||
});
|
||||
```
|
||||
|
||||
Call `sendResp()` for every message; it is part of the protocol. Delivery receipts reach you as
|
||||
`dlr` events, not here. A multipart message arrives reassembled and already answered segment by
|
||||
segment, so `sendResp()` there only says you are done with it: [Receiving in depth](#receiving-in-depth).
|
||||
Call `sendResp()` for every message, multipart included: until you do, it counts toward the bound
|
||||
past which the peer's messages are refused. Delivery receipts reach you as `dlr` events, not here. A
|
||||
multipart message arrives reassembled and already answered segment by segment, so `sendResp()` there
|
||||
puts nothing on the wire and releases it: [Receiving in depth](#receiving-in-depth).
|
||||
|
||||
## Run an SMPP server
|
||||
|
||||
@@ -161,7 +162,6 @@ await smpp.close(); // stop listening, then drain and close every live sess
|
||||
`sendDlr('UNDELIVERABLE')` any other state: [Server in depth](#server-in-depth).
|
||||
- A message that arrived in several segments was answered as they arrived, so `sendResp()` there
|
||||
takes no `smsId` or refusing `status`. `sms.answeredOnArrival` says which case you are in.
|
||||
- `smpp.close()` stops listening, then drains and closes every live session.
|
||||
|
||||
## 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. |
|
||||
| `idleTimeout` | `40000` | Drop a peer that has been silent this long. |
|
||||
| `maxReassembly` | `1000` | Incomplete multipart messages held per session. |
|
||||
| `maxOctets` | `67108864` | Bytes of 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. |
|
||||
| `reassemblyTimeout` | `300000` | How long a late segment can still join an incomplete message. |
|
||||
| `responseTimeout`, `shutdownTimeout`, `maxOutstanding`, `log`, `signal` | as for the client | |
|
||||
|
||||
@@ -288,7 +288,11 @@ await session.sendSms({
|
||||
```
|
||||
|
||||
**Addresses.** `sourceAddrTon` and `destinationAddrTon` default to 5 for an alphanumeric address
|
||||
and 1 for a numeric one; the NPI fields default to 0.
|
||||
and 1 for a numeric one; the NPI fields default to 0. An address is latin1, so `é` is one octet on
|
||||
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.**
|
||||
|
||||
@@ -318,9 +322,10 @@ 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
|
||||
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.
|
||||
- `err` is set when the SMSC refuses a segment, naming the status. Every segment goes out together,
|
||||
so `pduObjs` and `smsIds` then hold what was accepted: enough to reconcile a later receipt, not
|
||||
enough to resend the rest. Treat a partial failure as a failed message.
|
||||
- `err` is set when the SMSC refuses a segment, naming the status, or leaves one unanswered. Every
|
||||
segment goes out together, so `pduObjs` and `smsIds` then hold only the accepted segments, in send
|
||||
order: enough to reconcile a later receipt, not enough to resend the rest. Treat a partial failure
|
||||
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
|
||||
and lost only the response, so a message with `unanswered` above zero cannot be resent without
|
||||
risking a duplicate.
|
||||
@@ -342,9 +347,11 @@ 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.
|
||||
Name a later instant as a `Date`, which goes out absolute.
|
||||
|
||||
**What gets checked.** The library checks what it composes: an alphabet or a time you named, a string
|
||||
body under a `data_coding` you named. What you formed yourself, a `Buffer` body or a stamp you
|
||||
formatted, passes through as written. The same rule holds for `session.send()`.
|
||||
**What gets checked.** The library checks what it composes: an address you gave as `from` or `to`,
|
||||
an alphabet or a time you named, a string body under a `data_coding` you named. What you formed
|
||||
yourself, a `Buffer` body or a stamp you formatted, passes through as written, except that a text
|
||||
field is still checked: [PDUs and the low-level API](#pdus-and-the-low-level-api). The same rule
|
||||
holds for `session.send()`.
|
||||
|
||||
## Session
|
||||
|
||||
@@ -377,8 +384,7 @@ formatted, passes through as written. The same rule holds for `session.send()`.
|
||||
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.
|
||||
|
||||
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.
|
||||
A message left unanswered for five minutes is no longer waited for.
|
||||
`close({ signal })` cuts the wait short. `unbind()` takes no signal, and waits a further
|
||||
`responseTimeout` for its own response.
|
||||
|
||||
@@ -426,6 +432,12 @@ const { err, pduObj } = await session.send({
|
||||
each is two messages.
|
||||
- **Answered on arrival.** Each segment was answered as it landed, before you see the message:
|
||||
[Server in depth](#server-in-depth).
|
||||
- **Unanswered messages.** While a session holds 1000 messages you have not called `sendResp()` on,
|
||||
or 64 MiB of them counted the way `maxOctets` counts segments, every new message and segment is
|
||||
refused so the peer retries it: `ESME_RTHROTTLED` on a submission, `ESME_RX_T_APPN` on a delivery.
|
||||
No `sms` fires. Reaching the bound logs one `warn`, and the first message accepted once both are
|
||||
down to half one `info`. A message left five minutes is dropped from the count with a `warn`; a
|
||||
later `sendResp()` still answers it. None of the three is an option.
|
||||
- **Where the body is.** A body in the `message_payload` TLV, SMPP's way of carrying up to 64 KB and
|
||||
the only place a `data_sm` has, reads exactly like one in `short_message`, concatenated messages
|
||||
and receipts included. A PDU filling both is read from `short_message`.
|
||||
@@ -474,19 +486,20 @@ 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
|
||||
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
|
||||
earlier one still collecting loses its merged report.
|
||||
earlier one still collecting loses its merged report. A send that returned an `err` never fires `messageDlr`,
|
||||
even where the SMSC took some of its segments; their receipts still arrive as `dlr`.
|
||||
|
||||
## Server in depth
|
||||
|
||||
**Multipart is answered on arrival.** Each segment is answered as it lands, because a relaying SMSC
|
||||
will not send the next until the last is answered. The answer is `ESME_ROK`, unless the segment
|
||||
numbers itself into no message this session can join, which refuses it, or the reassembly buffer is
|
||||
full, which asks the SMSC to keep it and try again. `sms.answeredOnArrival` says whether the message
|
||||
you hold was answered that way; a segment count cannot, since a peer may number a message one part
|
||||
of one.
|
||||
numbers itself into no message this session can join, which refuses it, or the reassembly buffer or
|
||||
the unanswered messages are at their bound, which asks the SMSC to keep it and try again.
|
||||
`sms.answeredOnArrival` says whether the message you hold was answered that way; a segment count
|
||||
cannot, since a peer may number a message one part of one.
|
||||
|
||||
- The id was fixed with the first segment, so `sendResp()` there only says you are done, and
|
||||
returns `err` for an `smsId` or a refusing `status`.
|
||||
- The id was fixed with the first segment, so `sendResp()` there puts nothing on the wire and
|
||||
releases the message, and 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
|
||||
`submit_sm` responses carried.
|
||||
- A `deliver_sm` is answered with no id at all, since SMPP marks that field unused, so an inbound
|
||||
@@ -600,6 +613,10 @@ if (isCommand(pduObj, 'submit_sm')) {
|
||||
and hands back the UDH where the PDU carries one.
|
||||
- `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.
|
||||
- `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
|
||||
TE-specific ones, `undefined` where that `data_coding`'s coding group carries no class.
|
||||
|
||||
@@ -608,6 +625,10 @@ if (isCommand(pduObj, 'submit_sm')) {
|
||||
- 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,
|
||||
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
|
||||
data headers, deliberately malformed bodies.
|
||||
- `session.send()` and `session.sendReturn()` build through the same codec and refuse the same bodies.
|
||||
@@ -641,15 +662,17 @@ See [MIGRATION.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/MIGRAT
|
||||
## Goals
|
||||
|
||||
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 below.
|
||||
one wins. They do not override the [hard rules](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/AGENTS.md#hard-rules).
|
||||
|
||||
1. **Correct on the wire.** SMPP 3.4 as SMSCs actually run it. Every other goal yields to this one;
|
||||
the defect table below is what the alternative costs.
|
||||
the [defect table](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/AGENTS.md#defects-found-in-040) is what the alternative costs.
|
||||
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
|
||||
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
|
||||
is not dropped.
|
||||
is not dropped; a call that reports a message as sent asserts that the wire carried what the caller
|
||||
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
|
||||
the codec parses whatever arrives. Where the letter of the spec would discard traffic a real SMSC
|
||||
sends, keep the traffic.
|
||||
@@ -660,24 +683,30 @@ one wins. They do not override the hard rules below.
|
||||
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
|
||||
default or opts out of it; an option does not switch on the thing the caller obviously wanted.
|
||||
6. **Configurable and extendable, never at the defaults' expense.** Where an application needs other
|
||||
6. **Fast enough that this library is never the bottleneck.** Throughput over a few sessions rather
|
||||
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
|
||||
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
|
||||
its internals.
|
||||
7. **A small, stable public surface over reshapeable internals.** Only what `src/index.ts` exports is
|
||||
8. **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
|
||||
promise this library can verify. The low-level surface is a passthrough: policy binds what the
|
||||
library composes, never what the caller wrote.
|
||||
8. **State wider than one session goes through one store.** A pool of sessions, a limit shared
|
||||
9. **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
|
||||
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
|
||||
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
|
||||
argument each time.
|
||||
9. **It builds, tests and runs the same everywhere.** Container-only toolchain, no runtime
|
||||
dependencies, the Node 18 floor verified in CI rather than asserted, every README example executed
|
||||
10. **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
|
||||
by the suite.
|
||||
|
||||
## Audience
|
||||
@@ -686,15 +715,21 @@ 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
|
||||
reshaped freely.
|
||||
- **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.
|
||||
- **Node 18 and newer, ESM only, no runtime dependencies.**
|
||||
- **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
|
||||
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
|
||||
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
|
||||
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:
|
||||
|
||||
|
||||
@@ -17,9 +17,26 @@ docker compose -f compose.yaml -f interop-tests/compose.jasmin.yaml run --rm nod
|
||||
--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
|
||||
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
|
||||
|
||||
Single host, 8 cores, Node 24.18.0 in the project container, loopback. Client and SMSC are separate
|
||||
@@ -45,6 +62,36 @@ Against real peers, same driver, window 50, 20,000 messages:
|
||||
| Jasmin 0.10 | 2,207 | 0 |
|
||||
| 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
|
||||
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
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
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"
|
||||
@@ -0,0 +1,25 @@
|
||||
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"
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 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');
|
||||
@@ -1,7 +1,14 @@
|
||||
import { availableParallelism } from 'node:os';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
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
|
||||
* it measures, and they must run on the same runtime this library is measured under.
|
||||
@@ -61,3 +68,28 @@ for (const row of rows) {
|
||||
|
||||
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);
|
||||
|
||||
+88
-54
@@ -17,9 +17,6 @@ rule and an index of the titles below.
|
||||
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.
|
||||
|
||||
- **`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,
|
||||
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
|
||||
@@ -36,7 +33,7 @@ rule and an index of the titles below.
|
||||
- **`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
|
||||
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 7 is paid by
|
||||
without the class as a value an application is left string-matching `err.message`. Goal 8 is paid by
|
||||
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
|
||||
because `reason` is compared against string literals, and an accessor
|
||||
@@ -63,10 +60,10 @@ rule and an index of the titles below.
|
||||
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()`,
|
||||
which serve their own tables that way. Rejected: a `Result` signature on all three, which costs
|
||||
every typed consumer a narrow forever — goal 7, and the tag is the last cheap chance to spend it —
|
||||
every typed consumer a narrow forever — goal 8, 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:
|
||||
`sendSms()` takes its options as `unknown` and refuses `encoding` by name, which is what a caller
|
||||
without types gets. `smppTime.encode()` is where that reasoning lands the other way and is recorded
|
||||
`sendSms()` widens `encoding`, `messagingMode` and the two time options to `unknown` and refuses
|
||||
each by name, which is what a caller 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`.
|
||||
|
||||
- **A segment the SMSC took and named no id for is `undefined` in `smsIds`, not an empty string.**
|
||||
@@ -125,7 +122,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
|
||||
carry, several SMSCs use it, and Jasmin relays one faithfully — reading `short_message` alone
|
||||
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()`
|
||||
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
|
||||
@@ -133,8 +130,7 @@ rule and an index of the titles below.
|
||||
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,
|
||||
which discards a message that is almost certainly present twice over, where goal 3 keeps the
|
||||
traffic. The reassembler's octet cap already counts TLV values, so a 64 KB payload is bounded like
|
||||
any other segment.
|
||||
traffic.
|
||||
|
||||
- **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
|
||||
@@ -142,7 +138,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,
|
||||
Jasmin documents it as its own segmentation and jsmpp writes it, and reading the UDH alone handed
|
||||
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
|
||||
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
|
||||
@@ -168,13 +164,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
|
||||
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
|
||||
receipt merge, which is what goal 7 means by beating "the application can do this itself". The four
|
||||
receipt merge, which is what goal 8 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
|
||||
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.
|
||||
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
|
||||
that reason rather than as an unknown name — a mode this library cannot deliver is a promise goal 7
|
||||
that reason rather than as an unknown name — a mode this library cannot deliver is a promise goal 8
|
||||
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 under any other mode both go out untouched. Those three names left `ESM_CLASS`, where they
|
||||
@@ -224,14 +220,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
|
||||
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
|
||||
([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
|
||||
`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
|
||||
`encodingByDataCoding()` reads the alphabet off that same test rather than repeating the group
|
||||
masks beside it. It is exported for the reason `concatOf()` is — an application that needs a class
|
||||
other than 0 would otherwise rewrite the read this fixed. Rejected: a `messageClass` field on the
|
||||
`sms` event, which pays goal 7 for three classes nothing here acts on, where the boolean the
|
||||
`sms` event, which pays goal 8 for three classes nothing here acts on, where the boolean the
|
||||
application already had covers the one it does. Compressed text is out of scope and stays out —
|
||||
nothing here implements 3GPP TS 23.042, so a compressed body reaches the application as whatever
|
||||
its declared alphabet makes of it — but bit 5 does not move the class bits, so 0x30 is read as
|
||||
@@ -326,7 +322,7 @@ rule and an index of the titles below.
|
||||
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
|
||||
`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
|
||||
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
|
||||
@@ -359,8 +355,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
|
||||
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
|
||||
a fourth alphabet state its own. Rejected: 134 for GSM 7-bit too, which is the mistake the
|
||||
unpacked-alphabet section above exists to stop. Accepted: a Latin-1 message past the 140 characters
|
||||
a fourth alphabet state its own. Rejected: 134 for GSM 7-bit too, which is the mistake
|
||||
[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
|
||||
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
|
||||
@@ -413,14 +409,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
|
||||
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
|
||||
under any `data_coding`, which is what keeps goal 7's escape hatch open — the raw UDH, 8-bit binary
|
||||
under any `data_coding`, which is what keeps goal 8's escape hatch open — the raw UDH, 8-bit binary
|
||||
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
|
||||
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
|
||||
`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
|
||||
error prose as API for an application whose own refusal should read like itself. Goal 7, from the
|
||||
error prose as API for an application whose own refusal should read like itself. Goal 8, from the
|
||||
architecture review of [#99](https://github.com/larvit/larvitsmpp/pull/99), 2026-09-09. It is
|
||||
reached through
|
||||
`encodeBody()` in `message.ts`, which is where the `data_coding`-to-text pair already lives:
|
||||
@@ -443,8 +439,8 @@ rule and an index of the titles below.
|
||||
the guard would be a second spelling that disagrees about correctness. Rejected: refusing a string
|
||||
`message_payload` outright and demanding
|
||||
octets, which contradicts `short_message` on the same PDU. Rejected: guarding every string-valued
|
||||
field, which `data_coding` says nothing about — an address is a C-Octet String and ASCII by 3.4's
|
||||
own definition.
|
||||
field against `data_coding`, which says nothing about them — a text field on the wire has an
|
||||
alphabet of its own.
|
||||
|
||||
- **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
|
||||
@@ -478,6 +474,25 @@ rule and an index of the titles below.
|
||||
concatenation reference rather than on `data_coding` — so a receipt or a segment that crossed the
|
||||
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
|
||||
|
||||
- **A close arriving after our own `unbind` is a clean unbind, not an error.** Maintainer's call,
|
||||
@@ -569,12 +584,16 @@ rule and an index of the titles below.
|
||||
reports each session's unfinished drain through `serverError`, because its own result says nothing
|
||||
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
|
||||
application's own signal rather than the peer's answer.** Maintainer's call, 2026-09-06, from the
|
||||
Jasmin interoperability phase: Jasmin dispatches one `submit_sm` per connector at a time and will
|
||||
not send segment 2 until segment 1 is answered, so holding a group unanswered until it was whole
|
||||
deadlocked every multi-segment message against a production gateway
|
||||
([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
|
||||
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`
|
||||
@@ -584,7 +603,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
|
||||
an application that must refuse a PDU the `sms` event could not have shown it yet. `collect()`
|
||||
answers every segment it will not carry rather than leaving it unanswered, which is the same stall
|
||||
in miniature: the field that numbered it where the segment belongs to no group, `ESME_RMSGQFUL`
|
||||
in miniature: the field that numbered it where the segment belongs to no group, the retry status
|
||||
where the segment's own arrival overran the octet cap, since a peer told that still holds it. Rejected:
|
||||
answering every segment but the one that completes the group, which leaves the peer holding some
|
||||
segments accepted and one refused with nothing in SMPP to retract the rest, and still cannot honour
|
||||
@@ -595,7 +614,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`
|
||||
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
|
||||
application could act on, and goal 7 does not buy a second exported class to make a count
|
||||
application could act on, and goal 8 does not buy a second exported class to make a count
|
||||
distinguishable. Accepted: a completing segment whose own answer the socket would not carry still
|
||||
reaches the application, because the message is whole and correct and the failed answer is on
|
||||
`sessionError` — a peer that re-sends after the drop is the smaller risk than dropping a message
|
||||
@@ -607,7 +626,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
|
||||
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
|
||||
what goal 7 means by beating "the application can do this itself". What the library verifies is the
|
||||
what goal 8 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
|
||||
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
|
||||
@@ -645,32 +664,40 @@ 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 —
|
||||
the duplicate goal 2 forbids, in the direction the window already covers. No completion signal was
|
||||
added to the `sms` event: `sendResp()` is what an application already calls when it is done with a
|
||||
message, so it is the one the drain waits for. Counting every inbound request until `sendReturn()` answered it was rejected —
|
||||
an `onRequest` that deliberately answers nothing would then cost a full `shutdownTimeout` on every
|
||||
close — and a message no listener took is released at once, since nothing is going to answer it.
|
||||
A listener that failed before answering gives it up the same way, but only once every listener has:
|
||||
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
|
||||
reaching the wire, not the call — a `sendResp()` the library refused, or one the socket would not
|
||||
carry, leaves the message held, so `close()` still reports the one the peer is owed. Where the
|
||||
segments were answered as they arrived there is no response left to write, so the call itself ends
|
||||
the wait, an argument the library refuses excepted. `teardown()`
|
||||
drops what is still held for the same reason it drops inbound segments. The release is one turn
|
||||
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
|
||||
message is still held — past that it is an ordinary send, because the drain it would slip past is
|
||||
no longer waiting for it. `shutdownTimeout: 0` does not carry over to this half:
|
||||
waiting forever is safe for the peer, whose every request is bounded by `responseTimeout` unless the
|
||||
caller set that to 0 as well, and unsafe for the application, which nothing bounds — `close()` is
|
||||
what you reach for when the application is stuck, so it may not block on the application coming
|
||||
unstuck. That half falls back to `responseTimeout`, the same answer the link gate's hold already
|
||||
takes — and to that option's default where it is 0 as well, since neither option is an answer about
|
||||
the application. What is held is capped and expiring like every other inbound store, on constants
|
||||
rather than options, because a bound the application cannot raise is the point: an application that
|
||||
answers nothing would otherwise grow it for the life of the link, which goal 4 forbids. A message
|
||||
that falls out of the bound is one the drain stops waiting for, so `close()` can report fewer
|
||||
unanswered than there were — accepted, because the alternative is holding what nothing will answer,
|
||||
and both exits are logged.
|
||||
message, so it is the one the drain waits for. Counting every inbound request until `sendReturn()`
|
||||
answered it was rejected: an `onRequest` that deliberately answers nothing would then cost a full
|
||||
`shutdownTimeout` on every close. The response reaching the wire ends the wait, so a `sendResp()`
|
||||
the library refused or the socket would not carry leaves `close()` still reporting the message the
|
||||
peer is owed.
|
||||
|
||||
- **The drain's wait on the application ignores `shutdownTimeout: 0`.** Waiting forever is safe for
|
||||
the peer, whose every request is bounded by `responseTimeout` unless the caller set that to 0 as
|
||||
well, and unsafe for the application, which nothing bounds — `close()` is what you reach for when
|
||||
the application is stuck, so it may not block on the application coming unstuck. That half falls
|
||||
back to `responseTimeout`, the same answer the link gate's hold already takes — and to that
|
||||
option's default where it is 0 as well, since neither option is an answer about the application.
|
||||
|
||||
- **What the application holds unanswered is capped on constants, and a message past the cap is
|
||||
refused.** A bound the application cannot raise is the point: an application that answers nothing
|
||||
would otherwise hold ever more messages, which goal 4 forbids. Reassembly's `maxOctets` is an
|
||||
option because it bounds what the peer sends; this bounds what the application leaves unanswered.
|
||||
Maintainer's call, 2026-09-26. Refusing leaves the message with the peer, which will send it again
|
||||
(goal 2). Rejected: dropping the oldest to make room, which frees nothing while the application
|
||||
still holds its `Sms`, and stops the drain waiting for a message the peer is owed. Rejected:
|
||||
pausing the socket, which also stalls every answer and `enquire_link` on the link. Reaching the
|
||||
bound shows only in the log (goal 8): an event or a public count would be surface for what the
|
||||
application already knows, since it is the one not answering. A message held past its timeout is
|
||||
still dropped, so `close()` can report fewer unanswered than there were — accepted, because the
|
||||
alternative is holding what nothing will answer.
|
||||
|
||||
- **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.**
|
||||
`onDelivery()` answers each receipt before the group it belongs to is complete, and `teardown()`
|
||||
@@ -770,7 +797,7 @@ rule and an index of the titles below.
|
||||
dev image has no openssl.
|
||||
|
||||
- **`src/` stays flat until a module has to move for another reason.** Architecture review,
|
||||
2026-09-06: the grouping the file map above already implies — `wire/` for `pdu*` and `defs`,
|
||||
2026-09-06: the grouping the [file map](../AGENTS.md#architecture) already implies — `wire/` for `pdu*` and `defs`,
|
||||
`link/` for `link-*`, `reconnect-*`, `pdu-transport` and `send-window`, `messages/` for `sms*`,
|
||||
`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.
|
||||
@@ -788,5 +815,12 @@ 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:
|
||||
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
|
||||
run on Linux by goal 9. Rejected: macOS and Windows runners, on GitHub's mirror or as Gitea
|
||||
run on Linux by goal 10. Rejected: macOS and Windows runners, on GitHub's mirror or as Gitea
|
||||
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.
|
||||
|
||||
@@ -12,27 +12,37 @@ x-dumbclient-healthcheck: &dumbclient-healthcheck
|
||||
timeout: 2s
|
||||
|
||||
services:
|
||||
# Network owner for every dumbclient-* service and the capture sidecar below (see the comment on
|
||||
# `capture`): all four are pure outbound TCP clients with nothing of their own listening, so
|
||||
# sharing one netns is only ever a source-IP detail, never a port collision.
|
||||
# Network owner for every dumbclient-* service and the capture sidecar below: all four are pure
|
||||
# outbound TCP clients, so sharing one netns is only a source-IP detail. It never exits, because a
|
||||
# client that finishes early would take the namespace, and every other conversation, with it.
|
||||
dumbclient-netns:
|
||||
image: nicolaka/netshoot:v0.16
|
||||
<<: *log-limits
|
||||
command: ["sleep", "infinity"]
|
||||
init: true
|
||||
|
||||
dumbclient-w2000:
|
||||
build: ./interop-tests/peers/dumbclient
|
||||
image: interop-dumbclient:de0334b
|
||||
<<: *log-limits
|
||||
network_mode: "service:dumbclient-netns"
|
||||
command: ["conf/window2000.yml"]
|
||||
healthcheck: *dumbclient-healthcheck
|
||||
depends_on:
|
||||
dumbclient-netns:
|
||||
condition: service_started
|
||||
|
||||
# S9's comparison run: window below maxHeldMessages (1000, session-options.ts), where nothing
|
||||
# should ever be evicted - see findings/07-load.md.
|
||||
# should ever be throttled - see findings/07-load.md.
|
||||
dumbclient-w500:
|
||||
build: ./interop-tests/peers/dumbclient
|
||||
image: interop-dumbclient:de0334b
|
||||
<<: *log-limits
|
||||
network_mode: "service:dumbclient-w2000"
|
||||
network_mode: "service:dumbclient-netns"
|
||||
command: ["conf/window500.yml"]
|
||||
healthcheck: *dumbclient-healthcheck
|
||||
depends_on:
|
||||
dumbclient-w2000:
|
||||
dumbclient-netns:
|
||||
condition: service_started
|
||||
|
||||
# S6: sends one message, then never speaks again - the no-ping binary (see the Dockerfile) sends
|
||||
@@ -41,13 +51,13 @@ services:
|
||||
build: ./interop-tests/peers/dumbclient
|
||||
image: interop-dumbclient:de0334b
|
||||
<<: *log-limits
|
||||
network_mode: "service:dumbclient-w2000"
|
||||
network_mode: "service:dumbclient-netns"
|
||||
environment:
|
||||
DUMBCLIENT_BIN: /app/smpp-dumb-client-noping
|
||||
command: ["conf/idle.yml"]
|
||||
healthcheck: *dumbclient-healthcheck
|
||||
depends_on:
|
||||
dumbclient-w2000:
|
||||
dumbclient-netns:
|
||||
condition: service_started
|
||||
|
||||
# The long soak: the longest run the time-box allows, fast handler, watched for anything that
|
||||
@@ -56,25 +66,25 @@ services:
|
||||
build: ./interop-tests/peers/dumbclient
|
||||
image: interop-dumbclient:de0334b
|
||||
<<: *log-limits
|
||||
network_mode: "service:dumbclient-w2000"
|
||||
network_mode: "service:dumbclient-netns"
|
||||
command: ["conf/soak.yml"]
|
||||
healthcheck: *dumbclient-healthcheck
|
||||
depends_on:
|
||||
dumbclient-w2000:
|
||||
dumbclient-netns:
|
||||
condition: service_started
|
||||
|
||||
# Every dumbclient-* service shares dumbclient-w2000's netns (see above), so this one sidecar
|
||||
# Every dumbclient-* service shares dumbclient-netns's namespace (see above), so this one sidecar
|
||||
# sees all four conversations with node:2775 - the same pattern compose.kannel.yaml uses for its
|
||||
# four bearerbox variants, one namespace deeper.
|
||||
capture:
|
||||
image: nicolaka/netshoot:v0.16
|
||||
network_mode: "service:dumbclient-w2000"
|
||||
network_mode: "service:dumbclient-netns"
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
depends_on:
|
||||
dumbclient-w2000:
|
||||
condition: service_healthy
|
||||
dumbclient-netns:
|
||||
condition: service_started
|
||||
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/dumbclient.pcapng"]
|
||||
volumes:
|
||||
- ./interop-tests/captures:/captures
|
||||
|
||||
@@ -206,17 +206,26 @@ after(async () => {
|
||||
|
||||
// S9 (target 11) and the backpressure-at-server scenario: window 2000 at a high rate against a
|
||||
// handler slowed enough to build a real backlog. window500 is the same shape with a window below
|
||||
// maxHeldMessages (1000, session-options.ts defaults.maxHeldMessages) - see findings/07-load.md for
|
||||
// what that constant, rather than maxOutstanding, turns out to be the one that interacts with a
|
||||
// peer's window.
|
||||
describe('S9 - bounded window against a slowed handler', () => {
|
||||
for (const [name, expectedCount] of [['dumb-w500', 20_000], ['dumb-w2000', 20_000]] as const) {
|
||||
test(`${name}: every message answered exactly once, ordering holds`, async () => {
|
||||
const done = await waitFor(() => (statsFor(name).answered >= expectedCount ? true : undefined), 180_000);
|
||||
// maxHeldMessages (1000, session-options.ts defaults.maxHeldMessages), the bound past which a
|
||||
// peer's window is answered ESME_RTHROTTLED. smpp-dumb-client counts a throttled message as sent
|
||||
// and never resends it, so window 2000 accounts for 20,000 as answered plus throttled.
|
||||
const throttleMessage = 'session - unanswered messages at their bound, asking the peer to retry';
|
||||
|
||||
assert.ok(done, `${name} did not answer ${String(expectedCount)} messages within budget`);
|
||||
// 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', () => {
|
||||
for (const name of ['dumb-w500', 'dumb-w2000'] as const) {
|
||||
test(`${name}: every message answered or throttled exactly once, ordering holds`, async () => {
|
||||
const done = await waitFor(() => (statsFor(name).answered + throttled(name) >= 20_000 ? true : undefined), 180_000);
|
||||
|
||||
assert.ok(done, `${name} did not account for 20000 messages within budget`);
|
||||
|
||||
const s = statsFor(name);
|
||||
const expectedCount = 20_000 - throttled(name);
|
||||
|
||||
assert.equal(s.arrived, expectedCount);
|
||||
assert.equal(s.answered, expectedCount);
|
||||
@@ -227,17 +236,9 @@ describe('S9 - bounded window against a slowed handler', () => {
|
||||
});
|
||||
}
|
||||
|
||||
test('window 2000 pressed past maxHeldMessages (1000): the internal held-message cap evicts, window500 never does', async () => {
|
||||
await waitFor(() => (statsFor('dumb-w2000').answered >= 20_000 ? true : undefined), 180_000);
|
||||
|
||||
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);
|
||||
test('window 2000 pressed past maxHeldMessages (1000): the peer is throttled, window500 never is', () => {
|
||||
assert.ok(throttled('dumb-w2000') > 0, 'expected at least one ESME_RTHROTTLED under window 2000');
|
||||
assert.ok(statsFor('dumb-w2000').peakOutstanding <= 1000);
|
||||
assert.equal(statsFor('dumb-w500').peakOutstanding <= 500, true);
|
||||
});
|
||||
|
||||
@@ -284,10 +285,8 @@ 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
|
||||
// grows without bound (held messages, listeners, memory). Bounded by wall-clock rather than a
|
||||
// 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.
|
||||
// grows without bound (held messages, listeners, memory). Bounded by wall-clock, and asserting that
|
||||
// arrived/answered stay in lockstep.
|
||||
describe('Long soak', () => {
|
||||
const SOAK_DURATION_MS = 300_000;
|
||||
|
||||
|
||||
@@ -39,12 +39,33 @@ 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
|
||||
(`<<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
|
||||
identically on three separate runs (byte-for-byte). Recorded as **blocked**; `smppload.test.ts`
|
||||
Re-examined 2026-09-20 to see whether it could be unblocked for the throughput comparison in
|
||||
`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
|
||||
unframeable - see Scenarios) rather than removing the peer. `smpp-dumb-client` covers S9, and
|
||||
substitutes for S6 and (partially) S8 - see below.
|
||||
|
||||
### smpp-dumb-client: builds and interoperates cleanly; its own window bookkeeping stalls under sustained load
|
||||
### smpp-dumb-client: builds and interoperates cleanly
|
||||
|
||||
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
|
||||
@@ -57,25 +78,18 @@ 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,
|
||||
resolved with `getent hosts` and substituted into a writable copy before the real binary starts.
|
||||
|
||||
Four one-shot scenarios share `dumbclient-w2000`'s network namespace (`network_mode:
|
||||
"service:dumbclient-w2000"`) - they are pure outbound clients with nothing of their own listening,
|
||||
so the only shared cost is a source IP, and one capture sidecar sees all four conversations with
|
||||
`node:2775` the same way `compose.kannel.yaml`'s does for its four bearerbox variants.
|
||||
The four scenarios share one network namespace, owned by `dumbclient-netns`, a container that
|
||||
never exits - they are pure outbound clients with nothing of their own listening, so the only shared
|
||||
cost is a source IP, and one capture sidecar sees all four conversations with `node:2775` the same
|
||||
way `compose.kannel.yaml`'s does for its four bearerbox variants.
|
||||
|
||||
The long soak (below) surfaced a peer-side limit worth designing around rather than fighting: with
|
||||
a fast, immediate-response handler and a window of 100 - nothing our server should ever have
|
||||
trouble draining - the peer's own reported in-flight count (`GetTrackQueueSize`, read from
|
||||
`len(TrackTX)`) gets stuck pinned at the window within the first minute, and its log fills with
|
||||
`Expired TX packet` lines (`libsmpp`'s hardcoded, non-configurable 7000ms `TX_MAX_TIMEOUT_MS`) -
|
||||
throughput drops from ~500/s to a trickle of tens per second, gated by how many tracked entries
|
||||
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.
|
||||
Runs 1 and 2 had `dumbclient-w2000` own the namespace. It exits once it has sent its 20,000, which
|
||||
took every other client's network with it: the soak's responses stopped arriving, and its log filled
|
||||
with `Expired TX packet` lines (`libsmpp`'s 7000ms `TX_MAX_TIMEOUT_MS`). Those runs read that as the
|
||||
peer's own window bookkeeping stalling; run 3 (2026-09-26), with the namespace owned by a container
|
||||
that outlives them all, reached 173,820 soak messages in 300s where run 2 reached 22,440. The soak
|
||||
stays bounded by wall-clock (5 minutes), asserting every arrival answered, nothing duplicated, and
|
||||
the memory shape.
|
||||
|
||||
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
|
||||
@@ -84,11 +98,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
|
||||
`session`-creation time, recording it in the same per-scenario stats every other assertion reads.
|
||||
|
||||
Two runs of `./interop-tests/run.py dumbclient`. Run 1 (the original 300,000-count soak) surfaced
|
||||
both the peer's TX-tracking stall and the S6 harness bug above; run 2, after both fixes, is the one
|
||||
reported below. `smppload.test.ts` passed on every run it was given (three, across the investigation
|
||||
above); its one scenario needs no repeat - a second run reproduces the identical corrupted PDU,
|
||||
adding nothing.
|
||||
Three 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
|
||||
throttle in `src/`, is the one Scenarios reports. The capture figures below are run 2's.
|
||||
`smppload.test.ts` passed on every run it was given (three, across the investigation above); its one
|
||||
scenario needs no repeat - a second run reproduces the identical corrupted PDU, adding nothing.
|
||||
|
||||
```
|
||||
dumbclient run 2: frames 111300, bind_transceiver 4/4, enquire_link 12 (enquire_link_resp 9 - the
|
||||
@@ -106,29 +120,24 @@ every session's own `arrived` exactly, and every session's own `answered` matche
|
||||
|
||||
## Throughput and memory
|
||||
|
||||
`dumb-w500` and `dumb-w2000` (S9) both ran to their full 20,000-message count in ~44s each,
|
||||
concurrently, against a handler serialised to answer roughly one message every 2ms
|
||||
(`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.
|
||||
Run 3. `dumb-w500` ran to its full 20,000 in ~44s against a handler serialised to answer roughly
|
||||
one message every 2ms (`SLOW_HANDLER_DELAY_MS`), `peakOutstanding` exactly 500. `dumb-w2000`, run
|
||||
concurrently, held exactly 1000 and was throttled for the rest.
|
||||
|
||||
The soak (fast, immediate-response handler; window 100) reached 22,440 `submit_sm` over its fixed
|
||||
300s observation window - about 75/s, well under the peer's own configured `rate: 500` and under
|
||||
what our server can sustain (see Setup: `smpp-dumb-client`'s own TX-tracking bookkeeping is the
|
||||
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.
|
||||
The soak (fast, immediate-response handler; window 100) reached 173,820 `submit_sm` over its fixed
|
||||
300s, about 580/s, `peakOutstanding` 15. Sampled every 5s across the whole run (69 samples over
|
||||
340s, all four scenarios combined, the harness's own per-message bookkeeping included): rss
|
||||
first=165MiB, min=165MiB, max=298MiB, last=298MiB, heapUsed at the last sample 81MiB.
|
||||
|
||||
## Scenarios (PLAN.md)
|
||||
|
||||
| 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 |
|
||||
| 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 | `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 | 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 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 |
|
||||
| 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) |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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
|
||||
@@ -136,19 +145,16 @@ either direction.
|
||||
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
|
||||
library cannot frame...") is the documented behaviour working exactly as designed against a peer
|
||||
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.
|
||||
that never gets as far as a readable PDU.
|
||||
|
||||
## Peer quirks
|
||||
|
||||
- **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.
|
||||
- **`smpp-dumb-client`'s window bookkeeping stalls under sustained load, throttling its own
|
||||
throughput far below what a promptly-answering server can sustain** - see Setup. Its `enquire_link`
|
||||
interval (10s once bound as an ESME) is also hardcoded (`smpp.go`, `enquireSender(10)`), not
|
||||
exposed through `config.yml` at all - the no-ping binary built for S6 patches the call site out
|
||||
rather than configuring it.
|
||||
- **`smpp-dumb-client` treats `ESME_RTHROTTLED` as final** - a throttled message counts as sent
|
||||
and is never resubmitted. Its `enquire_link` interval (10s once bound as an ESME) is hardcoded
|
||||
(`smpp.go`, `enquireSender(10)`), not 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
|
||||
Setup.
|
||||
|
||||
@@ -156,7 +162,3 @@ run.
|
||||
|
||||
- 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.
|
||||
- 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,6 +64,7 @@ public final class Driver {
|
||||
server.createContext("/bind", Driver::handleBind);
|
||||
server.createContext("/unbind", Driver::handleUnbind);
|
||||
server.createContext("/submit", Driver::handleSubmit);
|
||||
server.createContext("/load", Driver::handleLoad);
|
||||
server.createContext("/windowBurst", Driver::handleWindowBurst);
|
||||
server.createContext("/sendWindowSize", Driver::handleSendWindowSize);
|
||||
server.setExecutor(null);
|
||||
@@ -224,6 +225,62 @@ 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. */
|
||||
private static void handleWindowBurst(HttpExchange exchange) {
|
||||
Map<String, String> p = queryParams(exchange);
|
||||
|
||||
@@ -38,6 +38,10 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
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
|
||||
@@ -64,6 +68,7 @@ public final class Driver {
|
||||
server.createContext("/unbind", Driver::handleUnbind);
|
||||
server.createContext("/enquireLink", Driver::handleEnquireLink);
|
||||
server.createContext("/submit", Driver::handleSubmit);
|
||||
server.createContext("/load", Driver::handleLoad);
|
||||
server.createContext("/querySm", exchange -> handleUnhandledCommand(exchange, "query"));
|
||||
server.createContext("/cancelSm", exchange -> handleUnhandledCommand(exchange, "cancel"));
|
||||
server.createContext("/replaceSm", exchange -> handleUnhandledCommand(exchange, "replace"));
|
||||
@@ -267,6 +272,70 @@ 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,
|
||||
java.util.List<Map<String, Object>> segments) throws Exception {
|
||||
SubmitSmResult r = session.submitShortMessage("CMT",
|
||||
|
||||
@@ -4,7 +4,6 @@ import { buffer, cstring, dest_address_array, int8, unsuccess_sme_array } from '
|
||||
type CommandSpec = {
|
||||
id: number;
|
||||
params?: Record<string, WireType>;
|
||||
tlvMap?: Record<string, string>;
|
||||
};
|
||||
|
||||
const bindParams = {
|
||||
@@ -59,7 +58,6 @@ const specs = {
|
||||
broadcast_sm_resp: {
|
||||
id: 0x80000111,
|
||||
params: { message_id: cstring },
|
||||
tlvMap: { broadcast_area_identifier: 'failed_broadcast_area_identifier' },
|
||||
},
|
||||
cancel_broadcast_sm: {
|
||||
id: 0x00000113,
|
||||
|
||||
+116
-34
@@ -1,18 +1,15 @@
|
||||
import type { ParamValue, WireType } from './types.ts';
|
||||
import type { ParamValue, TlvValue, WireType } from './types.ts';
|
||||
import type { Result } from '../result.ts';
|
||||
import { tlv } from './types.ts';
|
||||
|
||||
export type TlvDefinition = {
|
||||
id: number;
|
||||
multiple?: boolean;
|
||||
tag: string;
|
||||
type: WireType;
|
||||
};
|
||||
/** Only a tag read as octets or as a number may repeat, since its occurrences are listed as one of those. */
|
||||
type Definition<Tag> = { id: number; multiple?: false; tag: Tag; type: WireType<Buffer | number | string> }
|
||||
| { id: number; multiple: true; tag: Tag; type: WireType<Buffer> | WireType<number> };
|
||||
|
||||
export type TlvDefinition = Definition<string>;
|
||||
|
||||
/** 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]: { id: number; multiple?: boolean; tag: K; type: WireType } }>(
|
||||
definitions: T,
|
||||
): T => definitions;
|
||||
const tlvSpecs = <T extends { [K in keyof T]: Definition<K> }>(definitions: T): T => definitions;
|
||||
|
||||
// Ordered by tag id, mirroring the SMPP 5.0 TLV table.
|
||||
const specs = tlvSpecs({
|
||||
@@ -98,18 +95,18 @@ for (const definition of Object.values<TlvDefinition>(specs)) {
|
||||
}
|
||||
|
||||
/** Fallback for tags this table does not know: keep the raw octets. */
|
||||
export const tlvDefault: WireType = tlv.buffer;
|
||||
export const tlvDefault: WireType<Buffer> = tlv.buffer;
|
||||
|
||||
export type Tlv = {
|
||||
tagId: number;
|
||||
tagName: string | undefined;
|
||||
tagValue: ParamValue;
|
||||
tagValue: TlvValue;
|
||||
};
|
||||
|
||||
export type TlvInput = {
|
||||
/** Resolved from the record key; pass it for a tag the TLV table does not define. */
|
||||
tagId?: number | undefined;
|
||||
tagValue: ParamValue;
|
||||
tagValue: TlvValue;
|
||||
};
|
||||
|
||||
export function tagIdOf(name: string, input: TlvInput): Result<{ tagId: number }> {
|
||||
@@ -135,30 +132,115 @@ export function writeTlvs(inputs: Record<string, TlvInput> | undefined): Result<
|
||||
|
||||
if (tag.err) return { err: tag.err };
|
||||
|
||||
const type = tlvsById[tag.tagId]?.type ?? tlvDefault;
|
||||
const sized = type.size(input.tagValue);
|
||||
const definition = tlvsById[tag.tagId];
|
||||
const values = occurrences(input.tagValue, definition?.multiple === true);
|
||||
|
||||
if (sized.err) {
|
||||
return { err: new Error(`TLV "${name}": ${sized.err.message}`) };
|
||||
if (values.err) return { err: new Error(`TLV "${name}": ${values.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);
|
||||
}
|
||||
|
||||
if (sized.size > 0xffff) {
|
||||
return { err: new Error(`TLV "${name}": ${String(sized.size)} octets overflow the two octet length`) };
|
||||
}
|
||||
|
||||
const chunk = Buffer.alloc(sized.size + 4);
|
||||
|
||||
chunk.writeUInt16BE(tag.tagId, 0);
|
||||
chunk.writeUInt16BE(sized.size, 2);
|
||||
|
||||
const written = type.write(input.tagValue, chunk, 4);
|
||||
|
||||
if (written.err) {
|
||||
return { err: new Error(`TLV "${name}": ${written.err.message}`) };
|
||||
}
|
||||
|
||||
chunks.push(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) {
|
||||
return { err: new Error(`${String(sized.size)} octets overflow the two octet length`) };
|
||||
}
|
||||
|
||||
const chunk = Buffer.alloc(sized.size + 4);
|
||||
|
||||
chunk.writeUInt16BE(tagId, 0);
|
||||
chunk.writeUInt16BE(sized.size, 2);
|
||||
|
||||
const written = type.write(value, chunk, 4);
|
||||
|
||||
return written.err ? { err: written.err } : { chunk };
|
||||
}
|
||||
|
||||
type Occurrence = { definition: TlvDefinition | undefined; tagId: number; value: Buffer | number | string };
|
||||
|
||||
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];
|
||||
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) };
|
||||
}
|
||||
|
||||
+91
-22
@@ -1,4 +1,5 @@
|
||||
import type { Result, VoidResult } from '../result.ts';
|
||||
import { unencodableText } from './encodings.ts';
|
||||
|
||||
export type DestAddress =
|
||||
| { dest_addr_npi: number; dest_addr_ton: number; destination_addr: string }
|
||||
@@ -13,6 +14,34 @@ export type UnsuccessSme = {
|
||||
|
||||
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
|
||||
* re-derive a length that could disagree with what was actually written.
|
||||
@@ -25,10 +54,10 @@ export type WireType<T extends ParamValue = ParamValue> = {
|
||||
};
|
||||
|
||||
/** Renders a parameter as text without ever falling back to "[object Object]". */
|
||||
export function paramText(value: ParamValue | undefined): string {
|
||||
export function paramText(value: ParamValue | TlvValue | undefined): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number') return value.toString();
|
||||
if (Buffer.isBuffer(value)) return value.toString('ascii');
|
||||
if (Buffer.isBuffer(value)) return value.toString('latin1');
|
||||
|
||||
return '';
|
||||
}
|
||||
@@ -37,6 +66,11 @@ export function paramNumber(value: ParamValue | undefined, fallback: number): nu
|
||||
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 {
|
||||
if (offset < 0 || needed < 0 || offset + needed > buffer.length) {
|
||||
return new Error(
|
||||
@@ -49,7 +83,7 @@ function outOfRange(buffer: Buffer, offset: number, needed: number): Error | und
|
||||
|
||||
function wantInt(value: ParamValue, max: number): Result<{ int: number }> {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
||||
return { err: new Error(`Expected an integer, got ${JSON.stringify(value)}`) };
|
||||
return { err: new Error(`Expected an integer, got ${valueText(value)}`) };
|
||||
}
|
||||
|
||||
if (value < 0 || value > max) {
|
||||
@@ -79,11 +113,46 @@ function writeInt32(value: ParamValue, buf: Buffer, offset: number): VoidResult
|
||||
return {};
|
||||
}
|
||||
|
||||
function wantText(value: ParamValue): Result<{ text: string }> {
|
||||
if (typeof value === 'string') return { text: value };
|
||||
if (typeof value === 'number') return { text: value.toString() };
|
||||
function pastLatin1(text: string): { err: Error } | undefined {
|
||||
const index = text.search(/[\u0100-\uFFFF]/);
|
||||
|
||||
return { err: new Error(`Expected a string, got ${typeof value}`) };
|
||||
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 }> {
|
||||
if (typeof value !== 'number' && typeof value !== 'string') {
|
||||
return { err: new Error(`Expected a string or a number, got ${typeof value}`) };
|
||||
}
|
||||
|
||||
if (typeof value === 'number' && !Number.isFinite(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 }> {
|
||||
@@ -91,7 +160,7 @@ function wantBytes(value: ParamValue): Result<{ bytes: Buffer }> {
|
||||
|
||||
const { err, text } = wantText(value);
|
||||
|
||||
return err ? { err } : { bytes: Buffer.from(text, 'ascii') };
|
||||
return err ? { err } : { bytes: Buffer.from(text, 'latin1') };
|
||||
}
|
||||
|
||||
function isDestAddress(value: unknown): value is DestAddress {
|
||||
@@ -167,7 +236,7 @@ function readCstring(buffer: Buffer, offset: number): Result<{ bytesRead: number
|
||||
}
|
||||
}
|
||||
|
||||
return { bytesRead: length + 1, value: buffer.toString('ascii', offset, offset + length) };
|
||||
return { bytesRead: length + 1, value: buffer.toString('latin1', offset, offset + length) };
|
||||
}
|
||||
|
||||
function writeCstring(text: string, buffer: Buffer, offset: number): VoidResult {
|
||||
@@ -175,7 +244,7 @@ function writeCstring(text: string, buffer: Buffer, offset: number): VoidResult
|
||||
|
||||
if (err) return { err };
|
||||
|
||||
buffer.write(text, offset, 'ascii');
|
||||
buffer.write(text, offset, 'latin1');
|
||||
buffer[offset + text.length] = 0;
|
||||
|
||||
return {};
|
||||
@@ -248,7 +317,7 @@ export const string: WireType<string> = {
|
||||
|
||||
if (err) return { err };
|
||||
|
||||
return { bytesRead: length + 1, value: buffer.toString('ascii', offset + 1, offset + 1 + length) };
|
||||
return { bytesRead: length + 1, value: buffer.toString('latin1', offset + 1, offset + 1 + length) };
|
||||
},
|
||||
size(value) {
|
||||
const { err, text } = wantText(value);
|
||||
@@ -271,7 +340,7 @@ export const string: WireType<string> = {
|
||||
if (rangeErr) return { err: rangeErr };
|
||||
|
||||
buffer.writeUInt8(text.length, offset);
|
||||
buffer.write(text, offset + 1, 'ascii');
|
||||
buffer.write(text, offset + 1, 'latin1');
|
||||
|
||||
return {};
|
||||
},
|
||||
@@ -288,12 +357,12 @@ export const cstring: WireType<string> = {
|
||||
default: '',
|
||||
read: readCstring,
|
||||
size(value) {
|
||||
const { err, text } = wantText(value);
|
||||
const { err, text } = wantCstringText(value);
|
||||
|
||||
return err ? { err } : { size: text.length + 1 };
|
||||
},
|
||||
write(value, buffer, offset) {
|
||||
const { err, text } = wantText(value);
|
||||
const { err, text } = wantCstringText(value);
|
||||
|
||||
return err ? { err } : writeCstring(text, buffer, offset);
|
||||
},
|
||||
@@ -401,7 +470,7 @@ export const dest_address_array: WireType<DestAddress[]> = {
|
||||
if ('dl_name' in dest) {
|
||||
buf.writeUInt8(2, offset++);
|
||||
|
||||
const name = writeCstring(dest.dl_name, buf, offset);
|
||||
const name = cstring.write(dest.dl_name, buf, offset);
|
||||
|
||||
if (name.err) return { err: name.err };
|
||||
|
||||
@@ -417,7 +486,7 @@ export const dest_address_array: WireType<DestAddress[]> = {
|
||||
|
||||
if (npi.err) return { err: npi.err };
|
||||
|
||||
const addr = writeCstring(dest.destination_addr, buf, offset);
|
||||
const addr = cstring.write(dest.destination_addr, buf, offset);
|
||||
|
||||
if (addr.err) return { err: addr.err };
|
||||
|
||||
@@ -505,7 +574,7 @@ export const unsuccess_sme_array: WireType<UnsuccessSme[]> = {
|
||||
|
||||
if (npi.err) return { err: npi.err };
|
||||
|
||||
const addr = writeCstring(sme.destination_addr, buf, offset);
|
||||
const addr = cstring.write(sme.destination_addr, buf, offset);
|
||||
|
||||
if (addr.err) return { err: addr.err };
|
||||
|
||||
@@ -538,15 +607,15 @@ export const tlv = {
|
||||
? offset + length
|
||||
: terminator;
|
||||
|
||||
return { bytesRead: length, value: buf.toString('ascii', offset, end) };
|
||||
return { bytesRead: length, value: buf.toString('latin1', offset, end) };
|
||||
},
|
||||
size(value: ParamValue) {
|
||||
const { err, text } = wantText(value);
|
||||
const { err, text } = wantCstringText(value);
|
||||
|
||||
return err ? { err } : { size: text.length + 1 };
|
||||
},
|
||||
write(value: ParamValue, buf: Buffer, offset: number) {
|
||||
const { err, text } = wantText(value);
|
||||
const { err, text } = wantCstringText(value);
|
||||
|
||||
return err ? { err } : writeCstring(text, buf, offset);
|
||||
},
|
||||
@@ -559,7 +628,7 @@ export const tlv = {
|
||||
read(buf: Buffer, offset: number, length = 0) {
|
||||
const err = outOfRange(buf, offset, length);
|
||||
|
||||
return err ? { err } : { bytesRead: length, value: buf.toString('ascii', offset, offset + length) };
|
||||
return err ? { err } : { bytesRead: length, value: buf.toString('latin1', offset, offset + length) };
|
||||
},
|
||||
size(value: ParamValue) {
|
||||
const { err, text } = wantText(value);
|
||||
@@ -575,7 +644,7 @@ export const tlv = {
|
||||
|
||||
if (rangeErr) return { err: rangeErr };
|
||||
|
||||
buf.write(text, offset, 'ascii');
|
||||
buf.write(text, offset, 'latin1');
|
||||
|
||||
return {};
|
||||
},
|
||||
|
||||
+4
-4
@@ -1,5 +1,5 @@
|
||||
import type { MessageState } from './defs/constants.ts';
|
||||
import type { ParamValue } from './defs/types.ts';
|
||||
import type { TlvValue } from './defs/types.ts';
|
||||
import type { PduObject } from './pdu.ts';
|
||||
import type { SmsIdFormat } from './sms-id.ts';
|
||||
import { consts, constsById, hasUdh, messageTypeOf } from './defs/constants.ts';
|
||||
@@ -150,7 +150,7 @@ const smeMessageTypes: readonly number[] = [
|
||||
consts.ESM_CLASS.USER_ACKNOWLEDGEMENT,
|
||||
];
|
||||
|
||||
function nonEmptyText(value: ParamValue | undefined): string | undefined {
|
||||
function nonEmptyText(value: TlvValue | undefined): string | undefined {
|
||||
return typeof value === 'string' && value !== '' ? value : undefined;
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ function receiptBody(pduObj: PduObject): string {
|
||||
}
|
||||
|
||||
function receiptId(
|
||||
tlvId: ParamValue | undefined,
|
||||
tlvId: TlvValue | undefined,
|
||||
receipt: Receipt | undefined,
|
||||
format: SmsIdFormat,
|
||||
): 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. */
|
||||
function receiptStatus(
|
||||
tlvState: ParamValue | undefined,
|
||||
tlvState: TlvValue | undefined,
|
||||
receipt: Receipt | undefined,
|
||||
): { statusId: number; statusMsg: MessageState | undefined } {
|
||||
const scraped = receiptStates[receipt?.stat?.toUpperCase() ?? ''];
|
||||
|
||||
+45
-24
@@ -2,10 +2,12 @@ import type { PduObject } from './pdu.ts';
|
||||
import type { SmppLog } from './log.ts';
|
||||
import { ExpiringGroups } from './expiring-groups.ts';
|
||||
import { IdleWaiters } from './idle-waiters.ts';
|
||||
import { retainedOctets } from './retained-pdu.ts';
|
||||
|
||||
export type HeldMessagesOptions = {
|
||||
log: SmppLog;
|
||||
max: number;
|
||||
maxOctets: number;
|
||||
/** Injected so expiry can be exercised without a wall clock. */
|
||||
now?: (() => number) | undefined;
|
||||
timeout: number;
|
||||
@@ -18,12 +20,18 @@ function keyOf(pduObjs: PduObject[]): string | 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. */
|
||||
export class HeldMessages {
|
||||
private readonly held: ExpiringGroups<PduObject[]>;
|
||||
private readonly held: ExpiringGroups<Held>;
|
||||
private readonly idleWaiters = new IdleWaiters();
|
||||
private readonly log: SmppLog;
|
||||
private readonly max: number;
|
||||
private readonly maxOctets: number;
|
||||
private octets = 0;
|
||||
|
||||
constructor(options: HeldMessagesOptions) {
|
||||
this.held = new ExpiringGroups({
|
||||
@@ -33,14 +41,24 @@ export class HeldMessages {
|
||||
timeout: options.timeout,
|
||||
});
|
||||
this.log = options.log;
|
||||
this.max = options.max;
|
||||
this.maxOctets = options.maxOctets;
|
||||
}
|
||||
|
||||
get octetsHeld(): number {
|
||||
return this.octets;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.held.size;
|
||||
}
|
||||
|
||||
/** An application that answers no message at all may not grow this without end. */
|
||||
/** Whether a message arriving now is past the bound, once the expired are swept. */
|
||||
full(): boolean {
|
||||
this.sweep();
|
||||
|
||||
return this.held.full || this.octets >= this.maxOctets;
|
||||
}
|
||||
|
||||
hold(pduObjs: PduObject[]): void {
|
||||
const key = keyOf(pduObjs);
|
||||
|
||||
@@ -48,35 +66,42 @@ export class HeldMessages {
|
||||
|
||||
this.sweep();
|
||||
|
||||
if (this.held.get(key)) {
|
||||
const replaced = this.held.get(key);
|
||||
|
||||
if (replaced) {
|
||||
this.log.warn('heldMessages - replacing a message on a re-used sequence number', { seqNr: Number(key) });
|
||||
} else if (this.held.full) {
|
||||
this.dropOldest();
|
||||
this.delete(key, replaced);
|
||||
}
|
||||
|
||||
this.held.set(key, pduObjs);
|
||||
const octets = pduObjs.reduce((sum, pduObj) => sum + retainedOctets(pduObj), 0);
|
||||
|
||||
this.held.set(key, { octets, pduObjs });
|
||||
this.octets += octets;
|
||||
}
|
||||
|
||||
/** Whether a drain is still waiting for this message to be answered. */
|
||||
has(pduObjs: PduObject[]): boolean {
|
||||
const key = keyOf(pduObjs);
|
||||
|
||||
return key !== undefined && this.held.get(key) === pduObjs;
|
||||
return key !== undefined && this.held.get(key)?.pduObjs === pduObjs;
|
||||
}
|
||||
|
||||
release(pduObjs: PduObject[]): void {
|
||||
const key = keyOf(pduObjs);
|
||||
|
||||
// Identity, not the key: a wrapped sequence number must not release someone else's message.
|
||||
if (key === undefined || this.held.get(key) !== pduObjs) return;
|
||||
const held = key === undefined ? undefined : this.held.get(key);
|
||||
|
||||
this.held.delete(key);
|
||||
if (key === undefined || held?.pduObjs !== pduObjs) return;
|
||||
|
||||
this.delete(key, held);
|
||||
this.settle();
|
||||
}
|
||||
|
||||
/** Drops every message: their segments went with the link, so no answer of ours correlates now. */
|
||||
clear(): void {
|
||||
this.held.takeAll();
|
||||
this.octets = 0;
|
||||
this.idleWaiters.settle();
|
||||
}
|
||||
|
||||
@@ -85,31 +110,27 @@ export class HeldMessages {
|
||||
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. */
|
||||
sweep(): void {
|
||||
const expired = this.held.takeExpired();
|
||||
|
||||
if (expired.length === 0) return;
|
||||
|
||||
for (const [, held] of expired) {
|
||||
this.octets -= held.octets;
|
||||
}
|
||||
|
||||
this.log.warn('heldMessages - messages the application never answered', {
|
||||
messages: expired.length,
|
||||
});
|
||||
this.settle();
|
||||
}
|
||||
|
||||
private delete(key: string, held: Held): void {
|
||||
this.held.delete(key);
|
||||
this.octets -= held.octets;
|
||||
}
|
||||
|
||||
private settle(): void {
|
||||
if (this.held.size === 0) this.idleWaiters.settle();
|
||||
}
|
||||
|
||||
@@ -13,11 +13,17 @@ import { Reassembler, decodeSegments } from './reassembly.ts';
|
||||
import { bindCommands, defaults, standsInFor } from './session-options.ts';
|
||||
import { concatOf } from './concat.ts';
|
||||
import { createSms } from './sms.ts';
|
||||
import { detach } from './retained-pdu.ts';
|
||||
import { dlrFromPdu } from './dlr.ts';
|
||||
import { paramText } from './defs/types.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(
|
||||
carriedAs: string,
|
||||
refusal: Refusal,
|
||||
@@ -28,7 +34,7 @@ export function refusedSegmentStatus(
|
||||
return spelling === 'sar' ? 'ESME_RINVTLVVAL' : 'ESME_RINVESMCLASS';
|
||||
}
|
||||
|
||||
return carriedAs === 'submit_sm' ? 'ESME_RMSGQFUL' : 'ESME_RX_T_APPN';
|
||||
return throttledStatus(carriedAs);
|
||||
}
|
||||
|
||||
const lostReasons: Record<LostGroup['reason'], string> = {
|
||||
@@ -65,12 +71,14 @@ export class IncomingRequests {
|
||||
private readonly smsIdFormat: SmsIdFormat;
|
||||
private readonly systemId: string;
|
||||
private linkGeneration = 0;
|
||||
private refusing = false;
|
||||
|
||||
constructor(options: IncomingRequestsOptions) {
|
||||
this.dlrMerger = options.dlrMerger;
|
||||
this.held = new HeldMessages({
|
||||
log: options.log,
|
||||
max: defaults.maxHeldMessages,
|
||||
maxOctets: defaults.maxHeldOctets,
|
||||
timeout: defaults.heldMessageTimeout,
|
||||
});
|
||||
this.log = options.log;
|
||||
@@ -141,6 +149,7 @@ export class IncomingRequests {
|
||||
/** Drops the segments of every message that never became whole, and of every one still held. */
|
||||
clear(): void {
|
||||
this.linkGeneration++;
|
||||
this.refusing = false;
|
||||
this.held.clear();
|
||||
this.reassembler.clear();
|
||||
}
|
||||
@@ -171,6 +180,12 @@ export class IncomingRequests {
|
||||
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 });
|
||||
await this.session.sendReturn(pduObj, 'ESME_RINVCMDID');
|
||||
}
|
||||
@@ -198,15 +213,49 @@ export class IncomingRequests {
|
||||
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
|
||||
* one request at a time never sends the second segment until the first has been answered.
|
||||
*/
|
||||
private async onMessage(pduObj: PduObject): Promise<void> {
|
||||
if (await this.refusedAtBound(pduObj)) return;
|
||||
|
||||
const concat = concatOf(pduObj);
|
||||
|
||||
if (!concat) {
|
||||
this.emitSms([pduObj]);
|
||||
this.emitSms([detach(pduObj)]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ export type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
|
||||
export type { PduHeader } from './pdu-refusal.ts';
|
||||
export type { SplitOptions } from './message.ts';
|
||||
export type { Tlv, TlvDefinition, TlvName } from './defs/tlvs.ts';
|
||||
export type { DestAddress, ParamValue, UnsuccessSme, WireType } from './defs/types.ts';
|
||||
export type { DestAddress, ParamValue, TlvValue, UnsuccessSme, WireType } from './defs/types.ts';
|
||||
|
||||
/** The spec tables, grouped the way `larvitsmpp.defs` was in 0.4.0. */
|
||||
export { defs } from './defs/index.ts';
|
||||
|
||||
+3
-32
@@ -9,8 +9,8 @@ import { cmds, commandNameById, respNameFor } from './defs/commands.ts';
|
||||
import { hasUdh } from './defs/constants.ts';
|
||||
import { decodeMessage, encodeBody } from './message.ts';
|
||||
import { errorNameById, errors, isErrorName } from './defs/errors.ts';
|
||||
import { paramNumber } from './defs/types.ts';
|
||||
import { tagIdOf, tlvDefault, tlvs, tlvsById, writeTlvs } from './defs/tlvs.ts';
|
||||
import { paramNumber, valueText } from './defs/types.ts';
|
||||
import { parseTlvs, tagIdOf, tlvs, writeTlvs } from './defs/tlvs.ts';
|
||||
|
||||
/** The highest sequence number this library hands out; SMPP 3.4 4.7.1 reserves 0x7fffffff. */
|
||||
export const maxSeqNr = 2147483646;
|
||||
@@ -234,7 +234,7 @@ function buildPdu(
|
||||
}
|
||||
|
||||
if (!Number.isInteger(seqNr) || seqNr < 0 || seqNr > maxWireSeqNr) {
|
||||
return { err: new Error(`Invalid seqNr: ${JSON.stringify(seqNr)}`) };
|
||||
return { err: new Error(`Invalid seqNr: ${valueText(seqNr)}`) };
|
||||
}
|
||||
|
||||
const built = buildBody(definition, cmdName, cmdStatus, params, tlvs);
|
||||
@@ -262,35 +262,6 @@ 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(
|
||||
cmdName: CommandName,
|
||||
pdu: Buffer,
|
||||
|
||||
+3
-48
@@ -1,10 +1,9 @@
|
||||
import type { Concat } from './concat.ts';
|
||||
import type { ParamValue } from './defs/types.ts';
|
||||
import type { PduObject } from './pdu.ts';
|
||||
import type { SmppLog } from './log.ts';
|
||||
import type { Tlv } from './defs/tlvs.ts';
|
||||
import { ExpiringGroups } from './expiring-groups.ts';
|
||||
import { decodeMessage } from './message.ts';
|
||||
import { detach, retainedOctets } from './retained-pdu.ts';
|
||||
import { messageOctets } from './message-body.ts';
|
||||
import { paramNumber, paramText } from './defs/types.ts';
|
||||
import { uuidv7 } from './uuid.ts';
|
||||
@@ -43,7 +42,7 @@ export type Collected =
|
||||
whole?: PduObject[] | undefined;
|
||||
};
|
||||
|
||||
const defaultMaxOctets = 64 * 1024 * 1024;
|
||||
export const defaultMaxOctets = 64 * 1024 * 1024;
|
||||
|
||||
type Group = {
|
||||
octets: number;
|
||||
@@ -52,50 +51,6 @@ type Group = {
|
||||
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.
|
||||
function groupKey(pduObj: PduObject, concat: Concat): string {
|
||||
return [
|
||||
@@ -165,7 +120,7 @@ export class Reassembler {
|
||||
const group = existing ?? this.open(key, concat.total);
|
||||
const replaced = group.parts.get(concat.part);
|
||||
const segment = detach(pduObj);
|
||||
const delta = octetsOf(segment) - (replaced === undefined ? 0 : octetsOf(replaced));
|
||||
const delta = retainedOctets(segment) - (replaced === undefined ? 0 : retainedOctets(replaced));
|
||||
|
||||
group.parts.set(concat.part, segment);
|
||||
group.octets += delta;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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;
|
||||
}
|
||||
+17
-3
@@ -7,10 +7,10 @@ import type { SmppLog } from './log.ts';
|
||||
import type { SmsIdNotation } from './sms-id.ts';
|
||||
import { UnansweredError } from './unanswered-error.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 { namedValue } from './error-from.ts';
|
||||
import { normaliseSmsId } from './sms-id.ts';
|
||||
import { paramText } from './defs/types.ts';
|
||||
import { maxSegments, smppTime, splitMessage } from './message.ts';
|
||||
|
||||
export type SendSmsOptions = {
|
||||
@@ -211,8 +211,23 @@ 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');
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
function checkOptions(sms: SendSmsInput): Result<CheckedOptions> {
|
||||
const unwritable = checkAddresses(sms);
|
||||
|
||||
if (unwritable) return { err: unwritable };
|
||||
|
||||
const mode = checkMessagingMode(sms.messagingMode, sms.dlr === true);
|
||||
|
||||
if (mode.err) return { err: mode.err };
|
||||
@@ -299,8 +314,7 @@ export async function submitSms(deps: SendSmsDeps, sms: SendSmsInput): Promise<S
|
||||
|
||||
deps.log.debug('sendSms() - sending', { encoding, segments: segments.length, to: sms.to });
|
||||
|
||||
// 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.
|
||||
// Segments go out together: a receiver that waits for every segment before answering would otherwise deadlock.
|
||||
const sent = await Promise.all(segments.map(segment => deps.send({
|
||||
cmdName: 'submit_sm',
|
||||
params: submitSmParams(sms, segment, {
|
||||
|
||||
+2
-1
@@ -13,6 +13,7 @@ import { defaultInterfaceVersion } from './defs/constants.ts';
|
||||
import { errorFrom } from './error-from.ts';
|
||||
import { paramText } from './defs/types.ts';
|
||||
import { guardedLog } from './log.ts';
|
||||
import { respNameFor } from './defs/commands.ts';
|
||||
|
||||
export type AuthenticateResult = { userData?: unknown } | boolean;
|
||||
|
||||
@@ -224,7 +225,7 @@ async function handleRequest(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pduObj.cmdName === 'unbind') return false;
|
||||
if (pduObj.cmdName === 'unbind' || !respNameFor(pduObj.cmdName)) return false;
|
||||
|
||||
session.log.debug('server - command before bind', { cmdName: pduObj.cmdName });
|
||||
await session.sendReturn(pduObj, 'ESME_RINVBNDSTS');
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { SmsIdFormat } from './sms-id.ts';
|
||||
import type { Sms } from './sms.ts';
|
||||
import type { Socket } from 'node:net';
|
||||
import { backoffDefaults } from './reconnect-loop.ts';
|
||||
import { defaultMaxOctets } from './reassembly.ts';
|
||||
import { isSmsIdNotation, smsIdNotations, smsIdPlaces } from './sms-id.ts';
|
||||
import { namedValue } from './error-from.ts';
|
||||
|
||||
@@ -127,6 +128,7 @@ export const defaults = {
|
||||
heldMessageTimeout: 300_000,
|
||||
maxDlrMerges: 1000,
|
||||
maxHeldMessages: 1000,
|
||||
maxHeldOctets: 64 * 1024 * 1024,
|
||||
maxOutstanding: 10,
|
||||
maxReassembly: 1000,
|
||||
reassemblyTimeout: 300_000,
|
||||
@@ -160,6 +162,7 @@ export function checkSessionOptions(options: CheckableOptions): VoidResult {
|
||||
function limitsOf(options: CheckableOptions): [string, number, number][] {
|
||||
return [
|
||||
['idleTimeout', options.idleTimeout ?? 0, 0],
|
||||
['maxOctets', options.maxOctets ?? defaultMaxOctets, 1],
|
||||
['maxOutstanding', options.maxOutstanding ?? defaults.maxOutstanding, 1],
|
||||
['maxReassembly', options.maxReassembly ?? defaults.maxReassembly, 1],
|
||||
['reassemblyTimeout', options.reassemblyTimeout ?? defaults.reassemblyTimeout, 0],
|
||||
@@ -268,6 +271,7 @@ export type CheckableOptions = {
|
||||
/** Not an option: the one spelling is inside reconnect, and this is where the other is refused. */
|
||||
fromStart?: unknown;
|
||||
idleTimeout?: number | undefined;
|
||||
maxOctets?: number | undefined;
|
||||
maxOutstanding?: number | undefined;
|
||||
maxReassembly?: number | undefined;
|
||||
reassemblyTimeout?: number | undefined;
|
||||
|
||||
@@ -51,6 +51,11 @@ describe('header', () => {
|
||||
assert.equal(decode(encode({ cmdName: 'enquire_link', seqNr: 0x80000001 })).seqNr, 0x80000001);
|
||||
assert.equal(decode(encode({ cmdName: 'enquire_link', seqNr: 0xFFFFFFFF })).seqNr, 0xFFFFFFFF);
|
||||
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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,6 +132,36 @@ describe('parsing real PDUs', () => {
|
||||
assert.equal(pduObj.params.short_message, 'hej 一');
|
||||
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', () => {
|
||||
@@ -428,6 +463,48 @@ describe('TLVs', () => {
|
||||
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', () => {
|
||||
const receipt = 'id:450 sub:001 dlvrd:1 submit date:1504031342 done date:1504031342 stat:DELIVRD err:0 text:xxx';
|
||||
const pduObj = decode(encode({
|
||||
|
||||
+168
-27
@@ -24,7 +24,7 @@ import { Session } from '../src/session.ts';
|
||||
import { DlrMerger } from '../src/dlr-merger.ts';
|
||||
import { PduRefusedError } from '../src/pdu-refusal.ts';
|
||||
import { objToPdu } from '../src/pdu.ts';
|
||||
import { checkSessionOptions, standsInFor } from '../src/session-options.ts';
|
||||
import { checkSessionOptions, defaults, standsInFor } from '../src/session-options.ts';
|
||||
import { client } from '../src/client.ts';
|
||||
import { closeAfter, closeListenerAfter } from './teardown.ts';
|
||||
import { concatOf } from '../src/concat.ts';
|
||||
@@ -1482,28 +1482,133 @@ describe('held message bounds', () => {
|
||||
return [submitPdu(seqNr)];
|
||||
}
|
||||
|
||||
test('drops the message held longest rather than holding every one', () => {
|
||||
const held = new HeldMessages({ log: silentLog, max: 2, timeout: 10_000 });
|
||||
const oldest = message(1);
|
||||
test('is full at its count, and a re-used sequence number replaces rather than adding', () => {
|
||||
const held = new HeldMessages({ log: silentLog, max: 2, maxOctets: 1_000_000, timeout: 10_000 });
|
||||
const first = message(1);
|
||||
|
||||
held.hold(oldest);
|
||||
held.hold(first);
|
||||
held.hold(message(2));
|
||||
held.hold(message(2));
|
||||
|
||||
assert.equal(held.size, 2, 'a re-used sequence number replaces rather than evicting');
|
||||
assert.equal(held.has(oldest), true);
|
||||
|
||||
held.hold(message(3));
|
||||
|
||||
assert.equal(held.size, 2);
|
||||
assert.equal(held.has(oldest), false);
|
||||
assert.equal(held.full(), 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));
|
||||
|
||||
now = 20_000;
|
||||
held.sweep();
|
||||
now = 0;
|
||||
assert.equal(held.full(), false, 'after a sweep');
|
||||
held.hold(message(4));
|
||||
held.hold(message(5));
|
||||
|
||||
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', () => {
|
||||
let now = 0;
|
||||
const held = new HeldMessages({ log: silentLog, max: 10, now: () => now, timeout: 60 });
|
||||
const held = new HeldMessages({ log: silentLog, max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 });
|
||||
|
||||
held.hold(message(1));
|
||||
now = 61;
|
||||
@@ -1519,7 +1624,7 @@ describe('held message bounds', () => {
|
||||
// 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 () => {
|
||||
let now = 0;
|
||||
const held = new HeldMessages({ log: silentLog, max: 10, now: () => now, timeout: 60 });
|
||||
const held = new HeldMessages({ log: silentLog, max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 });
|
||||
|
||||
held.hold(message(1));
|
||||
|
||||
@@ -1805,7 +1910,7 @@ describe('reassembly bounds', () => {
|
||||
assert.deepEqual(lost, [], 'the peer holds the only segment there was, so nothing was lost');
|
||||
});
|
||||
|
||||
// The two addresses are 22 octets, so only the 14 the TLV carries can overrun a cap of 30.
|
||||
// The two addresses and the segment's and TLV's objects are 1322 octets, so only the 14 it carries can overrun 1330.
|
||||
test('counts a body carried in message_payload against the octet cap', () => {
|
||||
function collectPayload(maxOctets: number): Collected {
|
||||
const reassembler = new Reassembler({
|
||||
@@ -1820,8 +1925,45 @@ describe('reassembly bounds', () => {
|
||||
return collectPdu(reassembler, payloadSegment(9, 1, 2));
|
||||
}
|
||||
|
||||
assert.equal(collectPayload(30).kept, false, 'a TLV body the cap cannot hold is refused, not dropped later');
|
||||
assert.equal(collectPayload(40).kept, true);
|
||||
assert.equal(collectPayload(1330).kept, false, 'a TLV body the cap cannot hold is refused, not dropped later');
|
||||
assert.equal(collectPayload(1340).kept, true);
|
||||
});
|
||||
|
||||
test('counts the objects a segment and each of its TLVs hold against the octet cap, empty ones included', () => {
|
||||
function collectTlvs(maxOctets: number, tlvs: PduObject['tlvs']): Collected {
|
||||
const reassembler = new Reassembler({
|
||||
log: silentLog,
|
||||
max: 10,
|
||||
maxOctets,
|
||||
now: () => 0,
|
||||
onLost: () => undefined,
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
return collectPdu(reassembler, { ...segment(9, 1, 2), tlvs });
|
||||
}
|
||||
function collectCallbacks(maxOctets: number, tagValue: Buffer[]): Collected {
|
||||
return collectTlvs(maxOctets, { callback_num: { tagId: 0x0381, tagName: 'callback_num', tagValue } });
|
||||
}
|
||||
const 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.
|
||||
@@ -1830,8 +1972,8 @@ describe('reassembly bounds', () => {
|
||||
const reassembler = new Reassembler({
|
||||
log: silentLog,
|
||||
max: 10,
|
||||
// One segment is 36 octets, so the second overruns a group already holding the first.
|
||||
maxOctets: 50,
|
||||
// One segment is 1036 octets, so the second overruns a group already holding the first.
|
||||
maxOctets: 1050,
|
||||
now: () => 0,
|
||||
onLost: one => { lost.push(one); },
|
||||
timeout: 60_000,
|
||||
@@ -1895,9 +2037,9 @@ describe('reassembly bounds', () => {
|
||||
assert.equal(counted.size, 1, 'the second group evicted the first, as a UDH group would');
|
||||
counted.clear();
|
||||
|
||||
// A sar_* segment is the two 11-octet addresses plus an 8-octet body, with no UDH to carry.
|
||||
assert.equal(collectSar(capped(20), 3, 1, 2).kept, false);
|
||||
assert.equal(collectSar(capped(30), 3, 1, 2).kept, true);
|
||||
// A sar_* segment is the two 11-octet addresses, an 8-octet body, its own object and three TLVs'.
|
||||
assert.equal(collectSar(capped(1920), 3, 1, 2).kept, false);
|
||||
assert.equal(collectSar(capped(1930), 3, 1, 2).kept, true);
|
||||
});
|
||||
|
||||
// Nothing else says a message the peer has already been answered for was thrown away.
|
||||
@@ -2013,8 +2155,8 @@ describe('reassembly bounds', () => {
|
||||
const reassembler = new Reassembler({
|
||||
log: silentLog,
|
||||
max: 10,
|
||||
// One segment is 36 octets: 14 of short_message plus the two 11-octet addresses.
|
||||
maxOctets: 80,
|
||||
// One segment is 1036 octets: 14 of short_message, the two 11-octet addresses and its object.
|
||||
maxOctets: 2100,
|
||||
now: () => 0,
|
||||
onLost: () => undefined,
|
||||
timeout: 60_000,
|
||||
@@ -2097,16 +2239,15 @@ describe('reassembly bounds', () => {
|
||||
// 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).
|
||||
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', () => {
|
||||
assert.equal(refusedSegmentStatus('submit_sm', 'full', 'udh'), 'ESME_RMSGQFUL');
|
||||
assert.equal(refusedSegmentStatus('submit_sm', 'full', 'udh'), 'ESME_RTHROTTLED');
|
||||
assert.equal(refusedSegmentStatus('deliver_sm', 'full', 'udh'), 'ESME_RX_T_APPN');
|
||||
assert.equal(refusedSegmentStatus('submit_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.
|
||||
assert.equal(refusedSegmentStatus('submit_sm', 'unplaceable', 'sar'), 'ESME_RINVTLVVAL');
|
||||
assert.equal(refusedSegmentStatus('deliver_sm', 'unplaceable', 'sar'), 'ESME_RINVTLVVAL');
|
||||
assert.equal(refusedSegmentStatus('submit_sm', 'full', 'sar'), 'ESME_RMSGQFUL');
|
||||
assert.equal(refusedSegmentStatus('submit_sm', 'full', 'sar'), 'ESME_RTHROTTLED');
|
||||
});
|
||||
|
||||
// Which command that is, for the one that travels both ways, is what the end it arrived at says.
|
||||
@@ -2116,7 +2257,7 @@ describe('the status a refused segment is answered with', () => {
|
||||
assert.equal(standsInFor('deliver_sm', 'esme'), 'deliver_sm');
|
||||
assert.equal(standsInFor('submit_sm', 'smsc'), 'submit_sm');
|
||||
assert.equal(standsInFor('enquire_link', 'smsc'), 'enquire_link');
|
||||
assert.equal(refusedSegmentStatus(standsInFor('data_sm', 'smsc'), 'full', 'udh'), 'ESME_RMSGQFUL');
|
||||
assert.equal(refusedSegmentStatus(standsInFor('data_sm', 'smsc'), 'full', 'udh'), 'ESME_RTHROTTLED');
|
||||
assert.equal(refusedSegmentStatus(standsInFor('data_sm', 'esme'), 'full', 'udh'), 'ESME_RX_T_APPN');
|
||||
});
|
||||
});
|
||||
|
||||
+56
-3
@@ -12,6 +12,7 @@ import { DlrMerger } from '../src/dlr-merger.ts';
|
||||
import { PduFramer } from '../src/pdu-framer.ts';
|
||||
import { ReconnectLoop } from '../src/reconnect-loop.ts';
|
||||
import { Session, bindCommands } from '../src/session.ts';
|
||||
import { checkSessionOptions } from '../src/session-options.ts';
|
||||
import { client } from '../src/client.ts';
|
||||
import { closeAfter, closeListenerAfter } from './teardown.ts';
|
||||
import { consts } from '../src/defs/constants.ts';
|
||||
@@ -316,6 +317,25 @@ describe('bind', () => {
|
||||
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 => {
|
||||
const smpp = await startServer(t);
|
||||
const peer = rawPeer(t, smpp.port);
|
||||
@@ -890,8 +910,8 @@ describe('receiving', () => {
|
||||
|
||||
// The refusal a submission gets is the one submit_sm_resp defines, whichever command carried it.
|
||||
test('refuses a data_sm segment a server has no room for with the submit code', async t => {
|
||||
// The two addresses are 22 octets, so the 6-octet UDH and its text are what overrun 30.
|
||||
const smpp = await startServer(t, { maxOctets: 30 });
|
||||
// The two addresses and the objects are 1322 octets, so the 6-octet UDH and its text are what overrun 1330.
|
||||
const smpp = await startServer(t, { maxOctets: 1330 });
|
||||
const { session } = await connect(t, smpp, { bindType: 'transmitter' });
|
||||
|
||||
assert.ok(session);
|
||||
@@ -912,7 +932,7 @@ describe('receiving', () => {
|
||||
|
||||
assert.ok(refused.pduObj);
|
||||
assert.equal(refused.pduObj.cmdName, 'data_sm_resp');
|
||||
assert.equal(refused.pduObj.cmdStatus, 'ESME_RMSGQFUL');
|
||||
assert.equal(refused.pduObj.cmdStatus, 'ESME_RTHROTTLED');
|
||||
});
|
||||
|
||||
test('reassembles a concatenated message whose segments arrived in message_payload', async t => {
|
||||
@@ -1507,6 +1527,30 @@ describe('robustness', () => {
|
||||
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 () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
@@ -2645,6 +2689,15 @@ describe('option validation', () => {
|
||||
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 () => {
|
||||
const listening = await server({ port: 70_000 });
|
||||
|
||||
|
||||
+106
-2
@@ -1,8 +1,8 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { describe } from 'node:test';
|
||||
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 { types } from '../src/defs/types.ts';
|
||||
|
||||
describe('integers', () => {
|
||||
test('int8 reads, sizes and writes one octet', () => {
|
||||
@@ -39,6 +39,11 @@ describe('integers', () => {
|
||||
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.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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,6 +74,27 @@ describe('string (Octet String)', () => {
|
||||
|
||||
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)', () => {
|
||||
@@ -91,13 +117,39 @@ describe('cstring (C-Octet String)', () => {
|
||||
assert.deepEqual(target, encoded);
|
||||
});
|
||||
|
||||
test('coerces a numeric value to its decimal string', () => {
|
||||
test('coerces a numeric value to its decimal string, and refuses a non-finite one', () => {
|
||||
const target = Buffer.alloc(4);
|
||||
|
||||
types.cstring.write(123, target, 0);
|
||||
|
||||
assert.deepEqual(target, Buffer.from([0x31, 0x32, 0x33, 0x00]));
|
||||
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', () => {
|
||||
@@ -142,6 +194,33 @@ 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', () => {
|
||||
const expected = Buffer.from('abcd1234');
|
||||
|
||||
@@ -170,6 +249,20 @@ describe('buffer', () => {
|
||||
|
||||
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', () => {
|
||||
@@ -206,6 +299,11 @@ describe('dest_address_array', () => {
|
||||
types.dest_address_array.write(expected, target, 0);
|
||||
|
||||
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', () => {
|
||||
@@ -245,6 +343,12 @@ describe('unsuccess_sme_array', () => {
|
||||
types.unsuccess_sme_array.write(expected, target, 0);
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -345,6 +345,31 @@ 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', () => {
|
||||
const invalid = new Date('nope');
|
||||
|
||||
|
||||
@@ -9,8 +9,9 @@ govern it, and nothing here is a source anything else may cite.
|
||||
## Status
|
||||
|
||||
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. What is left is housekeeping around the release, a few things worth
|
||||
adding, and the gaps a comparison with other SMPP libraries found.
|
||||
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
|
||||
comprehension gate and the defects under [0.6.0](#060) come first, then the gaps a comparison with
|
||||
other SMPP libraries found.
|
||||
|
||||
## The agreed API
|
||||
|
||||
@@ -177,8 +178,243 @@ 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
|
||||
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
|
||||
|
||||
- [ ] **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
|
||||
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,
|
||||
@@ -186,6 +422,18 @@ the rewrite, for a dependency added later. Maintainer's call, 2026-09-14.
|
||||
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.
|
||||
|
||||
- [ ] **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`,
|
||||
`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
|
||||
@@ -194,7 +442,8 @@ the rewrite, for a dependency added later. Maintainer's call, 2026-09-14.
|
||||
`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
|
||||
options and `smsIdFormat` — are the same defect once more: there `true` and `'true'` both print
|
||||
as `true`. One fix closes all three. Raised by review, 2026-09-20.
|
||||
as `true`. One fix closes all three, and `valueText()` in `defs/types.ts` is the quoted
|
||||
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
|
||||
`outgoing-requests.ts` runs `misuse()` and the abort check before the wait, precisely so a call
|
||||
@@ -228,12 +477,6 @@ the rewrite, for a dependency added later. Maintainer's call, 2026-09-14.
|
||||
each Gitea pull request to GitHub for it to review there. Maintainer's ask, 2026-09-14; not
|
||||
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.**
|
||||
`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.
|
||||
@@ -279,7 +522,7 @@ the rewrite, for a dependency added later. Maintainer's call, 2026-09-14.
|
||||
|
||||
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.
|
||||
Each lands under goal 6: an option or a hook, with the call that passes none unchanged.
|
||||
Each lands under goal 7: an option or a hook, with the call that passes none unchanged.
|
||||
|
||||
### Sending
|
||||
|
||||
@@ -441,7 +684,7 @@ Each lands under goal 6: 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,
|
||||
2026-09-14. It replaces two declines — merge state surviving a restart, and a pool of sessions —
|
||||
and goal 8 was rewritten for it. Big: design before code.
|
||||
and goal 9 was rewritten for it. Big: design before code.
|
||||
- **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
|
||||
concatenation reference, so a restart does not reuse one. For a pool, the ids every session
|
||||
@@ -454,10 +697,10 @@ Each lands under goal 6: an option or a hook, with the call that passes none unc
|
||||
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
|
||||
expiry: a record from an older version is read or refused, never misread, and `DlrMerger`'s
|
||||
group shape is never published (goal 7). What processes share needs an atomic operation —
|
||||
group shape is never published (goal 8). What processes share needs an atomic operation —
|
||||
compare-and-set or increment — since get-then-set races.
|
||||
- **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 9).
|
||||
one ships the interface and the in-memory store, and no runtime dependency (goal 10).
|
||||
- **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
|
||||
memory. Either way, an application that supplied no store never waits on one.
|
||||
|
||||
Reference in New Issue
Block a user