Answer every segment of an inbound concatenated message as it arrives (#83)
* Regression tests for answering every inbound segment as it arrives * Answer every segment of an inbound concatenated message as it arrives * Record the segment-by-segment answer in AGENTS.md and README * Regression tests for an empty message_id on deliver_sm_resp * Answer a deliver_sm with the empty message_id SMPP 3.4 makes it * Record Jasmin's refusal of a deliver_sm_resp message_id * Mark the Jasmin multipart deadlock fixed * Regression tests for the architecture review's findings * Give the segment id notation an owner, and every segment a status * Correct what the ids reach and what a lost group tells the application * Regression tests for a group lost to its own octet overrun * Report a group lost to its own overrun, and refuse by the command it arrived on * Keep the docs true about what a segment is answered with * Count only the answered segments of a group lost to an overrun * Report only what a lost group cost, and say which cap bit
This commit is contained in:
@@ -96,7 +96,7 @@ src/
|
||||
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
|
||||
sms-id.ts The notation a peer writes message ids in, normalised for comparison
|
||||
sms-id.ts Message ids: the peer's notation, the <base>-<n> a segment gets, which response carries one
|
||||
udh.ts User data header: its length, the concatenation fields of a long SMS and their reference
|
||||
unanswered-error.ts UnansweredError: it went out and no answer came back
|
||||
uuid.ts uuidv7() — the ids the library generates for messages
|
||||
@@ -166,15 +166,15 @@ naming the behaviour.
|
||||
| Binary payloads decoded as text | `data_coding` 0x02, 0x04, 0x14 and 0xF4-0xF7 are 8-bit binary and land on the GSM 03.38 table, which rewrites every octet outside it |
|
||||
| Binary TLVs round-trip corrupt | `pduToObj` turns a `Buffer` TLV value into a hex string (`utils.js:307`), and `objToPdu` writes that string back as its own ASCII, so `message_payload`, `network_error_code`, `callback_num` and the rest are destroyed by any round trip |
|
||||
| `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 and the send window
|
||||
|
||||
`sendSms` puts every segment of a message on the wire together instead of waiting for each response
|
||||
in turn. This is not an optimisation: this library's own server holds segments until the whole
|
||||
message is reassembled before it answers any of them, so sending them one-after-a-response
|
||||
deadlocks. It follows that a message with more segments than `maxOutstanding` cannot be delivered to
|
||||
a server that defers responses that way — real SMSCs answer each `submit_sm` immediately, so this
|
||||
only bites when both ends are this library.
|
||||
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, and a message with more
|
||||
segments than `maxOutstanding` goes out a slot at a time and still completes.
|
||||
|
||||
## GSM 7-bit is sent unpacked
|
||||
|
||||
@@ -448,19 +448,54 @@ Grouped by what each one constrains.
|
||||
reports each session's unfinished drain through `serverError`, because its own result says nothing
|
||||
but that the listener stopped.
|
||||
|
||||
- **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
|
||||
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`
|
||||
or a refusing `status` passed to `sendResp()` on such a message is an error rather than a silent
|
||||
no-op. `answeredOnArrival` is on `Sms` because nothing the application can compute says it, and the
|
||||
discriminant a reader would reach for instead is wrong. A message `sendResp()` still answers itself is
|
||||
untouched, and is where a caller-chosen id and a refusal live; `onRequest` is the escape hatch for
|
||||
an application that must refuse a PDU the `sms` event could not have shown it yet. `collect()`
|
||||
answers every segment it will not carry rather than leaving it unanswered, which is the same stall
|
||||
in miniature: `ESME_RINVESMCLASS` where the UDH belongs to no group, `ESME_RMSGQFUL` where the
|
||||
segment's own arrival overran the octet cap, since a peer told that still holds it. Rejected:
|
||||
answering every segment but the one that completes the group, which leaves the peer holding some
|
||||
segments accepted and one refused with nothing in SMPP to retract the rest, and still cannot honour
|
||||
a caller's `smsId` on the segments already gone. Rejected: a hook that mints the id per segment,
|
||||
which asks the application to name a message it cannot read yet — what it wants is `sms.smsId`
|
||||
afterwards. Rejected: an option to keep the old behaviour, a second spelling whose only
|
||||
distinguishing feature is that it deadlocks. Accepted: a group given up on — expired, evicted, or
|
||||
dropped with the link — is traffic the peer will not send again, so each one reaches `sessionError`
|
||||
as well as the log. Rejected there: an exported `MessageLostError` carrying the group, on the
|
||||
`PduRefusedError` pattern — no `sms` ever fired for that group, so there is nothing in it the
|
||||
application could act on, and goal 6 does not buy a second exported class to make a count
|
||||
distinguishable. Accepted: a completing segment whose own answer the socket would not carry still
|
||||
reaches the application, because the message is whole and correct and the failed answer is on
|
||||
`sessionError` — a peer that re-sends after the drop is the smaller risk than dropping a message
|
||||
in hand. The answer goes out before the `sms` event either way, so a listener's own receipt can
|
||||
never precede the acceptance of the message it reports on.
|
||||
|
||||
- **The drain waits on the messages the application holds, and `sendResp()` is what says it is done
|
||||
with one.** Maintainer's call, 2026-09-01: waiting on the send window alone tore a server session
|
||||
down while the application was still answering a `submit_sm`, so the peer timed out and re-sent —
|
||||
the duplicate goal 2 forbids, in the direction the window already covers. No completion signal was
|
||||
added to the `sms` event: `sendResp()` is the answer the peer is waiting for, so it is the one the
|
||||
drain waits for. Counting every inbound request until `sendReturn()` answered it was rejected —
|
||||
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. `teardown()`
|
||||
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
|
||||
@@ -482,9 +517,11 @@ Grouped by what each one constrains.
|
||||
`onDeliverSm()` answers each receipt before the group it belongs to is complete, and `teardown()`
|
||||
runs on every path — an idle timeout and a failed rebind, not only `close()` — so clearing the
|
||||
merges there loses receipts no peer has a reason to send again. They are cleared where the session
|
||||
is over instead. Inbound segments stay in `teardown()`, because they go unanswered until the
|
||||
message is whole: the peer still holds them, and answering it on a later link with the old
|
||||
segments' sequence numbers would correlate with nothing.
|
||||
is over instead. Inbound segments stay in `teardown()`: an 8-bit concatenation reference is the
|
||||
peer's own counter, so a half-arrived group kept across a drop would take a later message's
|
||||
segments as readily as the rest of its own, and goal 2 will not hand the application a message
|
||||
assembled that way. What goes there is traffic already answered, which is why each group reaches
|
||||
`sessionError` like every other one given up on.
|
||||
|
||||
- **A message id base is merged at most once.** A receipt carries nothing but `<base>-<n>`, so a
|
||||
straggler for a message whose group is gone cannot be told from a receipt for a later message the
|
||||
|
||||
Reference in New Issue
Block a user