87 Commits

Author SHA1 Message Date
lilleman dd09a27dc6 Export isTlvName, so a string can be narrowed into tlvs
Test / lint (pull_request) Successful in 22s
Test / test (20) (pull_request) Successful in 30s
Test / test (22) (pull_request) Successful in 30s
Test / test (24) (pull_request) Successful in 30s
Mirror / push (push) Successful in 5s
Test / test (18) (pull_request) Successful in 34s
Test / test (26) (pull_request) Successful in 35s
2026-09-27 15:39:43 +02:00
lilleman 4094949917 Say tlvs is typed by TlvName
Mirror / push (push) Has been cancelled
Test / lint (pull_request) Successful in 22s
Test / test (18) (pull_request) Successful in 31s
Test / test (20) (pull_request) Successful in 30s
Test / test (26) (pull_request) Successful in 30s
Test / test (22) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 31s
2026-09-27 15:38:17 +02:00
lilleman ac6206dfec Type tlvs by its own names, so a dropped alternate fails to compile
Mirror / push (push) Has been cancelled
Test / lint (pull_request) Successful in 22s
Test / test (18) (pull_request) Successful in 30s
Test / test (20) (pull_request) Successful in 29s
Test / test (22) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 34s
Test / test (26) (pull_request) Successful in 30s
2026-09-27 15:38:09 +02:00
lilleman 9fb8f348c8 Drop the alternate TLV names from tlvs, correct the false doc claims the prose pass found, and file the moves
Mirror / push (push) Has been cancelled
Test / lint (pull_request) Successful in 22s
Test / test (20) (pull_request) Successful in 30s
Test / test (22) (pull_request) Successful in 36s
Test / test (24) (pull_request) Successful in 31s
Test / test (26) (pull_request) Successful in 30s
Test / test (18) (pull_request) Successful in 30s
2026-09-27 15:36:56 +02:00
lilleman 01891bc764 Word a disagreeing tagId as a mismatch, and record how a TLV input is keyed
Mirror / push (push) Has been cancelled
Test / test (18) (pull_request) Successful in 31s
Test / lint (pull_request) Successful in 22s
Test / test (20) (pull_request) Successful in 30s
Test / test (24) (pull_request) Successful in 30s
Test / test (26) (pull_request) Successful in 30s
Test / test (22) (pull_request) Successful in 37s
2026-09-27 15:33:35 +02:00
lilleman 9379212c68 Read an undefined tagId as absent
Mirror / push (push) Has been cancelled
Test / lint (pull_request) Successful in 31s
Test / test (18) (pull_request) Successful in 30s
Test / test (20) (pull_request) Successful in 30s
Test / test (22) (pull_request) Successful in 30s
Test / test (26) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 36s
2026-09-27 15:31:21 +02:00
lilleman cda8e9b112 Relay a parsed PDU's TLVs as they are, refuse only a tagId that disagrees with its key, and refuse a number for an octet TLV
Mirror / push (push) Has been cancelled
Test / lint (pull_request) Successful in 22s
Test / test (18) (pull_request) Successful in 31s
Test / test (22) (pull_request) Successful in 39s
Test / test (24) (pull_request) Successful in 31s
Test / test (26) (pull_request) Successful in 31s
Test / test (20) (pull_request) Successful in 36s
2026-09-27 15:29:31 +02:00
lilleman 58e622143b Drop an assertion that always passes, and reflow a decision
Mirror / push (push) Has been cancelled
Test / lint (pull_request) Successful in 27s
Test / test (18) (pull_request) Successful in 31s
Test / test (20) (pull_request) Successful in 39s
Test / test (22) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 38s
Test / test (26) (pull_request) Successful in 31s
2026-09-27 15:27:25 +02:00
lilleman 138464ad0f Refuse a TLV input that is no { tagValue }, and the alternate tag names, keep numbers out of octet TLVs, and pin that a TLV is copied off its chunk
Mirror / push (push) Has been cancelled
Test / test (18) (pull_request) Successful in 31s
Test / test (20) (pull_request) Successful in 30s
Test / lint (pull_request) Successful in 22s
Test / test (22) (pull_request) Successful in 39s
Test / test (24) (pull_request) Successful in 31s
Test / test (26) (pull_request) Successful in 32s
2026-09-27 15:25:49 +02:00
lilleman c4b0437323 Read a named tag's definition once in tagIdOf()
Mirror / push (push) Successful in 4s
Test / lint (pull_request) Successful in 23s
Test / test (18) (pull_request) Successful in 30s
Test / test (20) (pull_request) Successful in 30s
Test / test (22) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 31s
Test / test (26) (pull_request) Successful in 31s
2026-09-27 15:18:49 +02:00
lilleman 939eda7269 Key a TLV input like the read side, drop tagId, copy TLV octets at parse, and export only Tlvs, TlvInputs and Tlv
Mirror / push (push) Has been cancelled
Test / lint (pull_request) Successful in 29s
Test / test (18) (pull_request) Successful in 31s
Test / test (20) (pull_request) Successful in 31s
Test / test (22) (pull_request) Successful in 37s
Test / test (24) (pull_request) Successful in 31s
Test / test (26) (pull_request) Successful in 38s
2026-09-27 15:18:08 +02:00
lilleman 75d4794522 Type each known TLV's value by its tag, on read and on write
Test / lint (pull_request) Successful in 32s
Test / test (18) (pull_request) Successful in 31s
Test / test (20) (pull_request) Successful in 30s
Test / test (22) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 38s
Test / test (26) (pull_request) Successful in 30s
Mirror / push (push) Successful in 4s
2026-09-27 15:12:09 +02:00
lilleman 42b1239f3d Name the retry status by direction, and correct what the held-message decision claims
Test / lint (pull_request) Successful in 28s
Test / test (18) (pull_request) Successful in 31s
Test / test (20) (pull_request) Successful in 30s
Test / test (22) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 30s
Test / test (26) (pull_request) Successful in 37s
Mirror / push (push) Successful in 4s
2026-09-26 14:53:09 +02:00
lilleman f2977638f2 Stop refusing only once the held messages are down to half, so a full window warns once, and name the octets in the warning
Mirror / push (push) Successful in 5s
Test / test (22) (pull_request) Successful in 31s
Test / lint (pull_request) Successful in 22s
Test / test (18) (pull_request) Successful in 30s
Test / test (20) (pull_request) Successful in 30s
Test / test (24) (pull_request) Successful in 31s
Test / test (26) (pull_request) Successful in 31s
2026-09-26 14:44:00 +02:00
lilleman b9167073d4 Warn once at the held-message bound, tell receivers sendResp() is what releases a message, and pin that a refused segment joins no group
Mirror / push (push) Has been cancelled
Test / test (18) (pull_request) Successful in 31s
Test / test (22) (pull_request) Successful in 31s
Test / test (26) (pull_request) Successful in 32s
Test / lint (pull_request) Successful in 23s
Test / test (20) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 31s
2026-09-26 14:41:10 +02:00
lilleman fb6b125aab Give the dumbclient peers a namespace owner that outlives them, count window 2000's throttled messages, and correct what the findings blamed on the peer
Mirror / push (push) Successful in 4s
Test / lint (pull_request) Successful in 23s
Test / test (18) (pull_request) Successful in 31s
Test / test (20) (pull_request) Successful in 31s
Test / test (22) (pull_request) Successful in 32s
Test / test (24) (pull_request) Successful in 30s
Test / test (26) (pull_request) Successful in 31s
2026-09-26 14:37:20 +02:00
lilleman 855ccbe75f Refuse a message past the held-message bound with ESME_RTHROTTLED, and answer a full reassembly buffer the same way
Test / lint (pull_request) Successful in 23s
Test / test (18) (pull_request) Successful in 31s
Test / test (20) (pull_request) Successful in 31s
Test / test (22) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 31s
Test / test (26) (pull_request) Successful in 31s
Mirror / push (push) Successful in 4s
2026-09-26 13:18:56 +02:00
lilleman d8232bcd67 Split the held-message bounds and the shutdownTimeout fallback into decisions of their own, and drop what code and README say
Test / test (20) (pull_request) Successful in 32s
Test / test (24) (pull_request) Successful in 38s
Mirror / push (push) Successful in 7s
Test / lint (pull_request) Successful in 29s
Test / test (18) (pull_request) Successful in 33s
Test / test (22) (pull_request) Successful in 32s
Test / test (26) (pull_request) Successful in 32s
2026-09-25 19:32:59 +02:00
lilleman dba95d9524 Test the held octet accounting and the detach through a session, and file the application-held gap
Test / lint (pull_request) Successful in 25s
Mirror / push (push) Successful in 7s
Test / test (18) (pull_request) Successful in 35s
Test / test (20) (pull_request) Successful in 33s
Test / test (22) (pull_request) Successful in 33s
Test / test (24) (pull_request) Successful in 32s
Test / test (26) (pull_request) Successful in 39s
2026-09-25 19:28:08 +02:00
lilleman c3ebd6d6bc Cap the heap unanswered messages hold, and detach them from the chunk they arrived in
Test / lint (pull_request) Successful in 25s
Test / test (22) (pull_request) Successful in 32s
Test / test (18) (pull_request) Successful in 33s
Test / test (20) (pull_request) Successful in 32s
Test / test (24) (pull_request) Successful in 31s
Test / test (26) (pull_request) Successful in 31s
Mirror / push (push) Successful in 6s
2026-09-25 19:22:31 +02:00
lilleman d8d0947e1f Drop an edge-false fraction from the changelog
Test / lint (pull_request) Successful in 28s
Test / test (18) (pull_request) Successful in 39s
Test / test (20) (pull_request) Successful in 33s
Test / test (22) (pull_request) Successful in 33s
Test / test (24) (pull_request) Successful in 32s
Test / test (26) (pull_request) Successful in 36s
Mirror / push (push) Successful in 6s
2026-09-25 19:07:11 +02:00
lilleman 72b400e661 Name the per-occurrence charge exactly
Mirror / push (push) Has been cancelled
Test / lint (pull_request) Successful in 30s
Test / test (18) (pull_request) Successful in 33s
Test / test (20) (pull_request) Successful in 32s
Test / test (22) (pull_request) Successful in 36s
Test / test (24) (pull_request) Successful in 32s
Test / test (26) (pull_request) Successful in 32s
2026-09-25 19:06:20 +02:00
lilleman f1ef6b6643 State the octet cap's charge as a rule a consumer can size with
Mirror / push (push) Has been cancelled
Test / lint (pull_request) Successful in 29s
Test / test (18) (pull_request) Successful in 33s
Test / test (20) (pull_request) Successful in 32s
Test / test (22) (pull_request) Successful in 35s
Test / test (24) (pull_request) Successful in 39s
Test / test (26) (pull_request) Successful in 32s
2026-09-25 19:05:48 +02:00
lilleman fd6ce16325 Drop a false multiple from the changelog, and name the new per-segment charge
Mirror / push (push) Successful in 6s
Test / lint (pull_request) Successful in 25s
Test / test (18) (pull_request) Successful in 33s
Test / test (20) (pull_request) Successful in 37s
Test / test (22) (pull_request) Successful in 32s
Test / test (24) (pull_request) Successful in 32s
Test / test (26) (pull_request) Successful in 33s
2026-09-25 19:04:28 +02:00
lilleman f9c53aa576 Charge each held segment for its objects, at the measured cost, and file the held-message gap
Mirror / push (push) Successful in 6s
Test / lint (pull_request) Successful in 24s
Test / test (18) (pull_request) Successful in 38s
Test / test (20) (pull_request) Successful in 31s
Test / test (22) (pull_request) Successful in 32s
Test / test (24) (pull_request) Successful in 32s
Test / test (26) (pull_request) Successful in 33s
2026-09-25 19:02:07 +02:00
lilleman 92456b6f6e Charge every TLV a held segment carries against the reassembly cap
Test / lint (pull_request) Successful in 24s
Test / test (18) (pull_request) Successful in 38s
Test / test (20) (pull_request) Successful in 32s
Test / test (22) (pull_request) Successful in 32s
Test / test (24) (pull_request) Successful in 32s
Test / test (26) (pull_request) Successful in 32s
Mirror / push (push) Successful in 6s
2026-09-25 18:57:10 +02:00
lilleman 01210e734a Correct the doc claims the prose pass found, and move two decisions into the log
Test / lint (pull_request) Successful in 22s
Test / test (18) (pull_request) Successful in 28s
Test / test (20) (pull_request) Successful in 34s
Test / test (22) (pull_request) Successful in 34s
Test / test (24) (pull_request) Successful in 30s
Test / test (26) (pull_request) Successful in 28s
Mirror / push (push) Successful in 7s
2026-09-24 00:31:49 +02:00
lilleman e83ed1451c Have the typed-TLV item prove the changelog's advice compiles
Mirror / push (push) Successful in 6s
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Successful in 30s
Test / test (20) (pull_request) Successful in 31s
Test / test (22) (pull_request) Successful in 35s
Test / test (24) (pull_request) Successful in 34s
Test / test (26) (pull_request) Successful in 29s
2026-09-24 00:28:47 +02:00
lilleman 11ff349333 Lead the changelog with the read-side break, and show each repeatable TLV's shape
Mirror / push (push) Successful in 4s
Test / lint (pull_request) Successful in 29s
Test / test (20) (pull_request) Successful in 35s
Test / test (18) (pull_request) Successful in 37s
Test / test (24) (pull_request) Successful in 30s
Test / test (22) (pull_request) Successful in 31s
Test / test (26) (pull_request) Successful in 28s
2026-09-24 00:27:01 +02:00
lilleman f2ee8aa016 Drop a false claim about the server's answers, and file what the panel found
Mirror / push (push) Has been cancelled
Test / lint (pull_request) Successful in 20s
Test / test (18) (pull_request) Successful in 32s
Test / test (20) (pull_request) Successful in 34s
Test / test (22) (pull_request) Successful in 33s
Test / test (24) (pull_request) Successful in 28s
Test / test (26) (pull_request) Successful in 36s
2026-09-24 00:24:49 +02:00
lilleman 2476684f24 Charge each listed TLV occurrence against the reassembly cap, and file the unknown-tag gap
Mirror / push (push) Has been cancelled
Test / lint (pull_request) Successful in 28s
Test / test (18) (pull_request) Successful in 36s
Test / test (20) (pull_request) Successful in 30s
Test / test (22) (pull_request) Successful in 30s
Test / test (24) (pull_request) Successful in 34s
Test / test (26) (pull_request) Successful in 35s
2026-09-24 00:24:35 +02:00
lilleman a885913ecb Link AGENTS.md absolutely, as npm renders the README
Mirror / push (push) Successful in 6s
Test / lint (pull_request) Successful in 29s
Test / test (18) (pull_request) Successful in 35s
Test / test (20) (pull_request) Successful in 29s
Test / test (22) (pull_request) Successful in 30s
Test / test (24) (pull_request) Successful in 31s
Test / test (26) (pull_request) Successful in 29s
2026-09-24 00:19:28 +02:00
lilleman befa648991 Point goal 1 at the defect table where it lives
Mirror / push (push) Has been cancelled
Test / lint (pull_request) Successful in 20s
Test / test (18) (pull_request) Successful in 36s
Test / test (20) (pull_request) Successful in 34s
Test / test (24) (pull_request) Successful in 29s
Test / test (22) (pull_request) Successful in 30s
Test / test (26) (pull_request) Successful in 29s
2026-09-24 00:19:23 +02:00
lilleman cbfd6e0fbe Count ten goals, and point at where the hard rules live
Mirror / push (push) Has been cancelled
Test / lint (pull_request) Successful in 20s
Test / test (18) (pull_request) Successful in 34s
Test / test (20) (pull_request) Successful in 29s
Test / test (22) (pull_request) Successful in 30s
Test / test (24) (pull_request) Successful in 30s
Test / test (26) (pull_request) Successful in 33s
2026-09-24 00:19:19 +02:00
lilleman 5cdd3ccdae Say tlvs.ts reads a TLV stream too
Mirror / push (push) Successful in 6s
Test / lint (pull_request) Successful in 31s
Test / test (18) (pull_request) Successful in 37s
Test / test (20) (pull_request) Successful in 29s
Test / test (22) (pull_request) Successful in 29s
Test / test (24) (pull_request) Successful in 37s
Test / test (26) (pull_request) Successful in 29s
2026-09-24 00:18:13 +02:00
lilleman f6a66336cd Count and detach a repeatable TLV's occurrences in reassembly, and let only byte or integer tags repeat
Mirror / push (push) Successful in 4s
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Successful in 29s
Test / test (20) (pull_request) Successful in 30s
Test / test (22) (pull_request) Successful in 35s
Test / test (24) (pull_request) Successful in 30s
Test / test (26) (pull_request) Successful in 29s
2026-09-24 00:17:19 +02:00
lilleman 10877c9f5f Read every occurrence of a repeatable TLV, and drop the unread tlvMap
Test / lint (pull_request) Successful in 20s
Test / test (18) (pull_request) Successful in 30s
Test / test (20) (pull_request) Successful in 28s
Test / test (22) (pull_request) Successful in 30s
Test / test (24) (pull_request) Successful in 29s
Mirror / push (push) Successful in 4s
Test / test (26) (pull_request) Successful in 30s
2026-09-24 00:13:38 +02:00
lilleman c5ef4c4703 Pin that maxOctets refuses Infinity, and say refuse where the changelog said drop
Test / lint (pull_request) Successful in 20s
Test / test (18) (pull_request) Successful in 30s
Test / test (20) (pull_request) Successful in 29s
Test / test (22) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 28s
Test / test (26) (pull_request) Successful in 30s
Mirror / push (push) Successful in 4s
2026-09-24 00:06:22 +02:00
lilleman 80f77afa0f Refuse a maxOctets below 1 at startup, like the other limits
Test / lint (pull_request) Successful in 49s
Test / test (18) (pull_request) Successful in 29s
Test / test (20) (pull_request) Successful in 29s
Test / test (22) (pull_request) Successful in 29s
Test / test (24) (pull_request) Successful in 28s
Test / test (26) (pull_request) Successful in 29s
Mirror / push (push) Successful in 3s
2026-09-23 23:48:18 +02:00
lilleman 228b81aa5e Say where a failed send's taken segments report, and that an unanswered one fails it too
Test / lint (pull_request) Successful in 29s
Test / test (18) (pull_request) Successful in 31s
Test / test (20) (pull_request) Successful in 47s
Test / test (22) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 31s
Test / test (26) (pull_request) Successful in 34s
Mirror / push (push) Successful in 6s
2026-09-23 23:29:59 +02:00
lilleman e725f62b95 Make a failed send the whole message's answer, with no merged report after it
Mirror / push (push) Has been cancelled
Test / lint (pull_request) Successful in 23s
Test / test (18) (pull_request) Successful in 31s
Test / test (20) (pull_request) Successful in 29s
Test / test (22) (pull_request) Successful in 30s
Test / test (24) (pull_request) Successful in 35s
Test / test (26) (pull_request) Successful in 31s
2026-09-23 23:29:08 +02:00
lilleman 0ee7bbbaf0 Make goal 2 cover a value we could not send as the caller wrote it
Test / lint (pull_request) Successful in 28s
Test / test (18) (pull_request) Successful in 31s
Test / test (20) (pull_request) Successful in 30s
Test / test (22) (pull_request) Successful in 38s
Test / test (24) (pull_request) Successful in 31s
Test / test (26) (pull_request) Successful in 37s
Mirror / push (push) Successful in 6s
2026-09-23 23:06:35 +02:00
lilleman b105752d53 Log an ignored response-less command at verbose, since a peer drives it
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Successful in 30s
Test / test (20) (pull_request) Successful in 31s
Test / test (22) (pull_request) Successful in 35s
Test / test (24) (pull_request) Successful in 30s
Test / test (26) (pull_request) Successful in 34s
Mirror / push (push) Successful in 6s
2026-09-23 22:46:13 +02:00
lilleman 65ac3a3fd7 Register the raw peer's teardown, and keep the changelog line to what changed
Mirror / push (push) Successful in 6s
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Successful in 35s
Test / test (20) (pull_request) Successful in 30s
Test / test (22) (pull_request) Successful in 30s
Test / test (24) (pull_request) Successful in 31s
Test / test (26) (pull_request) Successful in 31s
2026-09-22 21:49:25 +02:00
lilleman b54f5b02f3 Leave alert_notification and outbind unanswered
Mirror / push (push) Has been cancelled
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Successful in 30s
Test / test (20) (pull_request) Successful in 29s
Test / test (22) (pull_request) Successful in 30s
Test / test (24) (pull_request) Successful in 30s
Test / test (26) (pull_request) Successful in 30s
2026-09-22 21:43:57 +02:00
lilleman bdebef849c Correct three claims a reader would have built on
Test / lint (pull_request) Successful in 29s
Test / test (18) (pull_request) Successful in 30s
Test / test (20) (pull_request) Successful in 29s
Test / test (22) (pull_request) Successful in 29s
Test / test (24) (pull_request) Successful in 29s
Test / test (26) (pull_request) Successful in 30s
Mirror / push (push) Successful in 5s
2026-09-21 10:39:54 +02:00
lilleman 0620d205d6 Take back the surface the compiler cannot guard
Mirror / push (push) Has been cancelled
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Successful in 29s
Test / test (20) (pull_request) Successful in 29s
Test / test (22) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 29s
Test / test (26) (pull_request) Successful in 31s
2026-09-21 10:38:40 +02:00
lilleman 3eb93303c3 Name the goal that outranks, on the number the guard still lets through
Mirror / push (push) Successful in 4s
Test / lint (pull_request) Successful in 22s
Test / test (18) (pull_request) Successful in 29s
Test / test (20) (pull_request) Successful in 29s
Test / test (22) (pull_request) Successful in 29s
Test / test (24) (pull_request) Successful in 35s
Test / test (26) (pull_request) Successful in 30s
2026-09-21 10:35:57 +02:00
lilleman 0c4e6000b0 Tell a consumer how the refusal reaches them, and what it does not cover
Mirror / push (push) Successful in 5s
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Successful in 31s
Test / test (20) (pull_request) Successful in 29s
Test / test (22) (pull_request) Successful in 30s
Test / test (24) (pull_request) Successful in 33s
Test / test (26) (pull_request) Successful in 30s
2026-09-21 10:33:59 +02:00
lilleman bbfd1083b8 File what tracing the numeric branch turned up
Mirror / push (push) Successful in 5s
Test / lint (pull_request) Successful in 28s
Test / test (18) (pull_request) Successful in 29s
Test / test (20) (pull_request) Successful in 29s
Test / test (22) (pull_request) Successful in 32s
Test / test (24) (pull_request) Successful in 30s
Test / test (26) (pull_request) Successful in 36s
2026-09-21 10:29:43 +02:00
lilleman faa5482c7a Spell a refused NaN as NaN, where JSON spells it null 2026-09-21 10:29:43 +02:00
lilleman 93d794e61b Say what is refused, and cover the two wire types the loop missed 2026-09-21 10:29:42 +02:00
lilleman 63e06856f2 Say what coercion would put on the wire, in the tense the test now holds
Mirror / push (push) Successful in 5s
Test / lint (pull_request) Successful in 23s
Test / test (18) (pull_request) Successful in 32s
Test / test (20) (pull_request) Successful in 29s
Test / test (22) (pull_request) Successful in 37s
Test / test (24) (pull_request) Successful in 37s
Test / test (26) (pull_request) Successful in 29s
2026-09-21 10:23:15 +02:00
lilleman e7e71ca9bd Refuse a non-finite number where a text field coerces one
Mirror / push (push) Successful in 4s
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Successful in 36s
Test / test (20) (pull_request) Successful in 30s
Test / test (22) (pull_request) Successful in 30s
Test / test (24) (pull_request) Successful in 29s
Test / test (26) (pull_request) Successful in 30s
2026-09-21 10:22:41 +02:00
lilleman e2edd7e31e Disclose the bind-credential collision the latin1 read closed
Test / lint (pull_request) Successful in 22s
Test / test (18) (pull_request) Successful in 29s
Test / test (20) (pull_request) Successful in 30s
Test / test (22) (pull_request) Successful in 30s
Test / test (24) (pull_request) Successful in 30s
Test / test (26) (pull_request) Successful in 29s
Mirror / push (push) Successful in 5s
2026-09-21 10:15:58 +02:00
lilleman 36a1c6ea7b Cut the prose the code, the error and the record already carry
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Successful in 29s
Test / test (20) (pull_request) Successful in 29s
Test / test (22) (pull_request) Successful in 30s
Test / test (24) (pull_request) Successful in 30s
Test / test (26) (pull_request) Successful in 29s
Mirror / push (push) Successful in 5s
2026-09-21 08:25:39 +02:00
lilleman a7eb4923f5 Say what is true for a TLV too, and cover the to half of the check
Mirror / push (push) Successful in 5s
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Successful in 30s
Test / test (20) (pull_request) Successful in 29s
Test / test (22) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 29s
Test / test (26) (pull_request) Successful in 30s
2026-09-21 08:20:59 +02:00
lilleman dc690b912f Name the option a refused address came from, and the data a 0.5.0 consumer stored
Mirror / push (push) Successful in 4s
Test / lint (pull_request) Successful in 20s
Test / test (18) (pull_request) Successful in 35s
Test / test (20) (pull_request) Successful in 30s
Test / test (22) (pull_request) Successful in 29s
Test / test (24) (pull_request) Successful in 29s
Test / test (26) (pull_request) Successful in 30s
2026-09-21 08:14:08 +02:00
lilleman f870b7530f Cover the guarded address writers, and cut what the code already says
Mirror / push (push) Successful in 5s
Test / lint (pull_request) Successful in 22s
Test / test (18) (pull_request) Successful in 29s
Test / test (20) (pull_request) Successful in 30s
Test / test (22) (pull_request) Successful in 37s
Test / test (24) (pull_request) Successful in 29s
Test / test (26) (pull_request) Successful in 30s
2026-09-21 08:08:42 +02:00
lilleman c1e0407942 Refuse a NULL inside a C-Octet String, which ends the field on the peer
Mirror / push (push) Successful in 4s
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Successful in 29s
Test / test (20) (pull_request) Successful in 29s
Test / test (22) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 35s
Test / test (26) (pull_request) Successful in 30s
2026-09-21 07:59:08 +02:00
lilleman 7db242e375 Read and write every text field on the wire as latin1
Mirror / push (push) Successful in 5s
Test / lint (pull_request) Successful in 22s
Test / test (18) (pull_request) Successful in 29s
Test / test (20) (pull_request) Successful in 30s
Test / test (22) (pull_request) Successful in 30s
Test / test (24) (pull_request) Successful in 29s
Test / test (26) (pull_request) Successful in 30s
2026-09-21 07:54:25 +02:00
lilleman cbd1190b96 Record what the smppload bind actually does, and what is still untested
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Successful in 29s
Test / test (20) (pull_request) Successful in 29s
Test / test (22) (pull_request) Successful in 29s
Test / test (24) (pull_request) Successful in 29s
Test / test (26) (pull_request) Successful in 29s
Mirror / push (push) Successful in 5s
2026-09-20 23:48:09 +02:00
lilleman b85d1f1dab Compare throughput against jsmpp and Cloudhopper on one sink
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Successful in 29s
Test / test (20) (pull_request) Successful in 29s
Test / test (22) (pull_request) Successful in 30s
Test / test (24) (pull_request) Successful in 29s
Test / test (26) (pull_request) Successful in 29s
Mirror / push (push) Has been cancelled
2026-09-20 23:45:09 +02:00
lilleman c5ed25f915 Make throughput goal 6, with floors a release has to clear
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Successful in 30s
Test / test (20) (pull_request) Successful in 29s
Test / test (22) (pull_request) Successful in 30s
Test / test (24) (pull_request) Successful in 30s
Test / test (26) (pull_request) Successful in 31s
Mirror / push (push) Successful in 5s
2026-09-20 23:23:08 +02:00
lilleman 4c43f6748d Measure what this library sustains, and against Jasmin and SMPPSim
Mirror / push (push) Has been cancelled
Test / lint (pull_request) Successful in 22s
Test / test (18) (pull_request) Successful in 29s
Test / test (20) (pull_request) Successful in 29s
Test / test (22) (pull_request) Successful in 30s
Test / test (24) (pull_request) Successful in 29s
Test / test (26) (pull_request) Successful in 29s
2026-09-20 23:11:48 +02:00
lilleman 36d32591c3 Plan 0.6.0 as the quality cut the comprehension panel and architecture review ask for
Test / lint (pull_request) Successful in 22s
Test / test (18) (pull_request) Successful in 30s
Test / test (20) (pull_request) Successful in 29s
Test / test (22) (pull_request) Successful in 30s
Test / test (24) (pull_request) Successful in 29s
Test / test (26) (pull_request) Successful in 30s
Mirror / push (push) Has been cancelled
2026-09-20 22:42:07 +02:00
lilleman 92fbd6a9f2 Wait the default out for real, since node:test mock timers postdate the Node 18 floor
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Successful in 30s
Test / test (20) (pull_request) Successful in 29s
Test / test (22) (pull_request) Successful in 30s
Test / test (24) (pull_request) Successful in 32s
Test / test (26) (pull_request) Successful in 30s
Mirror / push (push) Successful in 4s
2026-09-20 20:52:32 +02:00
lilleman 45171c2697 Bound the wait in the default test, and pin the boolean a refusal prints
Mirror / push (push) Successful in 4s
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Failing after 18s
Test / test (20) (pull_request) Successful in 22s
Test / test (22) (pull_request) Successful in 19s
Test / test (24) (pull_request) Successful in 19s
Test / test (26) (pull_request) Successful in 19s
2026-09-20 20:46:45 +02:00
lilleman a24f350b7a Prove the default bound is wired, and name the option on the failure a consumer reads
Mirror / push (push) Successful in 4s
Test / lint (pull_request) Successful in 22s
Test / test (18) (pull_request) Failing after 19s
Test / test (20) (pull_request) Successful in 19s
Test / test (22) (pull_request) Successful in 24s
Test / test (24) (pull_request) Successful in 20s
Test / test (26) (pull_request) Successful in 19s
2026-09-20 20:41:45 +02:00
lilleman 1416615ca3 Bound every connect attempt at 10 s by default, with false the way to opt out
Mirror / push (push) Successful in 5s
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Successful in 19s
Test / test (20) (pull_request) Successful in 19s
Test / test (22) (pull_request) Successful in 19s
Test / test (24) (pull_request) Successful in 20s
Test / test (26) (pull_request) Successful in 19s
2026-09-20 20:34:53 +02:00
lilleman 8f416c2bc4 Name the peer and the stalled phase on a connect timeout, and quote an untyped value
Mirror / push (push) Successful in 5s
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Successful in 19s
Test / test (20) (pull_request) Successful in 19s
Test / test (22) (pull_request) Successful in 24s
Test / test (24) (pull_request) Successful in 18s
Test / test (26) (pull_request) Successful in 19s
2026-09-20 18:17:39 +02:00
lilleman 84174b4a5b Refuse a connectTimeout Node's timers cannot hold, and pin the refusals the tests missed
Mirror / push (push) Successful in 5s
Test / lint (pull_request) Successful in 20s
Test / test (18) (pull_request) Successful in 26s
Test / test (20) (pull_request) Successful in 19s
Test / test (22) (pull_request) Successful in 20s
Test / test (24) (pull_request) Successful in 19s
Test / test (26) (pull_request) Successful in 20s
2026-09-20 18:12:39 +02:00
lilleman 17447bbe3a Give up a connect after connectTimeout instead of waiting the OS out
Mirror / push (push) Successful in 4s
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Successful in 22s
Test / test (20) (pull_request) Successful in 18s
Test / test (22) (pull_request) Successful in 18s
Test / test (24) (pull_request) Successful in 19s
Test / test (26) (pull_request) Successful in 27s
2026-09-20 00:42:09 +02:00
lilleman d874448273 Plan a connectTimeout option, found comparing 0.5.0 with the larvitsmpp forks
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Successful in 20s
Test / test (20) (pull_request) Successful in 18s
Test / test (22) (pull_request) Successful in 27s
Test / test (24) (pull_request) Successful in 19s
Test / test (26) (pull_request) Successful in 28s
Mirror / push (push) Successful in 4s
2026-09-20 00:23:06 +02:00
lilleman 656bbae500 Move the decisions to docs/decisions.md and index them in AGENTS.md
Test / lint (pull_request) Successful in 23s
Test / test (18) (pull_request) Successful in 20s
Test / test (20) (pull_request) Successful in 21s
Test / test (22) (pull_request) Successful in 20s
Test / test (24) (pull_request) Successful in 20s
Test / test (26) (pull_request) Successful in 21s
Mirror / push (push) Successful in 6s
2026-09-19 01:04:08 +02:00
lilleman c90b855958 Drop the anti-mention from the audience
Test / lint (pull_request) Successful in 22s
Test / test (18) (pull_request) Successful in 23s
Test / test (20) (pull_request) Successful in 20s
Test / test (22) (pull_request) Successful in 20s
Test / test (24) (pull_request) Successful in 21s
Test / test (26) (pull_request) Successful in 20s
Mirror / push (push) Successful in 6s
2026-09-19 00:59:29 +02:00
lilleman 0f13951e8c Move the goals to README.md and state the audience
Mirror / push (push) Successful in 5s
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Successful in 21s
Test / test (20) (pull_request) Successful in 19s
Test / test (22) (pull_request) Successful in 20s
Test / test (24) (pull_request) Successful in 20s
Test / test (26) (pull_request) Successful in 20s
2026-09-19 00:48:21 +02:00
lilleman aab74e4cb3 Fixed CLAUDE.md
Test / lint (pull_request) Successful in 22s
Test / test (18) (pull_request) Successful in 20s
Test / test (20) (pull_request) Successful in 19s
Test / test (22) (pull_request) Successful in 20s
Test / test (24) (pull_request) Successful in 19s
Test / test (26) (pull_request) Successful in 19s
Mirror / push (push) Successful in 5s
2026-09-19 00:15:07 +02:00
lilleman 1c0b4bef34 Tick off the 0.5.0 cleanup in todo.md, and decline a token expiry check
Test / lint (pull_request) Successful in 20s
Test / test (18) (pull_request) Successful in 18s
Test / test (20) (pull_request) Successful in 17s
Test / test (22) (pull_request) Successful in 18s
Test / test (24) (pull_request) Successful in 18s
Test / test (26) (pull_request) Successful in 18s
Mirror / push (push) Successful in 4s
2026-09-14 21:19:17 +02:00
lilleman c5adfd8e39 Say what the mirror's concurrency group does, and name every way a stale ref survives
Test / lint (pull_request) Successful in 20s
Test / test (18) (pull_request) Successful in 18s
Test / test (20) (pull_request) Successful in 18s
Test / test (22) (pull_request) Successful in 18s
Test / test (24) (pull_request) Successful in 17s
Test / test (26) (pull_request) Successful in 17s
Mirror / push (push) Successful in 3s
2026-09-14 20:54:04 +02:00
lilleman 14b114afda Split the GitHub mirror so Gitea creates its runs and no delete is dropped
Mirror / push (push) Successful in 5s
Test / lint (pull_request) Successful in 20s
Test / test (18) (pull_request) Successful in 18s
Test / test (20) (pull_request) Successful in 18s
Test / test (22) (pull_request) Successful in 22s
Test / test (24) (pull_request) Successful in 17s
Test / test (26) (pull_request) Successful in 17s
2026-09-14 20:40:38 +02:00
lilleman 27e8f288ec Mirror every branch and tag to GitHub from CI, keeping what only GitHub has 2026-09-14 20:40:38 +02:00
lilleman e1999a7305 Let a registered encoding claim a data_coding a built-in already uses, in todo.md
Test / lint (pull_request) Successful in 20s
Test / test (18) (pull_request) Successful in 17s
Test / test (20) (pull_request) Successful in 18s
Test / test (22) (pull_request) Successful in 18s
Test / test (24) (pull_request) Successful in 17s
Test / test (26) (pull_request) Successful in 19s
2026-09-14 20:38:02 +02:00
lilleman baa0ad553a Set failover's options, and decide to test on Linux only while keeping macOS and Windows unbroken
Test / lint (pull_request) Successful in 19s
Test / test (18) (pull_request) Successful in 17s
Test / test (20) (pull_request) Successful in 17s
Test / test (22) (pull_request) Successful in 17s
Test / test (24) (pull_request) Successful in 17s
Test / test (26) (pull_request) Successful in 17s
2026-09-14 17:26:46 +02:00
lilleman e32f9ccf46 Plan failover, an optional store for pooling and restarts, shipping src, and Gitea runners for macOS and Windows
Test / lint (pull_request) Successful in 19s
Test / test (18) (pull_request) Successful in 18s
Test / test (20) (pull_request) Successful in 18s
Test / test (22) (pull_request) Successful in 19s
Test / test (24) (pull_request) Successful in 17s
Test / test (26) (pull_request) Successful in 18s
2026-09-14 17:08:54 +02:00
lilleman b3cbbba615 Record the gaps found against other SMPP libraries, and make configurable and extendable a goal
Test / lint (pull_request) Successful in 1m4s
Test / test (18) (pull_request) Successful in 17s
Test / test (20) (pull_request) Successful in 17s
Test / test (22) (pull_request) Successful in 18s
Test / test (24) (pull_request) Successful in 25s
Test / test (26) (pull_request) Successful in 56s
2026-09-14 16:32:23 +02:00
lilleman 95e1f910a3 Track the peer's address on every log message in todo.md, and keep Renovate's dashboard open
Test / lint (pull_request) Successful in 24s
Test / test (18) (pull_request) Successful in 19s
Test / test (20) (pull_request) Successful in 19s
Test / test (22) (pull_request) Successful in 19s
Test / test (24) (pull_request) Successful in 18s
Test / test (26) (pull_request) Successful in 20s
2026-09-14 15:04:12 +02:00
49 changed files with 3614 additions and 1334 deletions
+40
View File
@@ -0,0 +1,40 @@
name: Mirror deletions
on:
delete:
permissions:
contents: read
jobs:
delete:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Delete the ref from GitHub once Gitea no longer has it
env:
MIRROR_GITHUB_TOKEN: ${{ secrets.MIRROR_GITHUB_TOKEN }}
MIRROR_URL: https://github.com/larvit/smpp-js.git
REF: ${{ github.event.ref }}
SOURCE_URL: ${{ github.server_url }}/${{ github.repository }}.git
run: |
# Gitea Actions sends the full ref name here, where its webhooks send the short one.
case "$REF" in
refs/heads/*|refs/tags/*) ;;
*) echo "delete event names '$REF', not a full branch or tag ref"; exit 1 ;;
esac
# ls-remote --exit-code: 0 the ref exists, 2 it does not, anything else the remote could not be read.
on_gitea=0
git ls-remote --exit-code "$SOURCE_URL" "$REF" > /dev/null || on_gitea=$?
if [ "$on_gitea" -ne 2 ]; then exit "$on_gitea"; fi
on_github=0
git ls-remote --exit-code "$MIRROR_URL" "$REF" > /dev/null || on_github=$?
if [ "$on_github" -eq 2 ]; then exit 0; fi
if [ "$on_github" -ne 0 ]; then exit "$on_github"; fi
git init --bare --quiet mirror.git
git -C mirror.git -c credential.helper= \
-c credential.helper='!f() { echo username=x-access-token; echo "password=$MIRROR_GITHUB_TOKEN"; }; f' \
push "$MIRROR_URL" ":$REF"
+29
View File
@@ -0,0 +1,29 @@
name: Mirror
on:
push:
schedule:
- cron: '17 3 * * *'
workflow_dispatch:
permissions:
contents: read
jobs:
push:
runs-on: ubuntu-24.04
timeout-minutes: 10
# Gitea cancels a queued job in this group when the next one arrives; only the full push may join.
concurrency:
group: mirror
steps:
- name: Push every branch and tag to GitHub, overwriting a same-named ref
env:
MIRROR_GITHUB_TOKEN: ${{ secrets.MIRROR_GITHUB_TOKEN }}
MIRROR_URL: https://github.com/larvit/smpp-js.git
SOURCE_URL: ${{ github.server_url }}/${{ github.repository }}.git
run: |
git clone --bare --quiet "$SOURCE_URL" mirror.git
git -C mirror.git -c credential.helper= \
-c credential.helper='!f() { echo username=x-access-token; echo "password=$MIRROR_GITHUB_TOKEN"; }; f' \
push "$MIRROR_URL" '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'
+119 -809
View File
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
# Changelog
## 0.6.0 (unreleased)
- `client()` now bounds each connect attempt at 10 seconds, the TLS handshake included, and reports
one that expires as an ordinary connect failure, so `reconnect` retries it on its usual backoff.
A connect previously waited the operating system out, around 130 s on Linux against a host that
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.
- `pduObj.tlvs` and every `tlvs` input are typed per tag, as `Tlvs` and `TlvInputs`:
`receipted_message_id` reads as a `string`, `message_state` as a `number`, and a value the tag
cannot carry fails to compile. Annotate with `Tlvs` or `TlvInputs` where you wrote
`Record<string, Tlv>` or `Record<string, TlvInput>`; `TlvInput` is gone.
**A TLV input is keyed by its name, or by its decimal id where the table names none, and a `tagId`
that disagrees with its key is refused.** Write `{ 5142: { tagValue } }` for a vendor tag, not
`{ vendor: { tagId: 5142, … } }`; `{ message_state: { tagId: 5, … } }` used to go out as tag 5. A
parsed PDU's `tlvs` still relay as they are. A number for an octet TLV, vendor tags included, is
refused, where it went out as its ASCII digits.
A decimal key naming a tag the table knows, `{ 1063: … }`, is refused in favour of the name, and
so are `alert_on_msg_delivery` and `failed_broadcast_area_identifier` in favour of
`alert_on_message_delivery` and `broadcast_area_identifier`, the names they read back under. The
two alternate names are gone from `tlvs` too, which is now typed by `TlvName`: narrow a `string` with `isTlvName()` before indexing it.
- `cmds.broadcast_sm_resp.tlvMap` is removed; nothing read it.
## 0.5.0
The TypeScript rewrite. 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).
+1 -1
View File
@@ -1 +1 @@
See [AGENTS.md](AGENTS.md).
@AGENTS.md
+9
View File
@@ -30,6 +30,12 @@ shape is the same, connect, send, listen for delivery reports, with callbacks re
`consts.MESSAGING_MODE`**, which also names `SMSC_DEFAULT`. They are bits 1-0 of `esm_class`, not
whole values of it. Read them from the new group, or pass `messagingMode` to `sendSms()`. A stale
`consts.ESM_CLASS.STORE_FORWARD` reads `undefined`, which OR-s into an `esm_class` carrying no mode.
- **A TLV is keyed by its name, or by its decimal id where the table names none**, and a `tagId`
disagreeing with its key is refused: `{ 5142: { tagValue } }`, not
`{ vendor: { tagId: 5142, tagValue } }`. A number for an octet TLV is refused; give a Buffer or a string.
Write `alert_on_message_delivery` and `broadcast_area_identifier`, the names they read back
under, for `alert_on_msg_delivery` and `failed_broadcast_area_identifier`, which are gone from
`tlvs` too.
- **The `error` event is `sessionError`**, and `serverError` on the server handle.
- **`log`** takes any object with `debug`, `error`, `info`, `verbose` and `warn` methods instead of
a `larvitutils` one, and is silent by default: [README](README.md#logging).
@@ -70,6 +76,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.
+137 -23
View File
@@ -22,7 +22,8 @@ window, long messages and delivery receipts. TypeScript, ESM, no dependencies.
[Send options](#send-options) · [Session](#session) · [Receiving in depth](#receiving-in-depth) ·
[Server in depth](#server-in-depth) · [Logging](#logging) ·
[PDUs and the low-level API](#pdus-and-the-low-level-api) ·
[Migrating from 0.4.0](#migrating-from-larvitsmpp-040) · [Development](#development)
[Migrating from 0.4.0](#migrating-from-larvitsmpp-040) · [Goals](#goals) ·
[Audience](#audience) · [Development](#development)
## Install
@@ -100,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
@@ -160,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
@@ -226,6 +227,7 @@ All optional. Timeouts and delays are milliseconds.
| `interfaceVersion` | `0x34` | The SMPP version declared at bind. `0x50` for an SMSC that requires SMPP 5.0. |
| `systemType`, `addressRange`, `addrTon`, `addrNpi` | `''`, `''`, `0`, `0` | The remaining bind fields, for operators that require them. |
| `tls` | `false` | `true` for defaults, or a `tls.ConnectionOptions` object for a private CA or a client certificate. |
| `connectTimeout` | `10000` | Give up on **each connect attempt** the SMSC never completes, the TLS handshake included, and report it as an ordinary connect failure, which `reconnect` then retries. It bounds the socket and the handshake — never the `client()` call, and never the wait for the bind response, which is `responseTimeout`. `false` waits the operating system out instead, around 130 s on Linux; `0` is refused. |
| `enquireLinkInterval` | `20000` | Interval between `enquire_link` on a quiet link. |
| `idleTimeout` | `2 × enquireLinkInterval` | Give up on a link the peer has stopped answering, and re-bind unless `reconnect` is `false`. |
| `responseTimeout` | `30000` | How long to wait for a response, and how long a send with no link waits for the next one. `0` waits forever. |
@@ -260,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 | |
@@ -286,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.**
@@ -316,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.
@@ -340,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
@@ -375,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.
@@ -424,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`.
@@ -472,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
@@ -598,6 +613,13 @@ 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.
- `pduObj.tlvs` is typed per tag, as `Tlvs`: `receipted_message_id` a string, `message_state` a
number, `message_payload` a `Buffer`. A tag the table does not define is a `Buffer` keyed by its
decimal id, `tlvs['5142']`.
- `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.
@@ -606,6 +628,14 @@ 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.
- `tlvs` is keyed and typed like `pduObj.tlvs`, as `TlvInputs`, so a parsed PDU's `tlvs` relay as
they are: `{ message_state: { tagValue: 2 } }`, or `{ 5142: { tagValue: octets } }` for a tag the
table does not define. Any other key, a decimal id the table names, a `tagId` disagreeing with its
key, and a number for an octet TLV are refused.
- 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.
@@ -625,13 +655,97 @@ if (isCommand(pduObj, 'submit_sm')) {
| Messages | `encodeMessage`, `decodeMessage`, `splitMessage`, `bitCount`, `messageOctets`, `concatOf`, `concatInfo`, `detect`, `unencodable`, `messageClassOf`, `dataCodingByEncoding`, `encodingByDataCoding` |
| Receipts | `dlrFromPdu`, `parseReceipt`, `receiptCodes` |
| Time and ids | `smppDate`, `smppTime`, `uuidv7` |
| Spec tables | `cmds`, `consts`, `encodings`, `errors`, `tlvs`, `types`, the `cmdsById`, `constsById`, `errorsById` and `tlvsById` maps, and all of them grouped as `defs`. `isCommandName`, `isErrorName`, `isEncodingName`, `commandNameById` and `errorNameById` narrow a value into them. |
| Spec tables | `cmds`, `consts`, `encodings`, `errors`, `tlvs`, `types`, the `cmdsById`, `constsById`, `errorsById` and `tlvsById` maps, and all of them grouped as `defs`. `isCommandName`, `isErrorName`, `isEncodingName`, `isTlvName`, `commandNameById` and `errorNameById` narrow a value into them. |
| Types | Every option, result, event payload and table entry has a named type: `ClientOptions`, `ServerOptions`, `SendSmsOptions`, `SendSmsResult`, `Sms`, `Dlr`, `MessageDlr`, `Receipt`, `PduObject`, `PduHeader`, `SmppLog`, `Result` and the rest in `dist/index.d.ts`. |
## What changed per release
See [CHANGELOG.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/CHANGELOG.md).
## Migrating from larvitsmpp 0.4.0
See [MIGRATION.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/MIGRATION.md).
## 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](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](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; 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.
4. **A peer an operator never has to complain about.** No bind flooding, nothing a bind direction
forbids, no optional parameters to a peer that declared none, nothing held without a bound.
5. **The session layer is in here, and its defaults are what most applications should run.**
Keepalive, reconnect, the send window, reassembly and receipt correlation. What the network says
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. **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.
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.
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.
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
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.**
- **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);
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:
1. The application developer sending or receiving SMS on the defaults, who should need no options.
2. The application developer who needs one thing retuned — an alphabet, a rate limit, a receipt
format — through an option or a hook rather than a fork.
3. The operator coordinating sessions across processes through a store.
4. The developer migrating from `larvitsmpp` 0.4.0.
## Development
Everything runs in the container; nothing is installed on the host.
+103
View File
@@ -0,0 +1,103 @@
# Benchmarks
What this library sustains, and against what. Run them before setting or changing a scale goal.
```bash
docker compose run --rm node node benchmarks/run.ts # this library, both ends
COUNT=100000 docker compose run --rm node node benchmarks/run.ts # longer, steadier
```
`run.ts` spawns `smsc-sink.ts` — a server that answers every `submit_sm` `ESME_ROK` and stores
nothing — and drives it with `submit-load.ts` at four window sizes. Point `submit-load.ts` at any
SMSC to compare:
```bash
docker compose -f compose.yaml -f interop-tests/compose.jasmin.yaml run --rm node \
node benchmarks/submit-load.ts --host=jasmin --port=2775 --username=esme1 --password=esme1pw \
--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
processes competing for the same CPUs, so these are a floor for split hosts.
`maxOutstanding` is the send window, and it is the setting that matters most:
| Window | msgs/s | with `dlr: true` |
| --- | --- | --- |
| 1 | 7,385 | 7,289 |
| 10 (default) | 25,358 | 24,282 |
| 50 | 37,125 | 37,502 |
| 200 | 40,046 | 39,730 |
Requesting delivery receipts costs nothing measurable at any window. A window of 1 — one request in
flight at a time — costs 5x, which is the round trip rather than the codec.
Against real peers, same driver, window 50, 20,000 messages:
| SMSC | msgs/s | failed |
| --- | --- | --- |
| this library's sink | 37,125 | 0 |
| Jasmin 0.11.0 | 2,207 | 0 |
| SMPPSim 2.6.11 | — | 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
thousand messages and it then refuses the rest, so it cannot be loaded; that is a property of the
simulator, not a result.
`smppload`, the one purpose-built SMPP load generator among the peers, is blocked by a bind defect
of its own ([findings/07-load.md](../interop-tests/findings/07-load.md)), so no third-party load
tool drives these numbers.
+25
View File
@@ -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"
+25
View File
@@ -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"
+33
View File
@@ -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');
+95
View File
@@ -0,0 +1,95 @@
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.
*/
const concurrencies = [1, 10, 50, 200];
const count = Number(process.env.COUNT ?? 5000);
function node(script: string, args: string[] = []) {
return spawn(process.execPath, [`${import.meta.dirname}/${script}`, ...args], {
stdio: ['ignore', 'pipe', 'inherit'],
});
}
async function firstLine(stream: NodeJS.ReadableStream): Promise<string> {
for await (const line of createInterface({ input: stream })) {
return line;
}
return '';
}
const sink = node('smsc-sink.ts');
const listening: unknown = JSON.parse(await firstLine(sink.stdout));
if (typeof listening !== 'object' || listening === null || !('port' in listening)) {
throw new Error('the sink did not report a port');
}
const port = String(listening.port);
const rows: Record<string, unknown>[] = [];
for (const dlr of [false, true]) {
for (const concurrency of concurrencies) {
const load = node('submit-load.ts', [
`--port=${port}`,
`--count=${String(count)}`,
`--concurrency=${String(concurrency)}`,
...(dlr ? ['--dlr'] : []),
]);
const reported: unknown = JSON.parse(await firstLine(load.stdout));
await once(load, 'exit');
if (typeof reported === 'object' && reported !== null) rows.push({ ...reported });
}
}
sink.kill('SIGTERM');
await once(sink, 'exit');
process.stdout.write(`\n${'dlr'.padEnd(6)}${'window'.padEnd(9)}${'msgs/s'.padEnd(10)}seconds\n`);
for (const row of rows) {
const dlr = String(row.dlr).padEnd(6);
const window = String(row.concurrency).padEnd(9);
const rate = String(row.perSecond).padEnd(10);
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);
+33
View File
@@ -0,0 +1,33 @@
import { server } from '../src/server.ts';
/**
* Answers every submit_sm ESME_ROK and does nothing else, so a measurement against it reads this
* library's own ceiling rather than an SMSC's storage. Prints the bound port on stdout, then waits.
*/
const port = Number(process.env.PORT ?? 0);
const { err, server: smpp } = await server({ port });
if (err) {
process.stderr.write(`sink failed to listen: ${err.message}\n`);
process.exit(1);
}
let answered = 0;
smpp.on('session', session => {
session.on('sms', async sms => {
answered++;
await sms.sendResp();
});
});
smpp.on('serverError', reason => {
process.stderr.write(`sink serverError: ${reason.message}\n`);
});
process.stdout.write(`${JSON.stringify({ port: smpp.port })}\n`);
process.on('SIGTERM', () => {
process.stderr.write(`sink answered ${String(answered)}\n`);
void smpp.close({ signal: AbortSignal.abort() }).then(() => process.exit(0));
});
+72
View File
@@ -0,0 +1,72 @@
import { client } from '../src/client.ts';
/**
* Pushes `count` single-segment messages and reports what the wire carried per second. Keeps
* `concurrency` sends in flight so the send window, not the caller, is what bounds the rate.
*/
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 host = arg('host', '127.0.0.1');
const port = Number(arg('port', '2775'));
const count = Number(arg('count', '2000'));
const concurrency = Number(arg('concurrency', '20'));
const message = arg('message', 'benchmark');
const dlr = process.argv.includes('--dlr');
const username = arg('username', 'user');
const password = arg('password', 'pass');
const { err, session } = await client({
host,
maxOutstanding: concurrency,
password,
port,
responseTimeout: 60_000,
username,
});
if (err) {
process.stdout.write(`${JSON.stringify({ error: err.message })}\n`);
process.exit(1);
}
// Narrowing from the guard above does not reach into worker(), which runs after it.
const bound = session;
let issued = 0;
let failed = 0;
let unanswered = 0;
async function worker(): Promise<void> {
while (issued < count) {
issued++;
const sent = await bound.sendSms({ dlr, from: 'BENCH', message, to: '46709771337' });
if (sent.err) failed++;
unanswered += sent.unanswered;
}
}
const started = process.hrtime.bigint();
await Promise.all(Array.from({ length: concurrency }, () => worker()));
const seconds = Number(process.hrtime.bigint() - started) / 1e9;
process.stdout.write(`${JSON.stringify({
concurrency,
count,
dlr,
failed,
perSecond: Math.round(count / seconds),
seconds: Number(seconds.toFixed(3)),
unanswered,
})}\n`);
await bound.close({ signal: AbortSignal.abort() });
process.exit(0);
+832
View File
@@ -0,0 +1,832 @@
# Decisions
The standing decisions for `@larvit/smpp`, grouped by what each one constrains. A decision is
written down only when it cannot be put better as a goal — [AGENTS.md](../AGENTS.md) carries that
rule and an index of the titles below.
## The public surface
- **`Session` is publicly constructible, which is what makes `SessionOptions` and `ReconnectOptions`
public too.** Raised twice as a leak; it is not one. The collaborators `session.ts` delegates to
(`Reassembler`, `PendingRequests`, `SendWindow`, `ReconnectLoop`, `LinkTimers`, `LinkGate`,
`DlrMerger`, `PduTransport`, `submitSms`) stay unpublished so they can be reshaped.
- **`acceptsOptionalParams()` and `bindAllows()` are predicates, not chokepoints.** The library's own
senders consult them; `session.send({ tlvs })` is passed through as written, because silently
stripping a caller's explicit TLVs off a deliberately public low-level surface would be worse than
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.
- **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
consumer. `declare on: …` and its six siblings re-type the inherited methods to return `unknown`,
which emits nothing and needs no cast; overriding them as real methods cannot work, because the
`super.on()` call needs one. The cost is that a subclass can no longer reach those seven through
`super` — re-declaring them the same way is its way out. `unknown` rather than
`void | Promise<void>` because a listener may return anything: `session.on('close', () =>
set.delete(session))` returns a boolean. This also settles what the drain can wait on: a listener's
own promise would be the better completion signal, and reaching it needs `listeners()`, which
cannot be re-declared the same way — Node types it invariantly enough that widening `void` to
`unknown` is `TS2416`. Re-probed 2026-09-01; `sendResp()` stays the signal.
- **`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 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
(`PduRefusedError['header']`) names either one where a signature wants it. The payload union
enforces nothing — a subclass narrows out of `Error` either way — and is there so the event's own
type names what to narrow to, which is also what makes it a half-truth if a second `Error` subclass
ever reaches this event without joining it. Rejected: a `SessionError` alias for that union, a
third name for a type that is structurally `Error`. Rejected: a separate `pduRefused` event, which
splits the failure channel so an application that wants every failure listens twice and an existing
listener silently stops seeing refusals. Rejected: coalescing or rate-limiting them, which re-opens
the standing decision that `sessionError` carries every failure, never coalesced or suppressed —
the filtering belongs where the application is, since only it knows which peer is routinely sloppy.
Rejected: an error code on a plain `Error`, which reads back off an `unknown` property only through
a cast and types nothing it carries. Accepted: a second copy of the package installed alongside
this one defeats `instanceof`, where `err.name` still reads `PduRefusedError`.
- **`bitCount()`, `encodeMessage()` and `splitMessage()` keep their total signatures, because
`EncodingName` is what keeps an alphabet with no codec away from them.** Maintainer's call,
2026-09-09, from the architecture review of [#95](https://github.com/larvit/larvitsmpp/pull/95):
all three index `encodings` by name and would throw on one it has no codec for, which hard rule 1
forbids. That PR left no such name to pass — `encodings` is a `Record<EncodingName, Encoding>`, so
every member of the union has a codec and one added without a codec, or without a segment budget,
fails to compile in four places. What was missing is the door for a caller holding a name at
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 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()` 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.**
Maintainer's call, 2026-09-12: `paramText()` resolves an absent `message_id` and one a peer wrote
empty to the same `''`, which `string[]` then presented as an id — taking `smsIds[0]`, or keying a
correlation table by the array, compiled and then misbehaved, and one message's empty entry
collides with another's. Telesign names an id for the first segment of a concatenated submit only,
so it is a documented operator's shape rather than a hypothesis. Nothing else moves:
`parseSegmentId('')` matched nothing, so `DlrMerger` already abandoned such a send and `undefined`
reaches that same refusal. `expect()` takes the wider type rather than a filtered `string[]`
because the arity is what `idNumbering()` refuses on: filtering `['a-1', undefined, 'a-3']` leaves
a numbering that spells out a whole message, and merges one that was never whole. `dlrFromPdu()`
reads an id through `nonEmptyText()`, so no receipt could ever have matched an empty entry — and
that reading stays separate from this one rather than sharing a helper, since it must leave a
Buffer-valued `receipted_message_id` unresolved for `messageType()` to read the PDU as unmarked.
What settles it here is the resolved text rather than the parameter, because `writeParams()`
substitutes the field's own default: a peer that omits `message_id` and one that writes it empty
build the same octets, leaving a raw-parameter test nothing to tell apart. Rejected: keeping `''`
and documenting it, which leaves the published type promising what the value does not keep — goal
2, a wrong answer about what the peer named. Rejected: dropping the unnamed entries, which breaks
the positional correspondence with `pduObjs` that README promises and loses which segment a PDU
belongs to. Rejected: `{ id?: string; pduObj: PduObject }[]`, which makes that positional promise
structural where today the compiler cannot check it; deferred to the next breaking release, the
first place two documented fields may become one. Accepted: every consumer reading `smsIds`
narrows, including the majority whose SMSC names every id; indexing narrows too, except for the
consumer who sets `noUncheckedIndexedAccess`, which typed `smsIds[0]` as `string | undefined`
already.
## The wire
- **The declared interface version is an option on both `client()` and `server()`, and is not the
optional-parameter threshold.** That threshold is fixed at 0x34 by the spec, so an implementation
that must declare 5.0 throughout can, without moving it.
- **A peer that declared no version is pre-3.4, and `undefined` means no bind yet.** `acceptBind()`
records what the ESME declared and the client's `bind()` records the `sc_interface_version` the
SMSC answered with; a peer that declared nothing is recorded as `undeclaredInterfaceVersion` (0x00)
and sent no optional parameters, which is how the spec reads an absent `sc_interface_version`.
- **`esm_class` decides what a `deliver_sm` is, and the body is read only when it names nothing.**
The two types the MC writes about a message we submitted — `MC_DELIVERY_RECEIPT` (0x04) and
`INTERMEDIATE_DELIVERY` (0x20) — are reports whatever the body parses to, so one in a format
`dlrFromPdu()` cannot read reaches `dlr` with `smsId` undefined instead of arriving as an inbound
SMS. The three the far-end SME writes (0x08, 0x10, 0x18) are messages and their bodies are not
scraped: Kannel reads 0x08 as report-bearing and this does not, because a delivery acknowledgement
is the handset's word about a message, not the network's. A message type of 0 or one of the ten
reserved keeps the scrape, and a non-empty `receipted_message_id` TLV marks a report on the same
footing. A report this library recognises never reaches the reassembler, so an SMSC that splits one
across segments gets a `dlr` per segment rather than one merged report. The `message_state` TLV is
authoritative only where it names a state in the table — SMPP reserves 0x80-0xFF for
MC-vendor-specific values, so an unnameable one keeps its raw `statusId` and leaves `statusMsg` to
the body.
- **A body is read from `message_payload` where `short_message` carries none, and `short_message`
wins where a peer filled both.** Maintainer's call, 2026-09-06, from the Jasmin interoperability
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
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
spec's own instruction to leave `sm_length` zero, and taking the mandatory field there keeps the
rule purely additive: no PDU that parsed before reads differently now. Rejected: preferring the
TLV, which re-reads every message a peer echoes into both. Rejected: refusing a PDU carrying both,
which discards a message that is almost certainly present twice over, where goal 3 keeps the
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
the Jasmin and Java-client interoperability phases: SMPP 3.4 5.3.2.31-5.3.2.33 make
`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)).
`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
reference, so the key is two tokens the reassembler joins and interprets neither of, and so the
refusal can name the field the peer got wrong — `ESME_RINVESMCLASS` for a UDH, `ESME_RINVTLVVAL`
for the TLVs, whose segment's `esm_class` is 0x00 and correct. Keying them together instead would
assemble two of a peer's messages into one, since a UDH reference is 8 bits and `sar_msg_ref_num`
is 16 and neither counts the other's messages; the two UDH widths share a space because they are
one sender's counter in one layer, where a `sar_*` reference is another layer's. A UDH that names
the concatenation wins over the TLVs — one carrying only a port leaves them to say — which keeps
the change additive for every message that reassembled before, and leaves the library nothing to
guess where the two disagree. Rejected: preferring the TLVs, which regroups every message a
gateway derived them from. Rejected: comparing the parts and reporting a disagreement: the
references are not comparable at all, and where the parts are, the UDH is still what the message
is assembled by, so the report would name a failure the application cannot act on. Accepted: a
peer that switches spelling mid-message now has two groups that expire rather than fragments that
arrive, which goal 2 prefers to a message assembled from two counters. Receive-only: `sendSms()`
goes on writing a UDH with an 8-bit reference, where a send-side `sar_*` would be a second
spelling of one message whose only difference is which peers accept it.
- **`sendSms()` takes the messaging mode by name, and it is the only part of `esm_class` a caller
writes.** Maintainer's call, 2026-09-06, closing target 5 of the interoperability plan: every peer
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 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 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
had `constsById.ESM_CLASS` read 0x03 as a whole `esm_class`. Rejected: a raw `esmClass` number,
which is exactly that clearable state and would need refusing bit by bit to be safe. Rejected:
taking a number beside a name, two spellings of one goal — which is why a value naming no mode is
refused, by name, before a segment goes out. Rejected: a session-level default with a per-send
override; an operator's requirement is a property of the link, but the library can verify nothing
the caller's own options object does not, and shipping both buys a precedence rule to document and
test for that. `SMSC_DEFAULT` is named so pinning the default deliberately is sayable.
- **An inbound `data_sm` stands in for whichever of `submit_sm` and `deliver_sm` its direction makes
it, and none goes out.** Maintainer's call, 2026-09-06, from the Jasmin interoperability phase:
SMPP 3.4 4.7.1 makes it a peer of both that always carries its body in `message_payload`, and
Jasmin's `[dlr-thrower] dlr_pdu = data_sm` throws real receipts on it, which `ESME_RINVCMDID`
dropped with nothing reported to the application at all. Every command but this one names its own
direction, which is why the bind gate and the dispatch never had to be told which end of the link
they are on; `linkEnd` is that fact, and it decides both. At the ESME end an inbound one is a
delivery, so `esm_class` classifies it as it classifies a `deliver_sm`; at the SMSC end it is a
submission and is read as one, because a report about a message this end never sent is goal 2's
wrong answer whatever `esm_class` a peer wrote on it. A concatenated one is answered segment by
segment either way, and 4.7.2 gives `data_sm_resp` a `message_id` where 4.6.2 leaves
`deliver_sm_resp`'s unused, so the answer carries one. `linkEnd` is a field beside `boundAs`
rather than a `SessionOptions` entry, so the code that knows which end this is writes it and
nothing else can contradict what the session then binds as. Rejected: grouping the command with
`deliver_sm` in the gate, which refuses a transmitter-bound ESME's legitimate submission, and with
`submit_sm`, which refuses the receiver-bound delivery this was fixed for. Rejected: sending one —
`send()` reaches the command raw, and an option choosing which command a message goes out on would
be a second spelling of `sendSms()` whose only difference is which peers accept it.
- **A receipt's body is read as octets, and its own `data_coding` never says how.** Maintainer's
call, 2026-09-05 via the SMPPSim interop run: SMPPSim copies the reported message's `data_coding`
onto a receipt whose body it always writes as plain text, and Melrose Labs documents the same
echo, so decoding by that field turns an Appendix B receipt into UCS-2 garbage — total loss
against the many peers that send no TLVs to fall back on. `dlrFromPdu()` reads
`PduObject.shortMessageOctets` through Latin-1, the one codec that maps every octet to a
character, so the fixed fields parse whatever the PDU claims; the codec keeps both spellings
because a message needs the text and a receipt needs the octets. Rejected: honouring `data_coding`
where the octets yield no field, which reads one body two ways for the sake of a peer writing a
UCS-2 receipt body that no researched SMSC is — that peer's receipt yields no fields at all here,
which goal 2 reports as undetermined rather than guessed. An inbound message is untouched: nothing
but `data_coding` can say how a message was written.
- **A message class is read where GSM 03.38 puts it, `flash` is class 0 alone, and a flash message
with no alphabet to carry it is refused.** Maintainer's call, 2026-09-09, closing the last target
of the interoperability plan: `sms.flash` was `(data_coding & 0xF0) === 0x10`, which called the
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).
`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 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
class 0 rather than special-cased into a wrong answer; 01xx is read for the same reason, 03.38
coding it exactly as 00xx. Rejected: reading only the two groups the defect named, which needs an
extra test to produce a wrong answer for a class the spec puts in plain sight. Accepted: the
alphabet is read only where a class is, so 0x58 is UCS2 while 0x48 — the same alphabet with the
class bit clear — stays ASCII, because below 0x10 SMPP's flat table contradicts 03.38 and wins
(0x03 is Latin-1 there, GSM 7-bit here) and a class is the only evidence a peer below 0x80 is
spelling 03.38 at all. Send-side: `flash`
is that class, so it goes out as 0x18 beside UCS2 and 0x10 beside GSM 7-bit, while
`encoding: 'LATIN1'` beside it is refused before a segment goes out, the way a messaging mode this
library cannot deliver is — 03.38's class groups hold GSM 7-bit, 8-bit data and UCS2, and Latin-1 is
SMPP's own flat-table alphabet, so the pair has no spelling. Rejected: 0x10 with Latin-1 octets,
which declares an alphabet the body is not in; rejected: 0x14, 8-bit data, which is not text to
the handset that would display it; rejected: promoting it to UCS2, which overrides the one option
the caller wrote in order to override a choice. Rejected with them: `encoding: 'FLASH'`, which
named `data_coding` 0x10 among the alphabets and so reached the class through the option that
chooses a charset — a second spelling of `flash: true` that also flattened every non-GSM character
to a space on the way. It leaves `EncodingName`, which is now exactly the three codecs `detect()`
and `encodingByDataCoding()` return, and `encoding` is checked by name like `messagingMode` so a
caller without types gets a refusal rather than a throw out of the codec table. `consts.ENCODING`
keeps its `FLASH` entry: the low-level surface reaches raw constants, and nothing reads that group
as an alphabet any more. Accepted: `flash` is now false for `data_coding` 0x11 to 0x13, which no
peer means as immediate display.
- **A report is final unless its `esm_class` or its state says otherwise, and only `ENROUTE` and
`SCHEDULED` say otherwise.** SMPP 3.4 Appendix B lists every other receipt state as final,
`UNKNOWN` and `ACCEPTED` included, so a peer writing `ACCEPTD` for a carrier-accepted step is taken
at its word. Rejected: reading `UNKNOWN` as non-final, which leaves a peer whose receipt body this
library cannot read with no `messageDlr` at all — goal 2 wants that reported as undetermined, not
withheld. Both spellings resolve into `Dlr.intermediate` at the boundary rather than being read a
second time in `DlrMerger`, so the library cannot answer the application one way and conclude the
other. Not every peer marks a transient report 0x20 — an ordinary receipt carrying `stat:ENROUTE`
is common — so the state test is what the marker test cannot replace. `message_state` 0 is 5.0's
`SCHEDULED` and undefined in 3.4; a peer that writes it is read as transient rather than as saying
nothing, maintainer's call, 2026-09-03, since the codec refuses a zero-length integer TLV and so an
absent one cannot land there.
- **A `stat:` an operator spells outside Appendix B is read as the state it names, and the two
researched ones are `FAILED` and CM.com's `DELIVERD`.** Maintainer's call, 2026-09-08, from the
operator-fixture phase: Kaleyra and Route Mobile both document `FAILED` in that field as a terminal
delivery failure, and the research attributes it to Vonage as well; CM.com's own code table prints
`DELIVERD` — eight characters — beside six correct ones. Both were left at `statusMsg: UNKNOWN` —
the same answer a receipt really saying `stat:UNKNOWN` gets, so an application could not tell an
operator's "it failed" from its "I do not know", nor a delivered message from one whose state
could not be read; and `DlrMerger` ranks `UNKNOWN` below `EXPIRED`, reporting a multipart send
carrying a failed segment as expired. They join `receiptStates` alone: `receiptCodes` goes on
writing the seven characters 3.4 defines, so nothing this library sends gains either spelling. Rejected: a `FAILED` member of `MESSAGE_STATE`, which
is 3.4's own numbered table — the code has no number there, so one would have to be invented, and
every consumer's switch would grow a case no `message_state` TLV can carry. Rejected: leaving it
`UNKNOWN` and sending the application to `dlr.receipt.stat` for the state, which reports a terminal
failure as undetermined and leaves the merge ranking it below `EXPIRED`. Rejected: reading the
numeric status tables Syniverse and Route Mobile publish beside it, which are vendor fields of
their own rather than the seven characters `stat:` holds. Accepted: all three of those operators
document `FAILED` and `UNDELIV` as separate codes, and both now resolve to `UNDELIVERABLE` — an
application that must tell them apart reads `dlr.receipt.stat`, which carries what the SMSC wrote.
Accepted: an unmarked `deliver_sm` whose body says one of them now reaches the application as a
report where it used to arrive as an inbound message, which is what every code already in the table
does. What decides a spelling is whether the corpus in `test/operator-receipts.test.ts` can cite the
page it is printed on and no other code could be meant, which is why `DELIVERD` is read and a
spelling nobody publishes is not: a mapping that costs nothing where an operator's own docs merely
contain a typo saves an application everything where they do not.
- **A transient state goes out as an intermediate delivery notification (0x20), every other state as
a delivery receipt (0x04).** Appendix B makes a receipt's `stat` the message's final status, so
0x04 over `ENROUTE` emits the two disagreeing spellings of finality the reading side above has to
reconcile, and goal 3 has our own senders write the marker 3.4 defines. `sendDlr()` takes the list
from `transientStates` in `dlr.ts`, the same one the reader uses, so the two cannot drift.
Rejected: 0x04 for every state, for the sake of a peer that classifies on the marker — the cost
accepted here is that such a peer stops recognising a transient report as a report at all and hands
its application receipt text as an inbound message, where under 0x04 it would have read the state
from `stat:` and been right. A transient state also carries `err:000`, since a message still on its
way has not failed.
- **A refused PDU is answered from its header, and any 32-bit `sequence_number` is echoed as it
arrived.** Maintainer's call, 2026-09-05 via the interop plan. The header of a framed PDU always
parses, so it carries the answer SMPP 3.4 4.3 asks for, with the status 3.4 names for the part
that would not parse. Rejected: nacking a refused *response*, whose sequence number is one of
ours — the `generic_nack` would land in the peer's own numbering and nack a request of the peer's
we never saw, so a refused response is written back nothing and settles the request it names
instead. An unknown command id with the response bit set takes that branch too: a peer echoing a
sequence number of ours is answering something, and settling it reaches the undetermined outcome
`responseTimeout` would have reached anyway, sooner. Rejected: clamping a sequence number outside 4.7.1's 0x00000001–0x7FFFFFFF into range
before answering, which correlates with nothing at the peer — stacks write the field as a plain
uint32 (ukarim/smscsim signs every unprompted `deliver_sm` with a raw `rand.Int()`), so goal 3
keeps that traffic and `PendingRequests.nextSeqNr()`, the only thing that invents one, is what
holds our own sends inside the spec.
- **The optional parameters run to `command_length` exactly, and the only slack tolerated is one
NULL octet where a peer padded `short_message`.** Maintainer's call, 2026-09-06, from the
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
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
once, in `readCstring()`: a trailing C-Octet String a peer left out entirely consumes no octet,
where reporting the terminator it never sent puts every later offset past the declared end and
refuses a bind, and every bodyless response, that used to parse. That composes, so a run of them
at the tail all read empty — `outbind` is the only command with two, and an absent field and an
empty one say the same thing, so goal 2 is not at stake even there. Rejected: keeping the tolerance
for the one to three trailing octets too few to hold a TLV header, which no researched peer sends
and which cannot be told apart from the truncated tail this fixes. Rejected: refusing it as
`body`/`ESME_RINVCMDLEN`, which names the mandatory fields — the part the peer got right.
- **`smsIdFormat` names a notation per place, and normalisation never reaches inside a `<base>-<n>`
id.** An SMSC may answer `submit_sm_resp` in hex and write the receipt's `id:` in decimal, so one
transform over both sides cannot make them equal. `submitResp` covers the `receipted_message_id`
TLV too, which SMPP 3.4 5.3.2.26 defines as the id the `submit_sm_resp` carried: naming one
notation for whichever id a receipt yields would break the peer that sends both. Omitting a place
is what leaving it alone means, so there is no `raw` notation, and a caller-supplied formatter is
refused because it would make the promise that the two ids are comparable unverifiable — `onRequest`
and the PDU on the `dlr` event are the escape hatches. A `<base>-<n>` id parses as no number and so
reaches `expect()` and `collect()` unchanged, which is what keeps `DlrMerger` working; normalising
the base instead would break that pair. The option is on `client()` only, since a `server()` session
writes both ids itself.
- **A concatenated segment is budgeted at 134 octets, which is 153 septets where the SMSC packs them
and 134 octets of anything it does not.** Maintainer's call, 2026-09-09, from the architecture
review of [#95](https://github.com/larvit/larvitsmpp/pull/95): `segmentUnits` handed 153 to
everything but UCS2, so a long `encoding: 'LATIN1'` message went out as segments of 153 octets plus
a 6-octet UDH — 159 on the air where GSM 03.40 carries 140, which no SMSC can deliver. Goal 1 owns
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
[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
refused, both before a segment goes out.** Maintainer's call, 2026-09-09, from the architecture and
stability reviews of [#96](https://github.com/larvit/larvitsmpp/pull/96): `encoding: 'LATIN1'` on
`あいう` put `42 44 46` — `"BDF"` — on the wire and returned success, `encoding: 'ASCII'`
flattened every character outside 03.38 to a space, and `validityPeriod: new Date('nope')` wrote
`NaNNaNNaNNaNNaNNaNNaN00+` into the PDU. Goal 2 owns all three: bytes that do not say what the
caller asked, reported as sent. `unencodable()` is the single answer to whether an alphabet can
carry a message, as `messageClassOf()` is to whether a `data_coding` carries a class, and it asks
the codec — `decode(encode(c)) === c` per code point — rather than restating the tables beside it,
so the guard cannot drift from what the encoder writes for any one character, and a fourth
alphabet answers by having a codec at all. It is exported for the reason `concatOf()` is: a caller
composing a `submit_sm` through `send()` and `encodeMessage()` would otherwise rewrite the read
this fixed. `match()` cannot be that answer — it doubles as the auto-selection policy `detect()`
reads, where LATIN1 is hardcoded false so nothing picks it, and using it would refuse the 8-bit
binary body Latin-1 is kept for. The guard is
on the named branch alone, so an unspecified send is untouched: every alphabet `detect()` returns
carries every character it was picked for, over the whole code point range. `smppTime.encode()`
returns a `Result`, where the three encoding helpers stayed total: that argument was that
`EncodingName` is a closed set the compiler guards, and `Date | number | string` is not — an
invalid `Date` and `NaN` inhabit it, which hard rule 1 makes a result "wherever the types admit
one", and `decode()` has been fallible for the same reason since it was written. Rejected:
transcoding to UCS2, which overrides the one option the caller wrote in order to override a choice
— the same reason a flash Latin-1 message is refused rather than promoted, and an operator that
accepts only `data_coding` 0x03 would be handed something it never agreed to take. Rejected:
guarding `sendSms()` alone and leaving `smppTime.encode()` writing `NaN`s, which leaves this
library's own published helper composing the garbage the guard exists to stop. Rejected: a
`holds()` member beside `match()` on `Encoding`, a second per-alphabet table to keep in step with
the codec. Rejected: validating the `string` spelling of a time, which is a stamp the caller
formatted for a peer whose format is theirs to name, its width included, where SMPP 3.4 gives the
field 1 or 17 octets. Accepted: a second count past 99d 23:59:59 is refused rather than clamped to
it, a negative one and `Infinity` with it — clamping `86400 * 365` reported success for a year and
put 99 days on the wire, the wrong answer about what happened that the rest of this bullet exists
to remove. The ceiling is this encoder's rather than the format's: 3.4's `YYMMDDhhmmss000R`
carries years and months, which `decode()` reads back, and no fixed number of seconds is either
one, so spelling a second count in days and below is where the guess would go — which is why the
too-long refusal names the `Date` that reaches every instant the absolute form holds, and the
negative one names nothing, there being no period to reach. Rejected: documenting the clamp, which
leaves the caller told a true thing and still sent the wrong period. Accepted: GSM's 0x1B is an
extension prefix rather than a character, so a bare ESC beside one of the ten extension bases is
the one input a per-character reading passes and the encoder then writes as the extended character
— the only composition in any of the three codecs, and not a character a message is written in.
- **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`.**
Maintainer's call, 2026-09-09, from the architecture review of
[#97](https://github.com/larvit/larvitsmpp/pull/97): `objToPdu()` took the codec off the caller's
own `data_coding` and encoded with it whatever the text was, so `data_coding` 3 beside `あいう`
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 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 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:
`encodeBody(text, dataCoding)` is `decodeMessage(buffer, dataCoding)`'s mirror and resolves the
alphabet through the same `encodingByDataCoding()`. `send()` and `sendReturn()` inherit it,
since both build through `buildPdu()`; `sendSms()` does not, and keeps its own guard, because
`splitMessage()` hands the codec a Buffer with nothing left to refuse and the index a segment
could name is not the one in the message. The TLV is encoded rather than merely checked because
`data_coding` names the alphabet of the body wherever it is carried — that is how
`messageOctets()` and `decodeMessage()` read one back, and a `data_sm` has nowhere else to put one
— so refusing what Latin-1 cannot hold while still writing UCS-2 text as Latin-1 octets would
close half of it. `short_message` settles the `data_coding` wherever it carries octets at all, the
order `messageOctets()` reads the two in, so the alphabet a PDU declares is the one its body will
be read under — and a `short_message` on a command whose table declares none is ignored here as
`writeParams()` ignores it, so an empty one, an absent one and one the wire cannot carry are the
same input rather than three. A `data_coding` on a command that declares no such field is honoured
the other way round, since it is `replace_sm`'s only way to name the alphabet its octets are in.
Rejected: refusing a string `message_payload` outright and demanding octets, which contradicts
`short_message` on the same PDU. Rejected: guarding every string-valued field against
`data_coding`, which says nothing about them — a text field on the wire has an 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
through `consts.ENCODING`, so `encoding: 'ASCII'` went out as 0x01 — SMPP 3.4 5.2.19's *IA5 (CCITT
T.50)/ASCII* — while the codec writes GSM 03.38, where `$` is 0x02 and `@` is 0x00 against IA5's
STX and NUL. Goal 1 owns it, and this library's own reader hid it by resolving both codings to the
same codec. `dataCodingByEncoding` is the single answer to which coding an alphabet is written
under, as `unencodable()` is to whether one can carry a message: the mirror of
`encodingByDataCoding()`, and reached by both the `sendSms()` path and `encodeBody()`'s detected
one rather than each spelling the map again, which is what `sendDlr()` inherits it through. It is
exported for the reason `unencodable()` is — a caller pairing `encodeMessage()`'s octets with a
`data_coding` of its own had only `consts.ENCODING` to reach for, which is the trap. 0x00 is the
*SMSC's* default alphabet rather than 03.38 by name, so it is a convention rather than a guarantee;
it is also what every peer in `interop-tests/` submits under and what LINK Mobility, Route Mobile
and Telesign all publish 03.38 as, where 0x01 names a different alphabet from the one written and
so is wrong whatever the peer makes of it. Reading is untouched, goal 3: those same three map 0x01
to 03.38 too, and Kaleyra and Route Mobile publish that value as known to cause problems, so no
researched peer means IA5 by it. The two tables agree over most of the printable range and part at
0x00-0x09, 0x0B-0x0C, 0x0E-0x1A, 0x1C-0x1F, 0x24, 0x40, 0x5B-0x60 and 0x7B-0x7F — line feed,
carriage return and escape are common to both — which is where a peer that did mean IA5 is
misread. Accepted with it: `consts.ENCODING` loses its `ASCII` alias and keeps `IA5`, the two
names 5.2.19 gives 0x01, because that alias was the only name the two tables shared at different
values and so the only one a reader could carry from the option's vocabulary into SMPP's flat
table; `constsById.ENCODING[0x01]` already read `IA5`, so nothing moves but the forward name.
Rejected: moving `consts.ENCODING.ASCII` to 0x00, which would make that table contradict the
section it exists to spell — the group is SMPP's flat `data_coding` table, not the `encoding`
option's vocabulary, the distinction the `FLASH` removal already drew. Rejected: reading 0x01 as
Latin-1, the closest codec here to IA5, which mojibakes every peer that means GSM for one nothing
researched has found. Accepted: a message already in flight is unmoved — both codings resolve to
the same codec, `messageClassOf()` finds no class in either, and `Reassembler` groups on the
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.
- **A TLV input is keyed by its tag name, or by its decimal id where the table names none, and a
`tagId` beside the key is accepted only where it agrees.** Settled in the architecture and
product-owner reviews of [#30](https://gitea.larvit.se/larvit/smpp-js/pulls/30), 2026-09-27. The
key is the one spelling, because a name and a `tagId` that disagreed sent the `tagId`'s tag under
a record keyed as another. A parsed TLV carries its `tagId`, and goal 8's passthrough means a
parsed PDU's `tlvs` relay as they are, so an agreeing copy is read past rather than refused.
Rejected: refusing every `tagId`, which breaks relaying. Rejected: a `tagId` overriding the key,
the 0.5.0 behaviour. Valid while parsed TLVs carry `tagId`.
## The session's life
- **A close arriving after our own `unbind` is a clean unbind, not an error.** Maintainer's call,
2026-08-26: most SMSCs drop the socket instead of answering, so the documented shutdown would
otherwise always report a failure. It does mask a socket that died mid-unbind for an unrelated
reason, which is accepted — the peer sees the same TCP close either way.
- **`close` means the session is over, and a drop the loop will retry is `disconnected`.**
Maintainer's call, 2026-08-31: without the split, an application that opens a replacement client on
`close` ends up holding two binds on one account. `teardown()` picks the event by whether the
reconnect loop is still live, and `end()` stops that loop before tearing down, so every deliberate
shutdown emits `close`. A retry that opens a socket and then loses it clears `closed` through
`attach()`, which is why a second drop emits again.
- **An answer belongs to the link the message arrived on; a receipt does not.** Maintainer's call,
2026-09-01. Rejected: answering on the new link, which succeeds and reports `{}` for a response
that correlates with nothing — goal 2's wrong answer. Accepted: a receipt sent after a refused
response names an id the peer has no record of.
- **`reconnect` takes `{ minDelay, maxDelay }` to retune and `false` to turn off**, so absent means
on and there is one spelling for each. Only `client()` reconnects — a `server()` session is a
connection the peer opened, and nothing at this end can reopen it. The retry timer is `unref()`'d,
so a process with nothing else left to do still exits between attempts.
- **Coming up is not proof a link works, so only one that outlasted `maxDelay` resets the backoff.**
An unreadable stream is found after the bind returns, so resetting on connect gave a link that died
on arrival a fresh `minDelay` every cycle — one TCP connect and bind per second, forever. A drop
after a healthy link still retries at `minDelay`.
- **`reconnect: { fromStart: true }` puts the first connect and bind through that same loop, and
`client()` then resolves only once it is bound.** Maintainer's call, 2026-09-05: an application
started before its SMSC is up otherwise writes that retry itself, around the one this library
already owns. A field on `reconnect` rather than an option of its own, so the combination that
would contradict `false` cannot be written at all — `false` carries no fields — and a top-level
`fromStart` is refused by name rather than ignored. Nothing but the caller's `signal` ends the
wait: a bound of its own would be a second spelling of a deadline the caller already writes with
that signal, and giving up after one is what the default does. A bind the SMSC refuses is
retried like any other failure — rejected: giving up on `ESME_RINVPASWD` and `ESME_RBINDFAIL`,
which would have the initial attempts and a rebind disagree about what a refused bind means, and
gives up on the operator whose provisioning lands a minute later; the backoff is what bounds the
rate goal 4 cares about. The attempts before the first link report nothing, because the session
running one has not reached the application: `disconnected` would have no listener and `close`
would be a lie. Its wait is the one retry timer that is not `unref()`'d, for the reason
`LinkGate`'s hold is not — it is awaited with no other handle, so a process whose only work is
`client()` would exit unbound.
- **`connectTimeout` defaults to 10 s, bounds the whole connect including the TLS handshake, and
`false` is the one way to turn it off.** Maintainer's call, 2026-09-20, serving goal 5: a connect
that never returns is one `reconnect` cannot retry, because the operating system holds the attempt
for around 130 s at Linux's default `tcp_syn_retries` and nothing above it is counting — and no
application chooses that, so it is a default rather than an option that switches on what the caller
obviously wanted. Accepted: the far end sees roughly four times the SYNs against a dead host, one
per ~40 s rather than one per ~160 s, which goal 4 tolerates because the backoff still caps the
rate. `false` spells the operating system's wait, as it does for `reconnect`, and `0` is refused
naming it, so one spelling reaches each result. What expires is reported as the ordinary connect
failure, so the loop retries it like any other, and the message names the peer and whether the TCP
connect or the TLS handshake stalled — different faults, different answers. It settles on
`secureConnect` for a TLS socket, so a peer that accepts and then says nothing is bounded the same
way a black-holed SYN is. Rejected: shipping the option with no default, which left goal 5's "an
option does not switch on the thing the caller obviously wanted" unmet, and would have cost a
second breaking minor plus a reversal of the `0` spelling to correct later. Rejected:
`socket.setTimeout()`, an idle timeout that goes on arming once the link is up. Rejected: bounding
it with `responseTimeout`, which names the wait for an answer on a link that already exists and
would retune both at once. `server()` shares the checker and ignores the option, as it already
ignores `reconnect` — nothing at that end connects out.
- **A stream this library cannot frame is a dead link; one PDU it cannot parse is not.**
Maintainer's call, 2026-08-31, narrowed 2026-09-05 via the interop plan: a `command_length` below
16 or above `maxPduLength` leaves nothing that can say where the next PDU starts, so it tears the
link down through `teardown()` and the reconnect loop retries it on a fresh socket with a fresh
framer. Every other codec failure honoured `command_length`, so the stream is still in sync and
the next PDU starts where it says — tearing the link down there cost one peer half its receipts
and its MO to a reconnect loop (`interop-tests/findings/01-smscsim.md`), and left the peer waiting
for answers it was owed. `sessionError` carries every failure of either kind, never coalesced or
suppressed, so a peer that only ever sends garbage is visible in the log rather than silent.
- **A deliberate shutdown drains; an unusable link and an abort do not.** `close()` and `unbind()`
wait on the send window rather than the pending map — the map misses a segment still queued behind
a full window, and finishing a half-sent multipart message is the point. The window counts slots,
never outcomes, and empties on a drop too, where `teardown()` settles everything the link was
carrying, which is why `drain()` reads `closed` before it reads the count. A stream the framer or
the codec cannot read takes `teardown()` instead, and `close({ signal })` on an aborted signal and
a peer's own `unbind` take `end()`: nothing on a dead link can answer, an abort means stop now, and
a peer that has declared itself finished will not answer what it still owes, so draining any of the
three would only hold a socket open for the timeout. `unbind()` sends its own PDU through
`request()` past both the window and the drain gate, because it must go out either way.
`shutdownTimeout` stays a session option rather than a `close()` argument: `server()` builds
sessions on the caller's behalf, so the option is the only composition point. `SmppServer.close()`
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
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: the field that numbered it where the segment belongs to no group, the retry status
where the segment's own arrival overran the octet cap, since a peer told that still holds it. Rejected:
answering every segment but the one that completes the group, which leaves the peer holding some
segments accepted and one refused with nothing in SMPP to retract the rest, and still cannot honour
a caller's `smsId` on the segments already gone. Rejected: a hook that mints the id per segment,
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 8 does not buy a second exported class to make a count
distinguishable. Accepted: a completing segment whose own answer the socket would not carry still
reaches the application, because the message is whole and correct and the failed answer is on
`sessionError` — a peer that re-sends after the drop is the smaller risk than dropping a message
in hand. The answer goes out before the `sms` event either way, so a listener's own receipt can
never precede the acceptance of the message it reports on.
- **`server()` composes the application's `onRequest` after its own bind handling, and offers it
every request that handling did not answer.** Maintainer's call, 2026-09-06, from a product review
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 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
`OnRequest` type on both option bags, because a second contract under one name is two spellings of
one goal; widened to accept a plain boolean, as `authenticate` already is, so an observing hook need
not be `async`. Nothing of ours is written for a request whose hook failed, the same on both
surfaces: the library cannot tell one that failed before answering from one that failed after, so
goal 2 reports the outcome as undetermined rather than guessing, and the peer's own
`responseTimeout` is what settles it — the answer `authenticate` failing already takes. A hook that
throws or rejects reaches `sessionError` on the way; one that never settles reaches nothing at all,
and is visible only as the request that was never answered. That takes the keepalive with it, since
a hook broken across the board leaves `enquire_link` unanswered and the peer drops the link — the
back-pressure wanted, because an application that cannot serve a link should not hold one.
`sessionError` rather than `serverError` because the
failure belongs to one session's request, and that channel already carries every failure of one.
The hook is consulted before the bind-direction gate, so it sees a `submit_sm` a receiver-bound
peer may not send; first refusal means first, and one it declines still gets `ESME_RINVBNDSTS`.
Nothing is held for a request the hook answered: `HeldMessages` is opened by the `sms` event the
hook skipped, so the drain waits on none of it. `OnRequest` stays unexported where
`AuthenticateInput` is exported, because that hook's argument is a shape this library invents and
this one's are two types already published. Rejected: consulting the hook first, which puts
bind and authentication inside the application's reach for nothing. Rejected: a narrower hook
returning a status for the library to write, which makes the answer verifiable but pays a second
contract under a second name for it, and could not express what the session-level hook already
does — answer a bind, a vendor command, a `data_sm` — leaving that error naming something only
half the surface can do. Rejected: falling the request through to the built-in handling on a
failure, which reads as the answer the peer would have had with no hook — true only of a hook
that failed before answering, where one that failed after put a second response on the peer's own
sequence number, goal 1's wire violation. Rejected with it: recording what the hook wrote so the
fall-through could be gated on it, which buys a fail-open path with state and an internal contract
no other collaborator needs.
- **The drain waits on the messages the application holds, and `sendResp()` is what says it is done
with one.** Maintainer's call, 2026-09-01: waiting on the send window alone tore a server session
down while the application was still answering a `submit_sm`, so the peer timed out and re-sent —
the duplicate goal 2 forbids, in the direction the window already covers. No completion signal was
added to the `sms` event: `sendResp()` is what an application already calls when it is done with a
message, so it is the one the drain waits for. Counting every inbound request until `sendReturn()`
answered it was rejected: an `onRequest` that deliberately answers nothing would then cost a full
`shutdownTimeout` on every close. The response reaching the wire ends the wait, so a `sendResp()`
the library refused or the socket would not carry leaves `close()` still reporting the message the
peer is owed.
- **The drain's wait on the application ignores `shutdownTimeout: 0`.** Waiting forever is safe for
the peer, whose every request is bounded by `responseTimeout` unless the caller set that to 0 as
well, and unsafe for the application, which nothing bounds — `close()` is what you reach for when
the application is stuck, so it may not block on the application coming unstuck. That half falls
back to `responseTimeout`, the same answer 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()`
runs on every path — an idle timeout and a failed rebind, not only `close()` — so clearing the
merges there loses receipts no peer has a reason to send again. They are cleared where the session
is over instead. Inbound segments stay in `teardown()`: a concatenation reference is the
peer's own counter, so a half-arrived group kept across a drop would take a later message's
segments as readily as the rest of its own, and goal 2 will not hand the application a message
assembled that way. What goes there is traffic already answered, which is why each group reaches
`sessionError` like every other one given up on.
- **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
peer handed the same ids — an SMSC whose id counter restarts with its process is the realistic
case. `DlrMerger` remembers the bases it has finished with, capped and expiring exactly like the
groups, and refuses to open one a second time: the later message gets no `messageDlr`, and an
earlier one whose receipts are still arriving is dropped rather than left to collect the later
one's. Every segment still reaches the application as a `dlr`. `expect()` ignores a lone id, so a
single-part message never claims a base.
- **A send that never reached the socket waits for the next link; one that did is counted, not
resent.** Maintainer's call, 2026-09-01: re-queueing everything unanswered would resend a
`submit_sm` the SMSC accepted and answered into a dead socket, which is delivered and billed twice,
while a request that never left this process can be lost for free. `attempt()` therefore wraps all
three ways a written request can fail in `UnansweredError`; counting only the dropped-link case, as
the first cut did, would have called the commonest one safe to resend. A count rather than a
boolean because `sendSms()` aggregates segments into one `err` slot, and required rather than
optional so every construction site answers. `UnansweredError` stays unexported: `unanswered` is
the one spelling on the public surface. The hold is bounded by `responseTimeout` rather than an
option of its own — that is already the answer to how long one request may wait — and its clock
starts when the send is issued rather than when it first finds the gate shut, so one budget covers
every hold a single call makes. That timer is the one here that is not `unref()`'d: a held request
is awaited with the socket already destroyed, so an unref'd one lets a process whose only remaining
work is that send exit without settling it.
- **A send queued for a send-window slot is bounded by the caller's `signal`, and by nothing else.**
Maintainer's call, 2026-09-06, from a review of PR #71: the hold above observes the signal and the
`acquire()` on the next line did not, so a caller that aborted while the window was full waited for
a slot it no longer wanted — at `responseTimeout: 0` for as long as the peer stayed quiet, which is
the deadline the README sends the caller to that signal for. Goal 4 is not re-opened by an
unbounded wait here: the queue is the application's own backlog, unbounded in depth as well as in
time because capping it would refuse a send the application asked for, and nothing in it keeps the
peer waiting — which is what separates it from the inbound stores capped on constants. Rejected:
having `release()` skip a waiter whose signal already fired, which leaves the departed waiter in
the queue where `unfinished()` still counts it and the drain waits on it; the waiter leaves as it
settles instead. Rejected: bounding this wait by `responseTimeout` as the hold is bounded — a full
window is this end's own concurrency draining as the peer answers rather than a link going nowhere,
and that bound would fail a message with more segments than `maxOutstanding` partway through
against a slow peer. The failure is a plain `Error` rather than `UnansweredError`, the same answer
an abort at the gate already gives. The drain half needs nothing: `close({ signal })` already hands the signal to
`window.idle()`, and `unbind()` taking none is the shape README states.
- **The gate decides whether a link can carry a request, and a bind is what makes it one.**
Maintainer's call, 2026-09-01: `attach()` clears `closed` the moment a socket is handed over, one
round trip before the bind is answered, so gating on `closed` let a send arriving in that window go
out unbound and come back `ESME_RINVBNDSTS`. `LinkGate` owns the answer instead — `shut(returning)`
on every teardown, `open()` only once `comeBackUp()` has a bound link — and
`OutgoingRequests.linkDown()` reads it rather than `closed`. The bind itself cannot wait for what it
creates, so `pastDrain()` lets the three bind commands past the gate and the window, the same door
`unbind()` takes through `now()`. The gate is told what happened and never reads back into the
session: a collaborator that has to ask does not own its decision, which is how the first cut ended
up answering the same question two different ways at admit and at release. For the same reason the
retry in `pastDrain()` asks `gate.isUp()` rather than `linkDown()`, which also reads the socket — a
condition that loops on something the gate does not gate on spins against a gate that admits it
straight back. `LinkGate.returning` is a copy of `retrying()` taken at teardown, and stays true
only because nothing stops the reconnect loop without `emitClose()` following it: `drain()` and
`end()` are the only callers of `stop()`. A third caller has to shut the gate itself.
## Internals and tests
- **A listener that rejects is routed by Node's `captureRejections`, not by hand-dispatching.** Both
emitters construct with `captureRejections: true` and implement
`[EventEmitter.captureRejectionSymbol]`, which lands a rejected `async` listener on `sessionError`
or `serverError` beside the synchronous guard in `emit()`. Dispatching `rawListeners()` from
`emit()` instead needs a cast to call them with the event's argument tuple, which hard rule 4
forbids. A rejection reason is `unknown` and `String()` throws on a null-prototype object, so both
handlers normalise through `errorFrom()` rather than inline — a route out of the handler would land
on a bare `process.nextTick` with nothing to catch it.
- **The four-line abort dance is copied across `LinkGate`, `IdleWaiters`, `PendingRequests` and
`SendWindow` rather than extracted.** Architecture review, 2026-09-06: pre-check `aborted`, attach
`{ once: true }`, detach on settle, leave the registry. What differs at each site is the registry
and what settling means — a FIFO handing over a slot, a set released together, a map keyed by
sequence number, a count recomputed at settle — so a shared `Waiters<T>` fits two of the four and
is a shallower module than the copies. Extract it once a fifth appears.
- **`SmppLog` is a five-method contract this library declares, not a dependency.** `debug`, `error`,
`info`, `verbose` and `warn` are what the code actually calls, so an application can satisfy it
with an object literal. `@larvit/log` implements it structurally and stays a devDependency, where
`test/tls.test.ts` passing a real `Log` as the server's logger keeps that compatibility compiled.
- **The TLS tests build their own self-signed certificate in DER** (`test/tls.test.ts`) instead of
adding a devDependency or shelling out to openssl. Maintainer's call, 2026-08-26: the dev image
`node:24.18.0-bookworm-slim` ships no openssl binary, so a shelled-out fixture would pass in CI and
fail on every developer machine, and a committed key leaks in a public repository. Valid while the
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](../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.
- **`test/` stays flat too, and a file there is named for the question it answers rather than for the
module it covers.** Architecture review, 2026-09-08, at 18 test files: what keeps that count honest
is the naming rule rather than a tree — `operator-receipts.test.ts` holds a corpus defined by where
it came from, cutting across four modules, where filing it by module would enter each new operator
twice. A split also has to be made twice, since `test` and `test:compiled` each carry a path of
their own. The four files that are not tests are the exception the rule needs stated:
`dummy-smsc.ts`, `raw-pdus.ts`, `reference-smpp.d.ts` and `teardown.ts` answer no question and are
named for what they hold.
- **CI tests on Linux only; `src/` keeps off what is known to break on macOS or Windows.** Maintainer's
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 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.
+1 -1
View File
@@ -93,7 +93,7 @@ ways the next experiment must see. One fix per defect class, as its own change:
1. A worktree on a branch off `origin/main` (never `origin/v0.4.0`, the 0.4.0 code), named
for the defect.
2. Regression tests in `test/` first, naming the behaviour with the reproducer from the findings;
then the implementation; then the decision record in the root `AGENTS.md` where the fix settles
then the implementation; then the decision record in `docs/decisions.md` where the fix settles
a question of the wire or the session's life.
3. `/larv-review` on the branch, with the pull request based on `main`. When it marks the PR
ready, fast-forward it.
+2 -2
View File
@@ -1,6 +1,6 @@
# interop-tests
Eight real SMPP implementations, run against this library in both directions, with every session
Ten real SMPP implementations, run against this library in both directions, with every session
decoded independently by tshark so no result rests on our own view of the wire. It exists because
the unit suite and this library's own dummy peers agree with themselves; these peers do not.
@@ -40,7 +40,7 @@ These bind to our server:
| Peer | What it is for | Run |
| --- | --- | --- |
| **Kannel 1.4.5** | The most deployed real ESME there is; parses our receipts with the parser most operators' customers run, and declares 3.4 or 3.3 on demand | `debian:bookworm-slim` + the distribution package; four `.conf` variants under `peers/kannel/` |
| **Kannel 1.4.5** | The most deployed real ESME there is; parses our receipts with the parser most operators' customers run, and declares 3.4 or 3.3 on demand | `debian:bookworm-20260824-slim` + the distribution package; four `.conf` variants under `peers/kannel/` |
| **jsmpp** | Strict and low-level: the driver builds UDH, `sar_*` and `message_payload` bytes by hand, and rejects an answer it dislikes | Maven build at a pinned commit, `peers/jsmpp/` |
| **Cloudhopper** | The one peer with real windowing knobs, plus a TLS client | Maven build at a pinned commit, `peers/cloudhopper/`. Its 2015-era TLS client cannot do 1.3, so that scenario caps the server at 1.2 |
| **python-smpplib 2.2.4** | An independent GSM 03.38 table to cross-check ours character by character | `python:3.12.14-slim-bookworm`, `peers/python/` |
+24 -14
View File
@@ -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
+22 -23
View File
@@ -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;
+55 -53
View File
@@ -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",
+30 -2
View File
@@ -22,6 +22,7 @@ export type ClientOptions = {
addrNpi?: number;
addrTon?: number;
bindType?: BindType;
connectTimeout?: number | false;
enquireLinkInterval?: number;
host?: string;
idleTimeout?: number;
@@ -42,6 +43,7 @@ export type ClientOptions = {
const defaults = {
bindType: 'transceiver',
connectTimeout: 10_000,
enquireLinkInterval: 20_000,
host: 'localhost',
/** The idle timeout is what notices a dead link, so it has to outlast one silent probe. */
@@ -52,7 +54,30 @@ const defaults = {
username: 'user',
} as const;
function armConnectTimeout(
sock: Socket,
connectTimeout: number | false,
target: { peer: string; secure: boolean },
settle: (result: Result<{ sock: Socket }>) => void,
): NodeJS.Timeout | undefined {
if (connectTimeout === false) return undefined;
let phase = `connecting to ${target.peer}`;
if (target.secure) {
sock.once('connect', () => { phase = `completing the TLS handshake with ${target.peer}`; });
}
return setTimeout(() => {
sock.destroy();
settle({
err: new Error(`Timed out ${phase} after ${String(connectTimeout)} ms; raise connectTimeout or set it to false`),
});
}, connectTimeout).unref();
}
function openSocket(options: ClientOptions): Promise<Result<{ sock: Socket }>> {
const connectTimeout = options.connectTimeout ?? defaults.connectTimeout;
const host = options.host ?? defaults.host;
const port = options.port ?? defaults.port;
const secure = options.tls !== undefined && options.tls !== false;
@@ -77,11 +102,14 @@ function openSocket(options: ClientOptions): Promise<Result<{ sock: Socket }>> {
return;
}
const settle = (result: Result<{ sock: Socket }>): void => {
const timer = armConnectTimeout(sock, connectTimeout, { peer: `${host}:${String(port)}`, secure }, settle);
function settle(result: Result<{ sock: Socket }>): void {
clearTimeout(timer);
sock.removeListener('error', onError);
signal?.removeEventListener('abort', onAbort);
resolve(result);
};
}
function onError(err: Error): void {
settle({ err });
-2
View File
@@ -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,
+218 -58
View File
@@ -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({
@@ -82,15 +79,18 @@ const specs = tlvSpecs({
its_session_info: { id: 0x1383, tag: 'its_session_info', type: tlv.buffer },
});
export type TlvName = keyof typeof specs;
type Specs = typeof specs;
export const tlvs: Record<TlvName, TlvDefinition> & Record<string, TlvDefinition> = {
...specs,
// Alternate spellings; the definition behind each keeps its canonical name.
alert_on_msg_delivery: specs.alert_on_message_delivery,
failed_broadcast_area_identifier: specs.broadcast_area_identifier,
export type TlvName = keyof Specs;
// SMPP 5.0's other spellings, which only name the tag to key instead.
const alternates: Record<string, TlvName> = {
alert_on_msg_delivery: 'alert_on_message_delivery',
failed_broadcast_area_identifier: 'broadcast_area_identifier',
};
export const tlvs: Record<TlvName, TlvDefinition> = specs;
export const tlvsById: Record<number, TlvDefinition> = {};
for (const definition of Object.values<TlvDefinition>(specs)) {
@@ -98,67 +98,227 @@ 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;
};
type Repeated<K extends TlvName, V> = Specs[K] extends { multiple: true } ? V[] : V;
export type TlvInput = {
/** Resolved from the record key; pass it for a tag the TLV table does not define. */
tagId?: number | undefined;
tagValue: ParamValue;
};
type WireValue<K extends TlvName> = Specs[K]['type']['default'];
export function tagIdOf(name: string, input: TlvInput): Result<{ tagId: number }> {
const tagId = input.tagId ?? tlvs[name]?.id;
type ReadValue<K extends TlvName> = Repeated<K, WireValue<K>>;
if (tagId === undefined) {
return { err: new Error(`TLV "${name}": unknown tag name, give it a tagId`) };
/** A lone text field also takes a number, and an octet field text, which goes out as latin1. */
type WriteValue<K extends TlvName> = Specs[K] extends { multiple: true } ? ReadValue<K>
: WireValue<K> extends number ? number
: WireValue<K> extends string ? number | string
: Buffer | string;
type KnownTlv<K extends TlvName> = { tagId: number; tagName: K; tagValue: ReadValue<K> };
type UnknownTlv = { tagId: number; tagName: undefined; tagValue: Buffer };
/** Keyed by tag name, or by its decimal id where the table defines no name. */
export type Tlvs = { [K in TlvName]?: KnownTlv<K> } & Partial<Record<`${number}`, UnknownTlv>>;
export type Tlv = { [K in TlvName]: KnownTlv<K> }[TlvName] | UnknownTlv;
/** Keyed like `Tlvs`. */
export type TlvInputs = { [K in TlvName]?: { tagValue: WriteValue<K> } }
& Partial<Record<`${number}`, { tagValue: Buffer | string }>>;
function isTlvInput(input: unknown): input is { tagValue: TlvValue } {
if (typeof input !== 'object' || input === null || !('tagValue' in input)) return false;
const value = input.tagValue;
if (!Array.isArray(value)) return Buffer.isBuffer(value) || typeof value === 'number' || typeof value === 'string';
return value.every(one => Buffer.isBuffer(one)) || value.every(one => typeof one === 'number');
}
function keyedTagId(name: string): Result<{ tagId: number }> {
if (isTlvName(name)) return { tagId: specs[name].id };
const alternate = Object.hasOwn(alternates, name) ? alternates[name] : undefined;
if (alternate) return { err: new Error(`TLV "${name}": key it ${alternate}, the name it reads back under`) };
if (!/^(0|[1-9]\d*)$/.test(name)) {
return { err: new Error(`TLV "${name}": unknown tag name; key a tag the table does not define by its decimal id`) };
}
if (!Number.isInteger(tagId) || tagId < 0 || tagId > 0xFFFF) {
return { err: new Error(`TLV "${name}": tagId ${String(tagId)} out of range 0-65535`) };
const tagId = Number(name);
if (tagId > 0xFFFF) return { err: new Error(`TLV "${name}": tag id out of range 0-65535`) };
const known = tlvsById[tagId];
return known ? { err: new Error(`TLV "${name}": the table names this tag ${known.tag}, key it by that`) } : { tagId };
}
function entryOf(name: string, input: unknown): Result<{ tagId: number; tagValue: TlvValue }> {
if (!isTlvInput(input)) {
return { err: new Error(`TLV "${name}": give it as { tagValue }, holding a Buffer, a number, a string, or an array of Buffers or of numbers`) };
}
return { tagId };
const keyed = keyedTagId(name);
if (keyed.err) return { err: keyed.err };
if ('tagId' in input && input.tagId !== undefined && input.tagId !== keyed.tagId) {
return { err: new Error(`TLV "${name}": its tagId does not match ${String(keyed.tagId)}, the tag its key names; drop the tagId`) };
}
return { tagId: keyed.tagId, tagValue: input.tagValue };
}
/** Each TLV as its four octet header and the value the tag's own wire type writes. */
export function writeTlvs(inputs: Record<string, TlvInput> | undefined): Result<{ chunks: Buffer[] }> {
export function writeTlvs(inputs: TlvInputs | undefined): Result<{ chunks: Buffer[] }> {
const chunks: Buffer[] = [];
for (const [name, input] of Object.entries(inputs ?? {})) {
const tag = tagIdOf(name, input);
for (const [name, input] of Object.entries<unknown>(inputs ?? {})) {
const tag = entryOf(name, input);
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(tag.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 }> {
if (type === tlvDefault && typeof value === 'number') {
return { err: new Error('holds octets, which a number would write as its digits; give a Buffer or a string') };
}
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 };
// Copied, so holding a TLV pins no more than its own octets.
const value = Buffer.isBuffer(read.value) ? Buffer.from(read.value) : read.value;
return { occurrence: { definition, tagId, value }, octets: 4 + tagLength };
}
export function isTlvName(name: string): name is TlvName {
return Object.hasOwn(specs, name);
}
function readsAs(definition: TlvDefinition, value: unknown): boolean {
const kind = definition.type.default;
const fits = (one: unknown): boolean => typeof one === typeof kind && Buffer.isBuffer(one) === Buffer.isBuffer(kind);
return definition.multiple === true ? Array.isArray(value) && value.every(fits) : fits(value);
}
function isTlvShape(tlv: unknown): tlv is { tagId: number; tagName: unknown; tagValue: unknown } {
return typeof tlv === 'object' && tlv !== null && 'tagId' in tlv && typeof tlv.tagId === 'number'
&& 'tagName' in tlv && 'tagValue' in tlv;
}
function isTlv(key: string, tlv: unknown): boolean {
if (!isTlvShape(tlv)) return false;
if (tlv.tagName === undefined) return /^\d+$/.test(key) && Buffer.isBuffer(tlv.tagValue);
return tlv.tagName === key && isTlvName(key) && readsAs(specs[key], tlv.tagValue);
}
function isTlvs(record: Record<string, unknown>): record is Tlvs {
return Object.entries(record).every(([key, tlv]) => isTlv(key, tlv));
}
/** Keyed by tag name, a repeatable tag listing every occurrence in wire order and any other keeping its last. */
function keyedTlvs(occurrences: Occurrence[]): Result<{ tlvs: Tlvs }> {
const repeated = new Map<string, { tagId: number; values: Occurrence['value'][] }>();
const tlvs: Record<string, unknown> = {};
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) {
tlvs[key] = { tagId, tagName: key, tagValue: values };
}
return isTlvs(tlvs) ? { tlvs } : { err: new Error('A TLV did not read as its table type, a defect in this library') };
}
export function parseTlvs(pdu: Buffer, start: number): Result<{ offset: number; tlvs: Tlvs }> {
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;
}
const keyed = keyedTlvs(occurrences);
return keyed.err ? { err: keyed.err } : { offset, tlvs: keyed.tlvs };
}
+81 -22
View File
@@ -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,24 @@ 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;
}
/**
* 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 +44,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 +56,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 +73,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 +103,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 +150,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 +226,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 +234,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 +307,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 +330,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 +347,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 +460,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 +476,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 +564,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 +597,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 +618,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 +634,7 @@ export const tlv = {
if (rangeErr) return { err: rangeErr };
buf.write(text, offset, 'ascii');
buf.write(text, offset, 'latin1');
return {};
},
+4 -4
View File
@@ -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() ?? ''];
+4 -2
View File
@@ -9,7 +9,9 @@ export function errorFrom(reason: unknown): Error {
}
}
/** String() throws on a null-prototype object or a symbol, so only a string or number is printed. */
const printable: readonly string[] = ['boolean', 'number', 'string'];
/** String() throws on a null-prototype object, so anything but these is named by its type. */
export function namedValue(value: unknown): string {
return typeof value === 'string' || typeof value === 'number' ? String(value) : typeof value;
return printable.includes(typeof value) ? String(value) : typeof value;
}
+45 -24
View File
@@ -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();
}
+52 -3
View File
@@ -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;
}
+4 -4
View File
@@ -6,7 +6,7 @@ export { cmds, cmdsById, commandNameById, isCommandName } from './defs/commands.
export { consts, constsById } from './defs/constants.ts';
export { dataCodingByEncoding, detect, encodingByDataCoding, encodings, isEncodingName, messageClassOf, unencodable } from './defs/encodings.ts';
export { errorNameById, errors, errorsById, isErrorName } from './defs/errors.ts';
export { tlvs, tlvsById } from './defs/tlvs.ts';
export { isTlvName, tlvs, tlvsById } from './defs/tlvs.ts';
export { types } from './defs/types.ts';
export {
@@ -64,11 +64,11 @@ export type { CommandName, PduParams, PduParamsInput } from './defs/commands.ts'
export type { ConstGroup, MessageState, SubmitMessagingMode } from './defs/constants.ts';
export type { Encoding, EncodingName, Unencodable } from './defs/encodings.ts';
export type { ErrorName } from './defs/errors.ts';
export type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
export type { PduObject, PduObjectInput, TlvInputs } 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 { Tlv, TlvDefinition, TlvName, Tlvs } from './defs/tlvs.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';
+22 -66
View File
@@ -3,14 +3,14 @@ import type { ErrorName } from './defs/errors.ts';
import type { ParamValue } from './defs/types.ts';
import type { PduHeader } from './pdu-refusal.ts';
import type { Result, VoidResult } from './result.ts';
import type { Tlv, TlvInput } from './defs/tlvs.ts';
import type { TlvInputs, Tlvs } from './defs/tlvs.ts';
import { PduRefusedError, framingRefusal } from './pdu-refusal.ts';
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, 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;
@@ -23,7 +23,7 @@ export type PduObjectInput<C extends CommandName = CommandName> = {
cmdStatus?: ErrorName;
params?: PduParamsInput<C>;
seqNr?: number;
tlvs?: Record<string, TlvInput> | undefined;
tlvs?: TlvInputs | undefined;
};
/**
@@ -43,10 +43,10 @@ export type PduObject = {
* the peer put in `message_payload` is not here; `messageOctets()` is what reads either.
*/
shortMessageOctets: Buffer | undefined;
tlvs: Record<string, Tlv>;
tlvs: Tlvs;
};
export type { TlvInput };
export type { TlvInputs };
const respBit = 0x80000000;
@@ -68,30 +68,13 @@ export function isCommand<C extends CommandName>(
type ResolvedBody = {
params: Record<string, ParamValue | undefined>;
tlvs: Record<string, TlvInput> | undefined;
tlvs: TlvInputs | undefined;
};
function codingOf(params: Record<string, ParamValue | undefined>): number | undefined {
return typeof params.data_coding === 'number' ? params.data_coding : undefined;
}
type CarriedBody = { name: string; text: string; tlv: TlvInput };
/** Every entry carrying body text, under whatever names their tagIds are keyed to. */
function carriedBodies(input: Record<string, TlvInput> | undefined): CarriedBody[] {
const carried: CarriedBody[] = [];
for (const [name, tlv] of Object.entries(input ?? {})) {
const tag = tagIdOf(name, tlv);
if (!tag.err && tag.tagId === tlvs.message_payload.id && typeof tlv.tagValue === 'string') {
carried.push({ name, text: tlv.tagValue, tlv });
}
}
return carried;
}
/** messageOctets() reads short_message wherever it holds an octet, and the TLV only where it does not. */
function carriesOctets(value: ParamValue | undefined): boolean {
return Buffer.isBuffer(value) && value.length > 0;
@@ -105,19 +88,21 @@ function writtenBody(definition: CommandDefinition, value: ParamValue | undefine
/** Encoded in place, settling data_coding where `settles` says no mandatory field will carry it. */
function resolveCarried(
resolved: ResolvedBody,
inputs: Record<string, TlvInput> | undefined,
inputs: TlvInputs | undefined,
dataCoding: number | undefined,
settles: boolean,
): VoidResult {
for (const carried of carriedBodies(inputs)) {
const encoded = encodeBody(carried.text, dataCoding);
const text = inputs?.message_payload?.tagValue;
if (encoded.err) return { err: new Error(`TLV "${carried.name}": ${encoded.err.message}`) };
if (typeof text !== 'string') return {};
if (settles) resolved.params.data_coding = encoded.dataCoding;
const encoded = encodeBody(text, dataCoding);
resolved.tlvs = { ...resolved.tlvs, [carried.name]: { ...carried.tlv, tagValue: encoded.buffer } };
}
if (encoded.err) return { err: new Error(`TLV "message_payload": ${encoded.err.message}`) };
if (settles) resolved.params.data_coding = encoded.dataCoding;
resolved.tlvs = { ...resolved.tlvs, message_payload: { tagValue: encoded.buffer } };
return {};
}
@@ -125,7 +110,7 @@ function resolveCarried(
/** data_coding names the alphabet of the body, and short_message settles it where it carries octets. */
function resolveBody(
params: Record<string, ParamValue | undefined>,
tlvs: Record<string, TlvInput> | undefined,
tlvs: TlvInputs | undefined,
definition: CommandDefinition,
): Result<ResolvedBody> {
const message = writtenBody(definition, params.short_message);
@@ -197,7 +182,7 @@ function buildBody(
cmdName: CommandName,
cmdStatus: ErrorName,
params: Record<string, ParamValue | undefined>,
tlvs: Record<string, TlvInput> | undefined,
tlvs: TlvInputs | undefined,
): Result<{ body: Buffer }> {
if (errors[cmdStatus] !== 0 && definition.id >= respBit) return { body: Buffer.alloc(0) };
@@ -221,7 +206,7 @@ function buildPdu(
cmdStatus: ErrorName,
seqNr: number,
params: Record<string, ParamValue | undefined>,
tlvs: Record<string, TlvInput> | undefined,
tlvs: TlvInputs | undefined,
): Result<{ buffer: Buffer }> {
const definition = cmds[cmdName];
@@ -234,7 +219,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 +247,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,
@@ -322,7 +278,7 @@ function readOptionalParams(
pdu: Buffer,
start: number,
afterShortMessage: boolean,
): Result<{ tlvs: Record<string, Tlv> }> {
): Result<{ tlvs: Tlvs }> {
const plain = parseTlvs(pdu, start);
if (!plain.err && plain.offset === pdu.length) return { tlvs: plain.tlvs };
@@ -447,7 +403,7 @@ export function pduReturn(
pdu: Buffer | PduObject,
status: ErrorName = 'ESME_ROK',
params: Record<string, ParamValue> = {},
tlvs?: Record<string, TlvInput>,
tlvs?: TlvInputs,
): Result<{ buffer: Buffer }> {
if (Buffer.isBuffer(pdu)) {
const parsed = pduToObj(pdu);
+3 -48
View File
@@ -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;
+49
View File
@@ -0,0 +1,49 @@
import type { ParamValue } from './defs/types.ts';
import type { PduObject } from './pdu.ts';
import { 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> = {};
for (const [name, value] of Object.entries(pduObj.params)) {
params[name] = Buffer.isBuffer(value) ? Buffer.from(value) : value;
}
// 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 };
}
// 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)) {
if (tlv === undefined) continue;
const listed = Array.isArray(tlv.tagValue) ? tlv.tagValue.length : 0;
octets += tlvOctets(tlv.tagValue) + (1 + listed) * tlvObjectOverhead;
}
return octets;
}
+17 -3
View File
@@ -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, {
+4 -3
View File
@@ -1,5 +1,5 @@
import type { CloseOptions, OnRequest } from './session-options.ts';
import type { PduObject, TlvInput } from './pdu.ts';
import type { PduObject, TlvInputs } from './pdu.ts';
import type { Result, VoidResult } from './result.ts';
import type { Server as NetServer, Socket } from 'node:net';
import type { Server as TlsServer, TlsOptions } from 'node:tls';
@@ -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;
@@ -161,7 +162,7 @@ async function authenticate(
}
/** An ESME reads a missing sc_interface_version as this SMSC having none. */
function bindRespTlvs(session: Session, options: ServerOptions): Record<string, TlvInput> | undefined {
function bindRespTlvs(session: Session, options: ServerOptions): TlvInputs | undefined {
if (!session.acceptsOptionalParams()) return undefined;
return {
@@ -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');
+39 -8
View File
@@ -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,
@@ -144,14 +146,11 @@ export function checkSessionOptions(options: CheckableOptions): VoidResult {
return { err: new Error('fromStart is part of the reconnect policy, spell it reconnect: { fromStart: true }') };
}
const checked = checkLimits([
['idleTimeout', options.idleTimeout ?? 0, 0],
['maxOutstanding', options.maxOutstanding ?? defaults.maxOutstanding, 1],
['maxReassembly', options.maxReassembly ?? defaults.maxReassembly, 1],
['reassemblyTimeout', options.reassemblyTimeout ?? defaults.reassemblyTimeout, 0],
['responseTimeout', options.responseTimeout ?? defaults.responseTimeout, 0],
['shutdownTimeout', options.shutdownTimeout ?? defaults.shutdownTimeout, 0],
]);
const connect = checkConnectTimeout(options.connectTimeout);
if (connect.err) return connect;
const checked = checkLimits(limitsOf(options));
if (checked.err) return checked;
@@ -160,6 +159,36 @@ export function checkSessionOptions(options: CheckableOptions): VoidResult {
return backoff.err ? backoff : checkSmsIdFormat(options.smsIdFormat);
}
function limitsOf(options: CheckableOptions): [string, number, number][] {
return [
['idleTimeout', options.idleTimeout ?? 0, 0],
['maxOctets', options.maxOctets ?? defaultMaxOctets, 1],
['maxOutstanding', options.maxOutstanding ?? defaults.maxOutstanding, 1],
['maxReassembly', options.maxReassembly ?? defaults.maxReassembly, 1],
['reassemblyTimeout', options.reassemblyTimeout ?? defaults.reassemblyTimeout, 0],
['responseTimeout', options.responseTimeout ?? defaults.responseTimeout, 0],
['shutdownTimeout', options.shutdownTimeout ?? defaults.shutdownTimeout, 0],
];
}
const maxTimerDelay = 2_147_483_647;
function checkConnectTimeout(connectTimeout: unknown): VoidResult {
if (connectTimeout === undefined || connectTimeout === false) return {};
const got = typeof connectTimeout === 'string' ? `"${connectTimeout}"` : namedValue(connectTimeout);
if (typeof connectTimeout !== 'number' || !Number.isInteger(connectTimeout) || connectTimeout < 1) {
return { err: new Error(`connectTimeout must be a whole number of milliseconds, 1 or more, got ${got}; false waits the OS out instead`) };
}
if (connectTimeout > maxTimerDelay) {
return { err: new Error(`connectTimeout must be ${String(maxTimerDelay)} ms or less (about 24 days), got ${got}; false waits the OS out instead`) };
}
return {};
}
function checkLimits(limits: [string, number, number][]): VoidResult {
for (const [name, value, min] of limits) {
if (!Number.isInteger(value) || value < min) {
@@ -238,9 +267,11 @@ function checkSmsIdFormat(smsIdFormat: unknown): VoidResult {
/** What the checker reads, as it arrives: a caller without types can put anything in it. */
export type CheckableOptions = {
connectTimeout?: unknown;
/** 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;
+2 -2
View File
@@ -1,7 +1,7 @@
import type { ErrorName } from './defs/errors.ts';
import type { MessageDlr } from './dlr-merger.ts';
import type { ParamValue } from './defs/types.ts';
import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
import type { PduObject, PduObjectInput, TlvInputs } from './pdu.ts';
import type { PduRefusedError } from './pdu-refusal.ts';
import type { BindType, CloseOptions, LinkEnd, ReconnectOptions, SendOptions, SessionEvents, SessionOptions } from './session-options.ts';
import type { Result, VoidResult } from './result.ts';
@@ -175,7 +175,7 @@ export class Session extends EventEmitter<SessionEvents> {
pdu: PduObject,
status: ErrorName = 'ESME_ROK',
params: Record<string, ParamValue> = {},
tlvs?: Record<string, TlvInput>,
tlvs?: TlvInputs,
): Promise<VoidResult> {
return Promise.resolve(
this.answer(pduReturn(pdu, status, params, tlvs), pdu.cmdName, pdu.seqNr),
+2 -2
View File
@@ -1,6 +1,6 @@
import type { ErrorName } from './defs/errors.ts';
import type { MessageState } from './defs/constants.ts';
import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
import type { PduObject, PduObjectInput, TlvInputs } from './pdu.ts';
import type { Result, VoidResult } from './result.ts';
import type { Session } from './session.ts';
import { UnansweredError } from './unanswered-error.ts';
@@ -179,7 +179,7 @@ function receiptText(sms: Sms, smsId: string, status: MessageState): string {
].join(' ');
}
function receiptTlvs(smsId: string, status: MessageState): Record<string, TlvInput> {
function receiptTlvs(smsId: string, status: MessageState): TlvInputs {
return {
message_state: { tagValue: consts.MESSAGE_STATE[status] },
receipted_message_id: { tagValue: smsId },
+2 -2
View File
@@ -4,7 +4,7 @@ import { consts } from '../src/defs/constants.ts';
import { dlrFromPdu, parseReceipt, receiptCodes } from '../src/dlr.ts';
import { encodeMessage } from '../src/message.ts';
import { objToPdu, pduToObj } from '../src/pdu.ts';
import type { PduObject, TlvInput } from '../src/pdu.ts';
import type { PduObject, TlvInputs } from '../src/pdu.ts';
const receiptText = 'id:0195f0c7 sub:001 dlvrd:001 submit date:2508251430 done date:2508251431 stat:DELIVRD err:000 text:hello there';
@@ -14,7 +14,7 @@ const textReceipt = `id:${textReceiptId} sub:001 dlvrd:001 submit date:250905143
function deliverSm(
message: Buffer | string,
tlvs?: Record<string, TlvInput>,
tlvs?: TlvInputs,
esmClass: number = consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
dataCoding = 0,
): PduObject {
+2 -2
View File
@@ -95,8 +95,8 @@ describe('our encoder against the reference parser', () => {
},
seqNr: 77,
tlvs: {
message_state: { tagId: 0x0427, tagValue: 2 },
receipted_message_id: { tagId: 0x001E, tagValue: 'abc123' },
message_state: { tagValue: 2 },
receipted_message_id: { tagValue: 'abc123' },
},
}));
+3 -3
View File
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import test, { describe } from 'node:test';
import type { Dlr, Receipt } from '../src/dlr.ts';
import type { MessageDlr } from '../src/session.ts';
import type { PduObject, TlvInput } from '../src/pdu.ts';
import type { PduObject, TlvInputs } from '../src/pdu.ts';
import { bindToSmsc, dummySmsc } from './dummy-smsc.ts';
import { consts } from '../src/defs/constants.ts';
import { dlrFromPdu, parseReceipt, receiptCodes, transientStates } from '../src/dlr.ts';
@@ -16,7 +16,7 @@ import { objToPdu, pduToObj } from '../src/pdu.ts';
function deliverSm(
body: string,
tlvs?: Record<string, TlvInput>,
tlvs?: TlvInputs,
esmClass: number = consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
): PduObject {
const { buffer } = objToPdu({
@@ -53,7 +53,7 @@ type ReceiptFixture = {
name: string;
receipt: Receipt;
source: string;
tlvs?: Record<string, TlvInput>;
tlvs?: TlvInputs;
};
const fixtures: readonly ReceiptFixture[] = [
+180 -25
View File
@@ -3,6 +3,7 @@ import test, { describe } from 'node:test';
import { PduRefusedError, refusalAnswer } from '../src/pdu-refusal.ts';
import { isCommand, isResp, objToPdu, pduReturn, pduToObj } from '../src/pdu.ts';
import { paramText } from '../src/defs/types.ts';
import { isTlvName, tlvsById } from '../src/defs/tlvs.ts';
function encode(...args: Parameters<typeof objToPdu>): Buffer {
const { buffer, err } = objToPdu(...args);
@@ -51,6 +52,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 +133,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', () => {
@@ -342,7 +378,7 @@ describe('TLVs', () => {
},
seqNr: 393,
tlvs: {
5142: { tagId: 5142, tagValue: Buffer.from('blajfoo', 'ascii') },
5142: { tagValue: Buffer.from('blajfoo', 'ascii') },
receipted_message_id: { tagValue: '293f293' },
},
}));
@@ -356,7 +392,7 @@ describe('TLVs', () => {
assert.deepEqual(unknown.tagValue, Buffer.from('blajfoo', 'ascii'));
});
test('keeps a binary TLV byte for byte through pduToObj and back', () => {
test('keeps a binary TLV byte for byte through pduToObj and back, copied off the chunk it arrived in', () => {
const payload = Buffer.from('deadbeef00ff', 'hex');
const params = {
destination_addr: '46709771337',
@@ -364,16 +400,21 @@ describe('TLVs', () => {
short_message: 'binary payload follows',
source_addr: '46701113311',
};
const parsed = decode(encode({
const pdu = encode({
cmdName: 'deliver_sm',
params,
seqNr: 7,
tlvs: { message_payload: { tagValue: payload } },
}));
const carried = parsed.tlvs.message_payload;
});
const chunk = Buffer.alloc(64 * 1024);
pdu.copy(chunk, 100);
const carried = decode(chunk.subarray(100, 100 + pdu.length)).tlvs.message_payload;
assert.ok(carried);
assert.deepEqual(carried.tagValue, payload);
assert.notEqual(carried.tagValue.buffer, chunk.buffer, 'a TLV holds its own octets, not the chunk it arrived in');
const rebuilt = decode(encode({
cmdName: 'deliver_sm',
@@ -397,25 +438,27 @@ describe('TLVs', () => {
assert.deepEqual(pduObj.tlvs.source_port, { tagId: 0x020A, tagName: 'source_port', tagValue: 1234 });
});
test('refuses an unknown tag name rather than putting a wrong tag on the wire', () => {
const { buffer, err } = objToPdu({
cmdName: 'deliver_sm',
params: { destination_addr: '46709771337', short_message: 'hi', source_addr: '46701113311' },
tlvs: { nils: { tagValue: 'blajfoo' } },
});
test('refuses a TLV keyed any way but by its name, or by its decimal id where the table names none', () => {
const params = { destination_addr: '46709771337', short_message: 'hi', source_addr: '46701113311' };
const refusals = [
// @ts-expect-error nils is no tag name
{ built: objToPdu({ cmdName: 'deliver_sm', params, tlvs: { nils: { tagValue: 'blajfoo' } } }), reason: /decimal id/ },
{ built: objToPdu({ cmdName: 'deliver_sm', params, tlvs: { 5142: { tagId: 5143, tagValue: 'blajfoo' } } }), reason: /does not match 5142/ },
// @ts-expect-error the key names the tag
{ built: objToPdu({ cmdName: 'deliver_sm', params, tlvs: { message_state: { tagId: 5, tagValue: 2 } } }), reason: /does not match 1063/ },
{ built: objToPdu({ cmdName: 'deliver_sm', params, tlvs: { 5142: { tagValue: 300 } } }), reason: /Buffer/ },
{ built: objToPdu({ cmdName: 'deliver_sm', params, tlvs: { 65536: { tagValue: 'blajfoo' } } }), reason: /out of range/ },
{ built: objToPdu({ cmdName: 'deliver_sm', params, tlvs: { '05142': { tagValue: 'blajfoo' } } }), reason: /decimal id/ },
{ built: objToPdu({ cmdName: 'deliver_sm', params, tlvs: { 1063: { tagValue: 2 } } }), reason: /message_state/ },
// @ts-expect-error the value goes in a { tagValue } wrapper
{ built: objToPdu({ cmdName: 'deliver_sm', params, tlvs: { message_state: 2 } }), reason: /give it as \{ tagValue \}/ },
];
assert.equal(buffer, undefined);
assert.ok(err instanceof Error);
});
test('refuses a tag id that does not fit the two octet field', () => {
const { err } = objToPdu({
cmdName: 'deliver_sm',
params: { destination_addr: '46709771337', short_message: 'hi', source_addr: '46701113311' },
tlvs: { nils: { tagId: 0x10000, tagValue: 'blajfoo' } },
});
assert.ok(err instanceof Error);
for (const { built: { buffer, err }, reason } of refusals) {
assert.equal(buffer, undefined);
assert.ok(err instanceof Error);
assert.match(err.message, reason);
}
});
test('refuses a TLV too long for the two octet length field', () => {
@@ -428,6 +471,118 @@ 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] },
},
}));
const firstRead: Buffer | undefined = pduObj.tlvs.callback_num?.tagValue[0];
const presentation: number | undefined = pduObj.tlvs.callback_num_pres_ind?.tagValue[0];
assert.deepEqual([firstRead, presentation], [first, 1]);
assert.deepEqual(pduObj.tlvs.callback_num?.tagValue, [first, second]);
assert.deepEqual(pduObj.tlvs.callback_num_pres_ind?.tagValue, [1]);
});
test('writes and reads the failed areas of a broadcast_sm_resp as broadcast_area_identifier', () => {
const areas = [Buffer.from('0001', 'hex'), Buffer.from('0002', 'hex')];
const params = { message_id: '01a0d051-b588-76eb-a5c5-a8cb8b854e68' };
const pduObj = decode(encode({ cmdName: 'broadcast_sm_resp', params, tlvs: { broadcast_area_identifier: { tagValue: areas } } }));
assert.deepEqual(pduObj.tlvs.broadcast_area_identifier?.tagValue, areas);
// @ts-expect-error the alternate spelling is read back under the name, so only the name is written
const { err } = objToPdu({ cmdName: 'broadcast_sm_resp', params, tlvs: { failed_broadcast_area_identifier: { tagValue: areas } } });
assert.match(err?.message ?? '', /key it broadcast_area_identifier/);
assert.deepEqual(
['broadcast_area_identifier', 'failed_broadcast_area_identifier', 'constructor'].map(isTlvName),
[true, false, false],
);
});
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 { buffer, err } of [
// @ts-expect-error callback_num repeats, so it takes an array
objToPdu({ cmdName: 'submit_sm', params, tlvs: { callback_num: { tagValue: Buffer.from('01', 'hex') } } }),
objToPdu({ cmdName: 'submit_sm', params, tlvs: { callback_num: { tagValue: [] } } }),
// @ts-expect-error source_port does not repeat
objToPdu({ cmdName: 'submit_sm', params, tlvs: { source_port: { tagValue: [1234, 1235] } } }),
]) {
assert.equal(buffer, undefined);
assert.ok(err instanceof Error);
}
});
test('reads every tag in the table as the type its entry declares', () => {
const bare = encode({ cmdName: 'deliver_sm', params: { destination_addr: '46709771337', source_addr: '46701113311' } });
for (const { id, tag, type } of Object.values(tlvsById)) {
const sized = type.size(type.default);
assert.ok(sized.size !== undefined);
const tlv = Buffer.alloc(4 + sized.size);
tlv.writeUInt16BE(id, 0);
tlv.writeUInt16BE(sized.size, 2);
assert.equal(type.write(type.default, tlv, 4).err, undefined);
const pdu = Buffer.concat([bare, tlv]);
pdu.writeUInt32BE(pdu.length, 0);
assert.ok(Object.hasOwn(decode(pdu).tlvs, tag), tag);
}
});
test('relays a parsed PDU\'s TLVs back out as they arrived', () => {
const params = { destination_addr: '46709771337', short_message: 'hi', source_addr: '46701113311' };
const parsed = decode(encode({
cmdName: 'deliver_sm',
params,
tlvs: {
5142: { tagValue: Buffer.from('01', 'hex') },
callback_num: { tagValue: [Buffer.from('0146709771337', 'hex')] },
receipted_message_id: { tagValue: '0199d8a4-5e2c-7b3f-9a61-c4e07f2d8b15' },
},
}));
assert.deepEqual(decode(encode({ cmdName: 'deliver_sm', params, tlvs: parsed.tlvs })).tlvs, parsed.tlvs);
// @ts-expect-error the key names the tag, and an undefined tagId names none
assert.equal(objToPdu({ cmdName: 'deliver_sm', params, tlvs: { message_state: { tagId: undefined, tagValue: 2 } } }).err, undefined);
});
test('types each known TLV by its tag, and an unknown one as octets', () => {
const pduObj = decode(encode({
cmdName: 'deliver_sm',
params: { destination_addr: '46709771337', short_message: 'hi', source_addr: '46701113311' },
tlvs: {
5142: { tagValue: Buffer.from('01', 'hex') },
message_state: { tagValue: 2 },
receipted_message_id: { tagValue: '0199d8a4-5e2c-7b3f-9a61-c4e07f2d8b15' },
},
}));
const id: string | undefined = pduObj.tlvs.receipted_message_id?.tagValue;
const state: number | undefined = pduObj.tlvs.message_state?.tagValue;
const unknown: Buffer | undefined = pduObj.tlvs['5142']?.tagValue;
assert.deepEqual([id, state, unknown], ['0199d8a4-5e2c-7b3f-9a61-c4e07f2d8b15', 2, Buffer.from('01', 'hex')]);
// @ts-expect-error an octet field takes no number, which would go out as its digits
objToPdu({ cmdName: 'deliver_sm', params: {}, tlvs: { network_error_code: { tagValue: 5 } } });
// @ts-expect-error message_state is an integer
assert.ok(objToPdu({ cmdName: 'deliver_sm', params: {}, tlvs: { message_state: { tagValue: 'ENROUTE' } } }).err);
});
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({
@@ -440,8 +595,8 @@ describe('TLVs', () => {
},
seqNr: 323,
tlvs: {
message_state: { tagId: 1063, tagValue: 2 },
receipted_message_id: { tagId: 30, tagValue: 450 },
message_state: { tagValue: 2 },
receipted_message_id: { tagValue: 450 },
},
}));
+299 -43
View File
@@ -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';
@@ -84,6 +84,21 @@ function within<T>(ms: number, promise: Promise<T>): Promise<T | undefined> {
return Promise.race([promise, delay(ms).then((): undefined => undefined)]);
}
/** A client still retrying holds a socket and a timer nothing else releases. */
function abortAfter(
t: TestContext,
controller: AbortController,
connecting: ReturnType<typeof client>,
): void {
t.after(async () => {
controller.abort();
const { session } = await connecting;
await session?.close({ signal: AbortSignal.abort() });
});
}
function submitPdu(seqNr: number, cmdStatus: ErrorName = 'ESME_ROK'): PduObject {
return {
cmdId: 0x00000004,
@@ -798,21 +813,6 @@ describe('reconnect from the first bind', () => {
return port;
}
/** A client still retrying holds a socket and a timer nothing else releases. */
function abortAfter(
t: TestContext,
controller: AbortController,
connecting: ReturnType<typeof client>,
): void {
t.after(async () => {
controller.abort();
const { session } = await connecting;
await session?.close({ signal: AbortSignal.abort() });
});
}
test('gives up on the first attempt where reconnect alone is asked for', async () => {
const port = await closedPort();
const spy = logSpy();
@@ -1018,6 +1018,121 @@ describe('reconnect from the first bind', () => {
});
});
describe('connectTimeout', () => {
/** Accepts and then says nothing, so a TLS handshake started on it never completes. */
async function stalledListener(t: TestContext): Promise<{ accepted: net.Socket[]; port: number }> {
const accepted: net.Socket[] = [];
const listener = net.createServer(sock => {
accepted.push(sock);
sock.resume();
});
closeListenerAfter(t, listener, accepted);
await new Promise<void>(resolve => { listener.listen(0, '127.0.0.1', resolve); });
const address = listener.address();
return { accepted, port: typeof address === 'object' && address !== null ? address.port : 0 };
}
test('gives up on a connect the peer never completes', async t => {
const { port } = await stalledListener(t);
const settled = await within(2000, client({
connectTimeout: 150,
host: '127.0.0.1',
port,
reconnect: false,
tls: true,
}));
assert.ok(settled, 'a handshake nothing answers is what the OS wait would swallow for minutes');
assert.ok(settled.err instanceof Error);
assert.match(
settled.err.message,
new RegExp(`Timed out completing the TLS handshake with 127\\.0\\.0\\.1:${String(port)} after 150 ms; raise connectTimeout`),
'a firewall and a peer that accepts then stalls need different answers, and whoever reads this has never heard of the option',
);
assert.equal(settled.session, undefined);
});
// Waits the default out for real: node:test mock timers land in Node 20.4, and the floor is 18.
test('bounds a connect nobody asked to bound, at the default ten seconds', async t => {
const { port } = await stalledListener(t);
const connecting = client({ host: '127.0.0.1', port, reconnect: false, tls: true });
const settled = await within(13_000, connecting);
assert.ok(settled, 'no bound was armed, so nothing ever settled this connect');
assert.ok(settled.err instanceof Error);
assert.match(settled.err.message, /after 10000 ms/);
});
test('retries a connect it timed out on, like any other failed attempt', async t => {
const { accepted, port } = await stalledListener(t);
const controller = new AbortController();
const connecting = client({
connectTimeout: 60,
host: '127.0.0.1',
port,
reconnect: { fromStart: true, maxDelay: 40, minDelay: 10 },
signal: controller.signal,
tls: true,
});
abortAfter(t, controller, connecting);
await delay(400);
assert.ok(accepted.length >= 3, `the loop retried what timed out, got ${String(accepted.length)} attempts`);
});
test('disarms on the connect that completed, rather than on the socket that follows it', async t => {
const smpp = await startServer(t);
const { session } = await connect(t, smpp, { connectTimeout: 200 });
assert.ok(session);
await delay(300);
const probe = await session.send({ cmdName: 'enquire_link' });
assert.equal(probe.err, undefined);
assert.equal(session.sock.destroyed, false);
});
test('refuses a connect timeout that would turn itself off', async () => {
assert.match(
checkSessionOptions({ connectTimeout: 0 }).err?.message ?? '',
/false waits the OS out/,
'off is spelled false, so 0 may not stand in for it',
);
assert.equal(checkSessionOptions({ connectTimeout: false }).err, undefined);
assert.match(checkSessionOptions({ connectTimeout: -1 }).err?.message ?? '', /connectTimeout/);
assert.match(checkSessionOptions({ connectTimeout: 1.5 }).err?.message ?? '', /whole number/);
assert.match(
checkSessionOptions({ connectTimeout: '5000' }).err?.message ?? '',
/got "5000"/,
'an env var read without Number() is the commonest untyped value, and it is a correct number',
);
assert.match(checkSessionOptions({ connectTimeout: true }).err?.message ?? '', /got true/);
assert.match(
checkSessionOptions({ connectTimeout: 2_147_483_648 }).err?.message ?? '',
/2147483647 ms or less \(about 24 days\), got 2147483648; false waits the OS out instead/,
'a delay Node cannot hold in 32 bits fires after 1 ms, the inverse of what it asked for',
);
assert.equal(checkSessionOptions({ connectTimeout: 2_147_483_647 }).err, undefined);
assert.equal(checkSessionOptions({ connectTimeout: 1000 }).err, undefined);
const refused = await client({ connectTimeout: 0, port: 1 });
assert.ok(refused.err instanceof Error);
assert.match(refused.err.message, /false waits/, 'the socket may not be opened before the option is refused');
assert.equal(refused.session, undefined);
const off = await client({ connectTimeout: false, port: 1 });
assert.ok(off.err instanceof Error);
assert.match(off.err.message, /ECONNREFUSED/, 'false opts out of the bound without breaking the connect');
});
});
describe('sends across a reconnect', () => {
/** Answers every message after the first, which is left to hold the send window open. */
function answerAfterTheFirst(smpp: SmppServer, arrived: string[]): Latch {
@@ -1367,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;
@@ -1404,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));
@@ -1588,7 +1808,7 @@ describe('where a segment says it is concatenated', () => {
});
test('reads no concatenation where a sar_* TLV is missing', () => {
const lone = {
const lone: PduObject = {
...sarSegment(5, 1, 2),
tlvs: { sar_msg_ref_num: { tagId: 0x020c, tagName: 'sar_msg_ref_num', tagValue: 5 } },
};
@@ -1690,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({
@@ -1705,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.
@@ -1715,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,
@@ -1780,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.
@@ -1898,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,
@@ -1982,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.
@@ -2001,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 -4
View File
@@ -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 => {
@@ -1493,7 +1513,6 @@ describe('robustness', () => {
// 0.4.0 registered a listener per sequence number and waited forever, leaking one per call.
test('gives up on a peer that never answers', async t => {
const accepted: net.Socket[] = [];
// resume() so the socket drains; an unread socket never notices the peer hanging up.
const silent = net.createServer(sock => { accepted.push(sock); sock.resume(); });
await new Promise<void>(resolve => silent.listen(0, resolve));
@@ -1508,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();
@@ -2646,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
View File
@@ -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', () => {
+30 -22
View File
@@ -173,18 +173,17 @@ describe('a body the PDU\'s own data_coding cannot carry', () => {
assert.match(built.err.message, /U\+3042/);
});
test('refuses the body TLV under whatever name the caller keyed its tagId to', () => {
test('refuses the body TLV keyed by its id, so only message_payload is written as text', () => {
const built = objToPdu({
cmdName: 'data_sm',
params: { data_coding: 0x03, destination_addr: to, source_addr: from },
tlvs: { body: { tagId: 0x0424, tagValue: 'あいう' } },
params: { data_coding: 0x08, destination_addr: to, source_addr: from },
tlvs: { 1060: { tagValue: 'あいう' }, message_payload: { tagValue: 'あいう' } },
});
assert.ok(built.err instanceof Error);
assert.equal(built.buffer, undefined);
assert.match(built.err.message, /"body"/);
assert.match(built.err.message, /LATIN1/);
assert.match(built.err.message, /U\+3042/);
assert.match(built.err.message, /"1060"/);
assert.match(built.err.message, /message_payload/);
});
test('leaves data_coding to short_message wherever it carries octets, as messageOctets() reads it', () => {
@@ -205,22 +204,6 @@ describe('a body the PDU\'s own data_coding cannot carry', () => {
assert.deepEqual(messageOctets(pduObj), short);
});
test('encodes every entry carrying the body tag, so a second one cannot go out truncated', () => {
const built = objToPdu({
cmdName: 'data_sm',
params: { data_coding: 0x08, destination_addr: to, source_addr: from },
tlvs: { alias: { tagId: 0x0424, tagValue: 'あいう' }, message_payload: { tagValue: 'あいう' } },
});
assert.equal(built.err, undefined);
assert.ok(built.buffer);
const hex = built.buffer.toString('hex');
assert.equal(hex.split('304230443046').length - 1, 2, 'both entries carry the UCS2 octets');
assert.ok(!hex.includes('424446'), 'no entry goes out as the low octets of its code points');
});
test('leaves the alphabet to the body TLV wherever short_message carries no octets', () => {
for (const short of [undefined, '', Buffer.alloc(0)]) {
const built = objToPdu({
@@ -345,6 +328,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');
+495 -51
View File
@@ -1,7 +1,7 @@
# todo.md
Remaining work for `@larvit/smpp`. Read [AGENTS.md](AGENTS.md) first — the goals and hard rules
there constrain every item below.
Remaining work for `@larvit/smpp`. Read [README.md](README.md)'s goals and [AGENTS.md](AGENTS.md)'s
hard rules first — they constrain every item below.
This is a working file that sets its own rules. The documentation conventions in AGENTS.md do not
govern it, and nothing here is a source anything else may cite.
@@ -9,7 +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. What is left is release work and a few things worth adding before or after 0.5.0.
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
@@ -73,8 +75,8 @@ Every defect listed in the AGENTS.md table has a regression test naming the beha
## Move the repository to Gitea
`gitea.larvit.se/larvit/smpp-js` is the repository. `github.com/larvit/smpp-js` becomes a push mirror
of it and the place issues are filed. Maintainer's calls, 2026-09-13 and 2026-09-14.
`gitea.larvit.se/larvit/smpp-js` is the repository. `github.com/larvit/smpp-js` mirrors it and is the
place issues are filed. Maintainer's calls, 2026-09-13 and 2026-09-14.
- [x] `larvit/smpp-js` holds `main`, from `typescript`, and `v0.4.0`, from `master`. `rewrite-base`
and the `renovate/*` branches stayed behind.
@@ -97,47 +99,55 @@ of it and the place issues are filed. Maintainer's calls, 2026-09-13 and 2026-09
- [x] 0.5.0 rather than 1.0.0, while usage is this low. Maintainer's call, 2026-09-14.
- [x] `NPM_TOKEN`, which `.gitea/workflows/release.yaml` needs, is a Gitea organization secret.
- [ ] Tag `v0.5.0` on Gitea to publish. The first publish creates `@larvit/smpp` on npm, provided the
- [x] Tag `v0.5.0` on Gitea to publish. The first publish creates `@larvit/smpp` on npm, provided the
token can publish under `@larvit`.
- [ ] `npm deprecate larvitsmpp` pointing at `@larvit/smpp`. Maintainer's call to run it; not
- [x] `npm deprecate larvitsmpp` pointing at `@larvit/smpp`. Maintainer's call to run it; not
something CI should do.
## Retire the GitHub repository
Nothing here starts before 0.5.0 is published. Maintainer's call, 2026-09-14. Then in this order:
the push mirror replaces GitHub's branches with Gitea's, which closes every pull request based on
`master` without a reply, and GitHub refuses to delete the default branch it syncs over.
deleting GitHub's old branches closes every pull request based on them without a reply, and GitHub
refuses to delete its default branch.
- [ ] Close the backlog below.
- [ ] Close [#71](https://github.com/larvit/larvitsmpp/pull/71), pointing at Gitea.
- [ ] Rename `larvit/larvitsmpp` to `larvit/smpp-js`. GitHub redirects the old URLs, and the `bugs`
- [x] Close the backlog below.
- [x] Close [#71](https://github.com/larvit/larvitsmpp/pull/71), pointing at Gitea.
- [x] Rename `larvit/larvitsmpp` to `larvit/smpp-js`. GitHub redirects the old URLs, and the `bugs`
URL in `package.json` resolves from then on.
- [ ] Push `main` and make it GitHub's default branch.
- [ ] Remove Renovate and CodeRabbit from the GitHub repository.
- [ ] Add it to Gitea as a push mirror, with a GitHub credential that can write to it.
- [x] Push `main` and make it GitHub's default branch.
- [x] Renovate is Silent for this repository in the Mend Developer Portal, so it opens nothing on
GitHub while the organization-wide installation stays. CodeRabbit stays installed.
Maintainer's call, 2026-09-14.
- [x] Mirror to GitHub from `.gitea/workflows/mirror.yaml` and `mirror-delete.yaml`, with
`MIRROR_GITHUB_TOKEN`. A push of a commit carrying the workflow, and the nightly run, send all of
Gitea's branches and tags, overwriting a same-named ref; a branch or tag deleted on Gitea is
deleted there too. Refs only GitHub has stay. Maintainer's call, 2026-09-14.
- [x] GitHub's wiki, projects and Actions are off, the Travis app and webhook are gone, and its About
matches the package. Gitea carries the same description, website and topics, and sends issues
to GitHub as its external tracker.
## Close the GitHub backlog
**Answer and close as fixed by 0.5.0**, the reply naming what fixed it:
- [ ] [#2](https://github.com/larvit/larvitsmpp/issues/2) Tests for the README examples:
- [x] [#2](https://github.com/larvit/larvitsmpp/issues/2) Tests for the README examples:
`test/readme.test.ts`.
- [ ] [#3](https://github.com/larvit/larvitsmpp/issues/3) Tests for flash messages:
- [x] [#3](https://github.com/larvit/larvitsmpp/issues/3) Tests for flash messages:
`test/session.test.ts`.
- [ ] [#4](https://github.com/larvit/larvitsmpp/issues/4) DLR errors with `message_state` missing:
- [x] [#4](https://github.com/larvit/larvitsmpp/issues/4) DLR errors with `message_state` missing:
`dlrFromPdu()` parses the `stat:` receipt text when the TLVs are absent.
- [ ] [#13](https://github.com/larvit/larvitsmpp/issues/13) Limit a long SMS to fewer segments: the
- [x] [#13](https://github.com/larvit/larvitsmpp/issues/13) Limit a long SMS to fewer segments: the
`maxSegments` send option.
- [ ] [#16](https://github.com/larvit/larvitsmpp/issues/16) Support all three bind types: bound and
- [x] [#16](https://github.com/larvit/larvitsmpp/issues/16) Support all three bind types: bound and
enforced in both directions.
- [ ] [#17](https://github.com/larvit/larvitsmpp/issues/17) `addr_ton`/`addr_npi` should be
- [x] [#17](https://github.com/larvit/larvitsmpp/issues/17) `addr_ton`/`addr_npi` should be
settable: `sendSms()` takes all four, documented and tested.
- [ ] [#20](https://github.com/larvit/larvitsmpp/issues/20) Tests fail on current dependency
- [x] [#20](https://github.com/larvit/larvitsmpp/issues/20) Tests fail on current dependency
versions: the mocha suite is gone; `node:test` on Node 18 to 26.
- [ ] [#33](https://github.com/larvit/larvitsmpp/issues/33) Large inbound text arrives as raw
- [x] [#33](https://github.com/larvit/larvitsmpp/issues/33) Large inbound text arrives as raw
`Buffer` segments: `IncomingRequests` reassembles a UDH-carrying `deliver_sm` into one `sms`
event.
- [ ] [#68](https://github.com/larvit/larvitsmpp/pull/68), a pull request: `message_id` in
- [x] [#68](https://github.com/larvit/larvitsmpp/pull/68), a pull request: `message_id` in
`submit_sm_resp`, spec DLR codes. All four hold: `sendResp()` always answers a `message_id`,
per segment; `stat:UNDELIV` is the 7-character code. Credit the reporter — the fork found real
defects.
@@ -145,7 +155,7 @@ the push mirror replaces GitHub's branches with Gitea's, which closes every pull
**Close as superseded**, all against 0.4.0 dependencies the rewrite does not have — `async`,
`coveralls`, `eslint`, `iconv-lite`, `larvitutils`, `mocha`, `mocha-eslint`, `portfinder`, `uuid`:
- [ ] [#40](https://github.com/larvit/larvitsmpp/pull/40),
- [x] [#40](https://github.com/larvit/larvitsmpp/pull/40),
[#41](https://github.com/larvit/larvitsmpp/pull/41),
[#42](https://github.com/larvit/larvitsmpp/pull/42),
[#45](https://github.com/larvit/larvitsmpp/pull/45),
@@ -160,15 +170,278 @@ the push mirror replaces GitHub's branches with Gitea's, which closes every pull
[#70](https://github.com/larvit/larvitsmpp/pull/70) is the open `uuid` advisory GitHub reports
on the default branch; it disappears with the runtime dependencies rather than being fixed.
[#60](https://github.com/larvit/larvitsmpp/issues/60) is Renovate's dashboard — close it with
Renovate's removal from GitHub.
[#60](https://github.com/larvit/larvitsmpp/issues/60) is Renovate's dashboard and stays open after
the rewrite, for a dependency added later. Maintainer's call, 2026-09-14.
**Leave open:** [#8](https://github.com/larvit/larvitsmpp/issues/8), the socket's remote host and
port on log messages. Only `server - incoming connection` carries them today; putting them on every
session message is a change to every call site.
**Close as tracked here**, the reply saying it will be implemented on Gitea:
- [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
- [ ] **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.
- [ ] **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
- [ ] **Move the decisions out of AGENTS.md's fixtures paragraph and the two tooling READMEs.** The
one dummy SMSC, `smscPeer()` staying separate and which copied helpers are tolerated
(AGENTS.md Conventions), and why Kannel is absent from `benchmarks/README.md`, go to
`docs/decisions.md` with index lines. Move the planned work written into
`interop-tests/AGENTS.md` (an expected count per peer) and `benchmarks/README.md` (the default
window gap) to this file. Give the `run.py`-in-background footgun in `interop-tests/AGENTS.md`
a rule of its own. Delete AGENTS.md's "message_id values … are UUID v7" line. From the prose
pass of #30.
- [ ] **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,
and the `net.Server.close()` sentence restates `closeListenerAfter`'s. Keep the registered-at-
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
of what it asked for, explained only by a warning on stderr. `connectTimeout` refuses one
already, which is the asymmetry to close. The same four print an untyped value bare, so
`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, 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
that can never go out does not queue for what it will never use; a body `objToPdu()` refuses on
@@ -201,12 +474,6 @@ session message is a change to every call site.
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.
@@ -225,6 +492,13 @@ session message is a change to every call site.
`tls.test.ts` wait forever, so an event that never fires still hangs the run the way an
unclosed listener used to. One shared, guarded copy closes the rest of that class.
- [ ] **The peer's address and bind on every session log message.** `remoteAddress` and `remotePort`
reach only `server - incoming connection`, and `systemId` only the bind messages, so with
several peers connected one session's lines cannot be told apart, and a reconnect leaves nothing
stable to filter on. Carrying them in every session message's metadata is a change to every
call site. From [#8](https://github.com/larvit/larvitsmpp/issues/8), closed there as tracked
here; maintainer's call, 2026-09-14.
- [ ] **A peer whose message ids share one base logs a refused merge on every send.** `smsc01-000123`
and `smsc01-000124` carry the same base, so `DlrMerger` merges the first message and refuses
every one after it, one log line per send. Left at `info` — nothing the operator can fix is
@@ -234,9 +508,6 @@ session message is a change to every call site.
end to end. The interop suite is the natural place.
- [ ] **Move to TypeScript 7** once `typescript-eslint` supports it; `renovate.json` pins TypeScript
below 6.1 for exactly that reason.
- [ ] **Coverage reporting.** `node --test --experimental-test-coverage` works today; nothing
publishes the numbers.
- [ ] **An `onReceipt` hook.** Receipt text is only loosely specified and operators disagree on it,
but `dlrFromPdu()` is wired into `IncomingRequests` with no seam of its own: an application
facing a format we do not parse has to take the whole PDU on `onRequest` and reimplement the
@@ -244,18 +515,191 @@ session message is a change to every call site.
Mirror the `onRequest` seam — return a `Dlr` to own the receipt, `undefined` to fall through
to the built-in parser.
## Gaps against other SMPP libraries
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 7: an option or a hook, with the call that passes none unchanged.
### Sending
- [ ] **A limiter hook, with a messages-per-second cap built on it.** Maintainer's call, 2026-09-14,
reversing the earlier decline: Kannel, Jasmin, go-smpp and `smpp-js-sdk` all limit throughput.
Count PDUs, not `sendSms()` calls — a long message is one `submit_sm` per segment and the
operator counts those, which an application wrapping `sendSms()` cannot see. The hook takes a
limiter the application already runs; the built-in cap counts per session until the store at the
bottom lets it span sessions and processes. The default stays uncapped. Open: the hook's shape (a
wait that resolves when a PDU may go, cut short by the send's `signal`), which requests it gates
— messages, never `enquire_link`, `unbind` or a response — and whether its wait counts against
`responseTimeout`.
- [ ] **Back off and resend on `ESME_RTHROTTLED`.** The SMSC refused the PDU, so resending cannot
duplicate it and goal 2 holds, and the retry needs nothing wider than the session. Needs a
decision: on by default with a bounded budget, as goal 5 suggests, and whether `ESME_RMSGQFUL`
counts too. A throttled answer is also what a limiter hook wants to hear about.
- [ ] **`sendSms()` takes the rest of `submit_sm`.** `service_type`, `priority_flag`, `protocol_id`,
`replace_if_present_flag` and TLVs on every segment, and `registered_delivery` beyond final
receipts: on failure only, and intermediate notifications. Today each needs `send()`, which gives
up splitting, the alphabet checks and receipt merging. TLVs are the common case: India's DLT
rules put `PE_ID` (0x1400) and `TEMPLATE_ID` (0x1401) on every `submit_sm`, and USSD rides on
`ussd_service_op`. Refuse a TLV the send composes itself (`sar_*`, `message_payload`). Open: one
spelling for receipts, since `dlr: true` and a raw `registered_delivery` could disagree, and
whether goal 4's rule on optional parameters binds a TLV the caller named.
- [ ] **Choose how a long message is spelled on the wire.** Only an 8-bit UDH reference goes out
(`message.ts`), though the reader takes a 16-bit UDH, `sar_*` and `message_payload` alike. Some
SMSCs take only `sar_*` or `message_payload`; php-smpp offers all three. A 16-bit reference also
makes a collision rarer: the 8-bit one wraps every 255 sends on a session.
- [ ] **Failover across SMSC hosts.** Maintainer's call, 2026-09-14. `client()` takes one `host` and
`port`; Kannel, Jasmin and php-smpp take several. A list the reconnect loop walks holds only
which host the one session is on, so it needs no store. Two options, both maintainer's calls:
- **Order**, `fixed` or round robin, default `fixed`. Fixed starts every reconnect at the first
host; round robin at the host after the one the link was last on.
- **Starting over**, a boolean, default on: once the last host has been tried, go back to the
first. Off ends the session once the last host fails, as a drop does under `reconnect: false`.
Open: how the list and today's `host` and `port` share one spelling; whether the backoff grows
per host or per pass; and whether round robin with starting over off still tries the hosts
before the one it started at.
### The server
- [ ] **`sendSms()` on a `server()` session sends `submit_sm` toward the ESME.** Kannel answers
`ESME_RINVCMDID` (`interop-tests/kannel.test.ts`, "MO to Kannel"), and goal 1 says that PDU never
goes out. A server has no other way to send an MO message either: `sendMo()` in that test builds
one from `submitSmParams()` and `ConcatReference`, neither exported. Choosing `deliver_sm` by
`linkEnd` gives MO messages the splitting and checks, keeps one method for one goal, and refuses
the options 3.4 has `deliver_sm` leave empty (`scheduleDeliveryTime`, `validityPeriod`).
- [ ] **Error TLVs on a response this library builds.** `buildBody()` in `pdu.ts` writes no body for
any non-zero status, so a server cannot answer a `data_sm` with `delivery_failure_reason`,
`network_error_code` or `additional_status_info_text`, and a 5.0 peer gets none of its error TLVs.
3.4 omits the body on error for `submit_sm_resp` by name; read each response's section before
widening it. Reading needs nothing: an error response carrying a body already parses.
- [ ] **PROXY protocol on `server()`.** Behind HAProxy or an AWS NLB every session's remote address is
the balancer's, so `authenticate` cannot allow-list by IP and logs name the wrong peer. v1 is
text; v2 is binary and the only one an NLB sends. `smpp` accepts v1 from anyone; accept either
only from addresses the option names.
- [ ] **`outbind`.** In the command table, handled nowhere: a client cannot take an SMSC's `outbind`
and bind back, and `server()` cannot send one. Rare; take it on with a peer that uses it.
- [ ] **Register vendor-specific commands.** 3.4 reserves `command_id` `0x00010200`–`0x000102FF` for
SMSC vendors; today one arrives as a `PduRefusedError`. `smpp` has `addCommand()`. The same
shape question as registering an encoding.
### Encodings
- [ ] **Register a custom encoding.** Maintainer's ask, 2026-09-14. `EncodingName` is a closed union
of three (`defs/encodings.ts`). An entry needs a name, a `data_coding`, `encode`, `decode`,
`match`, whether `detect()` may pick it, and enough for `splitMessage()` to budget a segment
without halving a character. Take encodings as a client or server option rather than mutating a
module table as `smpp` does, so two sessions in one process cannot disagree about a name. A taken
name is an `err`. Settle `consts.ENCODING`'s names first. An entry may claim a `data_coding` a
built-in already uses, maintainer's call, 2026-09-14: an SMSC whose default alphabet, 0x00, is
Latin-1 needs Latin-1 written and read under it
([#23](https://github.com/larvit/larvitsmpp/issues/23)). The entry then owns that coding on its
session both ways: `encodingByDataCoding()` resolves to it, `detect()` tries it in the
built-in's place, and naming the displaced built-in is an `err` naming the entry. Two entries
on one coding are an `err`. Settle whether a claim on 0x00 reaches the class groups
`messageClassEncoding()` reads GSM 7-bit from, which `flash` writes under.
- [ ] **The alphabets SMPP 3.4 names that no encoding carries.** `consts.ENCODING` lists the
`data_coding` ids (5.2.19); only `ASCII`, `LATIN1` and `UCS2` can be sent. Those with a published
definition, and what each costs:
- 0x01 IA5 (ITU-T T.50, ASCII in practice): trivial.
- 0x06 ISO-8859-5 (Cyrillic) and 0x07 ISO-8859-8 (Hebrew): 96-entry tables.
- 0x05 JIS X 0208, 0x0D JIS X 0212, 0x0A ISO-2022-JP and 0x0E KS C 5601: two-octet sets.
`TextDecoder` reads them through ICU — EUC-JP and EUC-KR once each octet's high bit is set,
`iso-2022-jp` as is; checked for JIS X 0208 and KS C 5601 on Node 24.18.0. Nothing built in
encodes them, so ship tables or build the reverse map on first use by decoding the 94×94 grid.
A Node without full ICU throws from `new TextDecoder()`, which hard rule 1 wraps into an `err`.
- 0x09 pictogram has no published definition; leave it out.
- [ ] **GSM 7-bit national language shift tables.** 3GPP TS 23.038 defines them for Turkish, Spanish
(single shift only), Portuguese and ten Indian languages — Bengali, Gujarati, Hindi, Kannada,
Malayalam, Oriya, Punjabi, Tamil, Telugu and Urdu — selected per message by UDH elements 0x25
(locking) and 0x24 (single). They keep that text near GSM's segment size instead of UCS2's 67
characters. Reading means honouring those elements in `decodeMessage()`; sending means `detect()`
picking a table, with each element's 3 octets off the segment budget. `smpp` has Turkish, Spanish
and Portuguese, used only when the caller writes the UDH.
- [ ] **Packed GSM 7-bit, opt-in.** Everything goes out unpacked, SMPP's convention (AGENTS.md, "GSM
7-bit is sent unpacked"); go-smpp carries a packed codec for SMSCs that want septets. Find an SMSC
that needs it before building it.
- [ ] **`consts.ENCODING` spells five alphabets twice.** `CYRILLIC`/`ISO_8859_5`,
`HEBREW`/`ISO_8859_8`, `JIS`/`X_0208_1990`, `EXTENDED_KANJI_JIS`/`X_0212_1990` and
`LATIN1`/`ISO_8859_1`; `FLASH` is a message class, not an alphabet. One name each before
registration starts taking names. A breaking change to an export.
### Observability
- [ ] **Metrics.** Inbound traffic has `data`, `incomingPdu` and `incomingPduObj`; outbound has no
event, and nothing counts requests in flight, queued for a window slot, waiting for a link, or
unanswered. `smpp` and `@semyonf/smpp` emit `metrics`; cloudhopper keeps per-session counters. An
`outgoingPdu`/`outgoingPduObj` pair mirrors the inbound events; the counters can be one read-only
snapshot, read from the owner of each count rather than a second tally that can drift.
### Packaging, tests and CI
- [ ] **Ship `src`, so the source maps lead somewhere.** Maintainer's call, 2026-09-14. `sourceMap`
and `declarationMap` write maps whose `sources` are `../src/*.ts`, but `files` publishes only
`dist`, so 82 of the package's 167 files, 225 KB of its 555 KB unpacked, point at nothing. Adding
`src` (41 files, 209 KB) makes go to definition land in the TypeScript, and lets a debugger or
`--enable-source-maps` show it.
- [ ] **A coverage report and a floor in the gate.** `node --test --experimental-test-coverage
--test-coverage-include='src/**' test/*.test.ts` on Node 24.18.0, 2026-09-14: 98.77% lines,
93.85% branches, 98.28% functions. Gate at 98, 93 and 98 with `--test-coverage-lines`,
`--test-coverage-branches` and `--test-coverage-functions`, which Node 22 and later take — a job
of its own on 24, since the matrix runs compiled JavaScript — and add it to `main`'s required
checks. Raise the floor as coverage rises; never lower it.
- [ ] **Mutation testing.** `@stryker-mutator/tap-runner` runs `node:test` suites and measures whether
a test notices a change, which coverage cannot; `@semyonf/smpp` runs Stryker in CI. The session
suites are timer-heavy, so start with the codec and the encodings.
- [ ] **A Node-RED node, as a package of its own.** `@leissner/node-red-smpp` is the only SMPP node in
the Node-RED library, and by a read of its source it never parses a receipt and never answers the
SMSC's `enquire_link`. Its UI is a fair list of what operators set. It builds on this package,
never inside it.
## Declined
- **Merge state surviving a process restart.** Declined by AGENTS.md goal 7, maintainer's call,
2026-09-02. A restart loses every incomplete receipt group and a peer has no reason to resend one it
already had answered, so the loss is real — but surviving it means handing the application the merge
state to persist, which the scope floor covers as squarely as holding the state here would, and
which publishes the shape of `DlrMerger`'s groups against goal 6. Nothing is foreclosed: the seam
can still be added after 0.5.0 as a minor.
- **A check that warns before `MIRROR_GITHUB_TOKEN` expires.** Gitea mails no one about a failed
scheduled run, since its Actions bot triggers those, so a nightly check would fail unseen. The
maintainer relies on GitHub's own expiry reminders, and on the mirror's first failed push run after
expiry, which mails whoever pushed. Maintainer's call, 2026-09-14.
- **Throughput throttling — a TPS cap, and backing off on `ESME_RTHROTTLED`.** Declined by AGENTS.md
goal 7: an operator's rate limit is scoped to the account, while the widest thing this library owns
is a session, so a bucket here cannot see a second process binding the same account and is wrong in
exactly the case it exists for. `sendSms()` surfaces `ESME_RTHROTTLED` to the caller instead, and
`maxOutstanding` stays — a window slot frees on the peer's next response, which is self-limiting in
a way a rate ceiling is not.
- **CommonJS.** ESM only, maintainer's call reaffirmed 2026-09-14, though `node-smpp-next` ships both.
`require()` of an ES module works unflagged from Node 20.19 and 22.12.
## An optional store
- [ ] **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 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
sent, since an SMSC may deliver a receipt on any bind of the account, and the
messages-per-second budget the sessions share.
- **What it cannot hold.** A response belongs to the link its request arrived on, so a message
left unanswered at a restart stays unanswerable; the peer's own timeout settles it.
- **Pooling.** Several sessions, in one process or many, behind one send: a message goes to a
bound session with a free window slot, all its segments on that one. In one process the
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 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 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.
- **One spelling.** A store-backed cap and the limiter hook both reach a limit shared between
processes; settle which owns that case before building the second.
+1 -1
View File
@@ -24,5 +24,5 @@
"types": ["node"],
"verbatimModuleSyntax": true
},
"include": ["eslint.config.ts", "interop-tests", "src", "test"]
"include": ["benchmarks", "eslint.config.ts", "interop-tests", "src", "test"]
}