136 Commits

Author SHA1 Message Date
lilleman bc4c6b3721 Correct the doc claims the prose sweep falsified, and file the rest
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 32s
Test / test (22) (pull_request) Successful in 32s
Test / test (24) (pull_request) Successful in 31s
Test / test (26) (pull_request) Successful in 32s
2026-09-28 11:32:50 +02:00
lilleman 21dc674d83 Leave the session's link end alone in incomingOn()
Mirror / push (push) Successful in 6s
Test / lint (pull_request) Successful in 23s
Test / test (18) (pull_request) Successful in 32s
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
2026-09-28 11:24:08 +02:00
lilleman 0d60b89e08 Answer a message on the session it arrived on 2026-09-28 11:24:08 +02:00
lilleman e39cece717 Call the onRequest hook unbound, as the session did
Mirror / push (push) Successful in 6s
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 / lint (pull_request) Successful in 23s
Test / test (18) (pull_request) Successful in 31s
Test / test (26) (pull_request) Successful in 32s
2026-09-28 11:21:06 +02:00
lilleman 101b624cdc Build IncomingRequests after the collaborators it reaches through the session
Mirror / push (push) Successful in 6s
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 32s
Test / test (26) (pull_request) Successful in 31s
2026-09-28 11:11:02 +02:00
lilleman 33cb24913a Hand IncomingRequests the session in place of sixteen closures
Mirror / push (push) Successful in 6s
Test / lint (pull_request) Successful in 24s
Test / test (18) (pull_request) Successful in 32s
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 31s
2026-09-28 11:08:39 +02:00
lilleman 0bea36c069 Give the held-message flow one place a reader can follow it
Test / lint (pull_request) Successful in 24s
Test / test (18) (pull_request) Successful in 32s
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
Mirror / push (push) Successful in 7s
2026-09-28 10:54:55 +02:00
lilleman 1aadcbe3c9 Name what each of the three request paths skips
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 32s
Mirror / push (push) Successful in 6s
2026-09-28 10:42:18 +02:00
lilleman 469a91b9bb File the two spellings of a bind command
Test / lint (pull_request) Successful in 23s
Test / test (18) (pull_request) Successful in 32s
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 32s
Mirror / push (push) Successful in 6s
2026-09-28 10:16:25 +02:00
lilleman e4ff71a86b Tie the bind-state decision to its goal, and drop what MIGRATION holds
Test / lint (pull_request) Successful in 23s
Test / test (18) (pull_request) Successful in 31s
Test / test (20) (pull_request) Successful in 42s
Test / test (22) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 32s
Test / test (26) (pull_request) Successful in 31s
Mirror / push (push) Successful in 5s
2026-09-28 04:11:55 +02:00
lilleman 3f736ff440 Quote a string connectTimeout through quoted() too
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 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
2026-09-28 04:11:23 +02:00
lilleman 80dc263915 Name strict-mode code as where a getter-only assignment throws
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 31s
Test / test (22) (pull_request) Successful in 32s
Test / test (24) (pull_request) Successful in 36s
Test / test (26) (pull_request) Successful in 31s
2026-09-28 04:09:40 +02:00
lilleman 2142c2e3bb Spell out the hand-wired ESME's bind, and where a getter-only assignment throws
Mirror / push (push) Has been cancelled
Test / test (18) (pull_request) Successful in 32s
Test / test (20) (pull_request) Successful in 31s
Test / lint (pull_request) Successful in 28s
Test / test (22) (pull_request) Successful in 32s
Test / test (24) (pull_request) Successful in 37s
Test / test (26) (pull_request) Successful in 31s
2026-09-28 04:08:53 +02:00
lilleman 4bdb1e64b0 Take bound()'s bind type as a string, name the fix in its refusals, and show the ESME's spelling
Mirror / push (push) Has been cancelled
Test / test (18) (pull_request) Successful in 31s
Test / test (22) (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
Test / test (26) (pull_request) Successful in 32s
2026-09-28 04:07:44 +02:00
lilleman 9eebd42887 Test that bound() takes a bind type as a string and names the fix for a wrong one 2026-09-28 04:07:02 +02:00
lilleman 9a07f75bdf Refuse a bind the server cannot record as it refuses one authenticate turns down
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 32s
Test / test (22) (pull_request) Successful in 32s
Test / test (24) (pull_request) Successful in 34s
Test / test (26) (pull_request) Successful in 32s
2026-09-28 04:05:07 +02:00
lilleman 2ac72fb3f4 Refuse a bind bound() cannot record, and document it for a 0.4.0 consumer
Mirror / push (push) Successful in 6s
Test / lint (pull_request) Successful in 23s
Test / test (18) (pull_request) Successful in 31s
Test / test (20) (pull_request) Successful in 45s
Test / test (22) (pull_request) Successful in 32s
Test / test (24) (pull_request) Successful in 31s
Test / test (26) (pull_request) Successful in 32s
2026-09-28 04:01:51 +02:00
lilleman 3cc38325cc Test that bound() refuses a bind type or version it cannot record 2026-09-28 04:00:13 +02:00
lilleman b70f0d6ade Let the session own its bind state, written by bound() alone
Mirror / push (push) Successful in 7s
Test / lint (pull_request) Successful in 26s
Test / test (18) (pull_request) Successful in 32s
Test / test (20) (pull_request) Successful in 40s
Test / test (22) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 37s
Test / test (26) (pull_request) Successful in 38s
2026-09-28 03:55:40 +02:00
lilleman ebc5db5a00 Test that the session owns its bind state and keeps it through a reconnect's gap 2026-09-28 03:55:40 +02:00
lilleman b0b7404477 Name the session-options hooks that import Session
Test / lint (pull_request) Successful in 27s
Test / test (18) (pull_request) Successful in 32s
Test / test (20) (pull_request) Successful in 31s
Test / test (22) (pull_request) Successful in 32s
Test / test (24) (pull_request) Successful in 37s
Test / test (26) (pull_request) Successful in 31s
Mirror / push (push) Successful in 6s
2026-09-28 02:40:13 +02:00
lilleman c32c1bc65b Name the session's lifecycle state and the invariant that keeps ended final
Mirror / push (push) Has been cancelled
Test / lint (pull_request) Successful in 24s
Test / test (18) (pull_request) Successful in 40s
Test / test (20) (pull_request) Successful in 32s
Test / test (22) (pull_request) Successful in 32s
Test / test (24) (pull_request) Successful in 31s
Test / test (26) (pull_request) Successful in 31s
2026-09-28 02:39:49 +02:00
lilleman 20aa2828c2 Fold the session's closed and ended flags into one link state, and split Locality by the scoring run
Mirror / push (push) Has been cancelled
Test / lint (pull_request) Successful in 23s
Test / test (18) (pull_request) Successful in 34s
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 35s
2026-09-28 02:36:32 +02:00
lilleman af29846544 Reflow the Self-sufficiency item
Test / lint (pull_request) Successful in 30s
Test / test (18) (pull_request) Successful in 35s
Test / test (20) (pull_request) Successful in 33s
Test / test (22) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 31s
Test / test (26) (pull_request) Successful in 40s
Mirror / push (push) Has been cancelled
2026-09-28 02:10:02 +02:00
lilleman 65fadfc93a Narrow CodingSource to the two bodies, and file Locality's remaining work
Mirror / push (push) Has been cancelled
Test / test (20) (pull_request) Successful in 1m33s
Test / test (22) (pull_request) Successful in 32s
Test / test (24) (pull_request) Successful in 33s
Test / test (26) (pull_request) Successful in 40s
Test / lint (pull_request) Successful in 24s
Test / test (18) (pull_request) Successful in 32s
2026-09-28 02:09:42 +02:00
lilleman 2a6df3ffe2 Name which body settles data_coding in resolveBody
Mirror / push (push) Has been cancelled
Test / lint (pull_request) Successful in 23s
Test / test (18) (pull_request) Successful in 38s
Test / test (20) (pull_request) Successful in 33s
Test / test (26) (pull_request) Successful in 32s
Test / test (22) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 37s
2026-09-28 02:02:53 +02:00
lilleman 30245bda21 Move the gate's teardown invariant to retrying()
Test / lint (pull_request) Successful in 23s
Test / test (18) (pull_request) Successful in 32s
Test / test (20) (pull_request) Successful in 31s
Test / test (22) (pull_request) Successful in 32s
Test / test (24) (pull_request) Successful in 31s
Test / test (26) (pull_request) Successful in 31s
Mirror / push (push) Has been cancelled
2026-09-28 01:50:33 +02:00
lilleman 5e132977f4 Reflow the gate decision after the rename
Mirror / push (push) Has been cancelled
Test / test (18) (pull_request) Successful in 32s
Test / test (20) (pull_request) Successful in 31s
Test / test (26) (pull_request) Successful in 32s
Test / lint (pull_request) Successful in 23s
Test / test (22) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 31s
2026-09-28 01:48:32 +02:00
lilleman 669cbde9d0 Drop carry()'s misstated exit line
Mirror / push (push) Has been cancelled
Test / test (18) (pull_request) Successful in 32s
Test / test (26) (pull_request) Successful in 31s
Test / lint (pull_request) Successful in 23s
Test / test (20) (pull_request) Successful in 31s
Test / test (22) (pull_request) Successful in 34s
Test / test (24) (pull_request) Successful in 31s
2026-09-28 01:48:02 +02:00
lilleman 696562daec Name carry()'s retry condition and what ends its loop
Test / lint (pull_request) Successful in 23s
Mirror / push (push) Has been cancelled
Test / test (22) (pull_request) Successful in 32s
Test / test (24) (pull_request) Successful in 31s
Test / test (18) (pull_request) Successful in 31s
Test / test (20) (pull_request) Successful in 30s
Test / test (26) (pull_request) Successful in 32s
2026-09-28 01:45:31 +02:00
lilleman bfd0ed1b98 Say only what canCarry() and droppedWhileDraining() can know
Test / lint (pull_request) Successful in 23s
Test / test (18) (pull_request) Successful in 32s
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) Has been cancelled
2026-09-28 01:34:55 +02:00
lilleman 8d443bf670 Split linkDown() into canCarry() and droppedWhileDraining()
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 34s
Test / test (24) (pull_request) Successful in 40s
Test / test (26) (pull_request) Successful in 33s
Mirror / push (push) Waiting to run
2026-09-28 01:31:39 +02:00
lilleman 1371137fef Branch once on a dest_address's kind when sizing it
Test / lint (pull_request) Successful in 28s
Test / test (18) (pull_request) Successful in 31s
Test / test (20) (pull_request) Successful in 31s
Test / test (22) (pull_request) Successful in 34s
Test / test (24) (pull_request) Successful in 34s
Test / test (26) (pull_request) Successful in 31s
Mirror / push (push) Successful in 5s
2026-09-28 01:25:31 +02:00
lilleman 809acd487e Size the address arrays' C-Octet Strings through cstring.size()
Test / lint (pull_request) Successful in 22s
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 31s
Mirror / push (push) Has been cancelled
Test / test (26) (pull_request) Successful in 32s
2026-09-28 01:22:54 +02:00
lilleman acad60c889 Say what ExpiringGroups leaves to its owners
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 30s
Test / test (26) (pull_request) Successful in 31s
Mirror / push (push) Successful in 5s
2026-09-28 01:16:06 +02:00
lilleman b1a4201a7d Record a group's weight through weigh() alone
Mirror / push (push) Successful in 5s
Test / lint (pull_request) Successful in 22s
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 39s
2026-09-28 01:13:51 +02:00
lilleman 7117c91eab Assert a replaced held message leaves its octets with it 2026-09-28 01:13:51 +02:00
lilleman 91c155175f Derive the retained octets from ExpiringGroups' weight
Test / lint (pull_request) Successful in 22s
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 33s
Test / test (26) (pull_request) Successful in 32s
Mirror / push (push) Successful in 5s
2026-09-28 01:09:12 +02:00
lilleman 0b9b210fb2 Keep a hold's release behind MessageHold, and require its listener count
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 32s
Mirror / push (push) Successful in 5s
2026-09-28 01:03:15 +02:00
lilleman 9dda1a6917 Release a held message through the hold it was given 2026-09-28 01:02:43 +02:00
lilleman bd1284b661 Give the held-message protocol one home in MessageHold
Test / lint (pull_request) Successful in 23s
Test / test (18) (pull_request) Successful in 34s
Test / test (20) (pull_request) Successful in 31s
Test / test (22) (pull_request) Successful in 39s
Test / test (24) (pull_request) Successful in 32s
Test / test (26) (pull_request) Successful in 39s
Mirror / push (push) Successful in 5s
2026-09-28 00:59:38 +02:00
lilleman 76c6783317 Route sms.ts through its handlers, all of it
Test / lint (pull_request) Successful in 23s
Test / test (18) (pull_request) Successful in 38s
Test / test (20) (pull_request) Successful in 30s
Test / test (22) (pull_request) Successful in 33s
Test / test (24) (pull_request) Successful in 35s
Test / test (26) (pull_request) Successful in 34s
Mirror / push (push) Successful in 5s
2026-09-28 00:54:01 +02:00
lilleman 4031cfadf1 Stub the handlers an Sms answers and receipts through 2026-09-28 00:52:39 +02:00
lilleman efb59b6cff Pin only the unbind ordering, type peerUnbound, and drop what the port made stale
Test / lint (pull_request) Successful in 23s
Test / test (18) (pull_request) Successful in 31s
Test / test (20) (pull_request) Successful in 34s
Test / test (22) (pull_request) Successful in 35s
Test / test (24) (pull_request) Successful in 35s
Test / test (26) (pull_request) Successful in 34s
Mirror / push (push) Successful in 6s
2026-09-28 00:43:26 +02:00
lilleman b2183ea30e Give IncomingRequests a port instead of the Session it drives
Test / lint (pull_request) Successful in 23s
Test / test (18) (pull_request) Successful in 31s
Test / test (20) (pull_request) Successful in 37s
Test / test (22) (pull_request) Successful in 32s
Test / test (24) (pull_request) Successful in 34s
Test / test (26) (pull_request) Successful in 37s
Mirror / push (push) Successful in 5s
2026-09-28 00:38:41 +02:00
lilleman b90c2647be Record the TLV keying decision as the maintainer's
Test / lint (pull_request) Successful in 22s
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 30s
Mirror / push (push) Successful in 5s
2026-09-27 23:59:11 +02:00
lilleman b9f278b0b0 Open every todo item with a bold one-sentence tagline
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 36s
Test / test (24) (pull_request) Successful in 31s
Test / test (26) (pull_request) Successful in 31s
Mirror / push (push) Successful in 5s
2026-09-27 23:37:38 +02:00
lilleman ac9cb40d69 Reflow the under-floor decision
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 (22) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 30s
Test / test (26) (pull_request) Successful in 31s
Mirror / push (push) Successful in 4s
2026-09-27 16:41:53 +02:00
lilleman 9724dd1751 Record #30 merging under the comprehension floor, and put Locality first in 0.6.0
Mirror / push (push) Successful in 5s
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 30s
Test / test (26) (pull_request) Successful in 30s
2026-09-27 16:41:47 +02:00
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 / 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 32s
Test / test (24) (pull_request) Successful in 38s
Test / test (26) (pull_request) Successful in 32s
Mirror / push (push) Successful in 7s
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
Mirror / push (push) Successful in 7s
Test / lint (pull_request) Successful in 25s
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 (18) (pull_request) Successful in 33s
Test / test (20) (pull_request) Successful in 32s
Test / test (22) (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 / test (18) (pull_request) Successful in 31s
Test / test (22) (pull_request) Successful in 30s
Test / test (26) (pull_request) Successful in 31s
Test / lint (pull_request) Successful in 23s
Test / test (20) (pull_request) Successful in 29s
Test / test (24) (pull_request) Successful in 35s
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 / 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
Test / lint (pull_request) Successful in 21s
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 (22) (pull_request) Successful in 31s
Test / test (20) (pull_request) Successful in 29s
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
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 (26) (pull_request) Successful in 30s
Mirror / push (push) Successful in 4s
Test / lint (pull_request) Successful in 21s
Test / test (24) (pull_request) Successful in 29s
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
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 30s
Test / test (24) (pull_request) Successful in 29s
Test / test (26) (pull_request) Successful in 29s
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 / test (18) (pull_request) Successful in 29s
Test / test (20) (pull_request) Successful in 29s
Test / lint (pull_request) Successful in 22s
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 (26) (pull_request) Successful in 19s
Test / test (24) (pull_request) Successful in 17s
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 (24) (pull_request) Successful in 17s
Test / test (26) (pull_request) Successful in 17s
Test / test (18) (pull_request) Successful in 17s
Test / test (20) (pull_request) Successful in 17s
Test / test (22) (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
54 changed files with 4208 additions and 1670 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/*'
+125 -811
View File
File diff suppressed because it is too large Load Diff
+87
View File
@@ -0,0 +1,87 @@
# 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.
- `session.boundAs` and `session.peerInterfaceVersion` are read-only, and `session.loggedIn` is
removed: read `session.boundAs !== undefined`. A session you wire yourself records the bind it
accepted or had accepted with `session.bound(bindType, declaredVersion)`, which returns `err` for a
bind type or version it cannot record. An assignment to either field does not compile in
TypeScript, throws a `TypeError` in strict-mode code (every ES module, and any file under
`'use strict'`), and is ignored otherwise.
## 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
+15 -2
View File
@@ -1,6 +1,6 @@
# Migrating from larvitsmpp 0.4.0 # Migrating from larvitsmpp 0.4.0
`@larvit/smpp` 0.5.0 succeeds [larvitsmpp](https://www.npmjs.com/package/larvitsmpp) 0.4.0. The `@larvit/smpp` succeeds [larvitsmpp](https://www.npmjs.com/package/larvitsmpp) 0.4.0. The
shape is the same, connect, send, listen for delivery reports, with callbacks replaced by promises. shape is the same, connect, send, listen for delivery reports, with callbacks replaced by promises.
## API changes ## API changes
@@ -13,7 +13,8 @@ shape is the same, connect, send, listen for delivery reports, with callbacks re
a `session` event. It no longer calls back once per connection. a `session` event. It no longer calls back once per connection.
- **The id a message is answered with goes to `sendResp({ smsId })`.** `sms.smsId` is read-only: the - **The id a message is answered with goes to `sendResp({ smsId })`.** `sms.smsId` is read-only: the
id the segments were answered with, the id `sendResp()` was given, or the generated UUID v7. id the segments were answered with, the id `sendResp()` was given, or the generated UUID v7.
Assigning to it throws a `TypeError`, since modules are strict mode. Assigning to it throws a `TypeError` in strict-mode code (every ES module, and any file under
`'use strict'`), and is ignored otherwise.
- **`smsIds` from `sendSms()` is `(string | undefined)[]`**, one entry per segment, positional with - **`smsIds` from `sendSms()` is `(string | undefined)[]`**, one entry per segment, positional with
`pduObjs`, `undefined` where the SMSC took the segment without naming an id. `pduObjs`, `undefined` where the SMSC took the segment without naming an id.
- **`checkuserpass` is `authenticate`**, takes `{ password, session, systemId, systemType }` and - **`checkuserpass` is `authenticate`**, takes `{ password, session, systemId, systemType }` and
@@ -30,6 +31,15 @@ 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 `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 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. `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.
- **`session.loggedIn` and the `loggedIn` event are gone.** `client()` resolves once bound,
`session.boundAs !== undefined` says a bind happened, and `disconnected`/`reconnected` say whether
the link is up now. A session you construct yourself records a bind with `session.bound()`.
- **The `error` event is `sessionError`**, and `serverError` on the server handle. - **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 - **`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). a `larvitutils` one, and is silent by default: [README](README.md#logging).
@@ -70,6 +80,9 @@ have for these:
- Binary TLVs (`message_payload`, `network_error_code`, `callback_num` and the rest) were parsed into - Binary TLVs (`message_payload`, `network_error_code`, `callback_num` and the rest) were parsed into
a hex string and written back as the ASCII of that string, so every round trip corrupted them. a hex string and written back as the ASCII of that string, so every round trip corrupted them.
They are `Buffer`s in both directions now; drop any hex encoding of your own. They are `Buffer`s in both directions now; drop any hex encoding of your own.
- `callback_num`, `callback_num_atag`, `callback_num_pres_ind`, `broadcast_area_identifier` and
`broadcast_error_status` may repeat within a PDU, and each is an array of every occurrence in both
directions. 0.4.0 kept only the last one it read.
- A body carried in the `message_payload` TLV was ignored, so the message arrived empty, and a - A body carried in the `message_payload` TLV was ignored, so the message arrived empty, and a
`data_sm` was answered `ESME_RINVCMDID`, so a receipt thrown on one was lost silently. Both reach `data_sm` was answered `ESME_RINVCMDID`, so a receipt thrown on one was lost silently. Both reach
the application now: a receipt as `dlr`, answered for you, and a message as `sms` for you to answer. the application now: a receipt as `dlr`, answered for you, and a message as `sms` for you to answer.
+151 -25
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) · [Send options](#send-options) · [Session](#session) · [Receiving in depth](#receiving-in-depth) ·
[Server in depth](#server-in-depth) · [Logging](#logging) · [Server in depth](#server-in-depth) · [Logging](#logging) ·
[PDUs and the low-level API](#pdus-and-the-low-level-api) · [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 ## 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 Call `sendResp()` for every message, multipart included: until you do, it counts toward the bound
`dlr` events, not here. A multipart message arrives reassembled and already answered segment by past which the peer's messages are refused. Delivery receipts reach you as `dlr` events, not here. A
segment, so `sendResp()` there only says you are done with it: [Receiving in depth](#receiving-in-depth). 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 ## 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). `sendDlr('UNDELIVERABLE')` any other state: [Server in depth](#server-in-depth).
- A message that arrived in several segments was answered as they arrived, so `sendResp()` there - A message that arrived in several segments was answered as they arrived, so `sendResp()` there
takes no `smsId` or refusing `status`. `sms.answeredOnArrival` says which case you are in. takes no `smsId` or refusing `status`. `sms.answeredOnArrival` says which case you are in.
- `smpp.close()` stops listening, then drains and closes every live session.
## Errors ## Errors
@@ -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. | | `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. | | `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. | | `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. | | `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`. | | `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. | | `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. | | `tls` | `false` | A `tls.TlsOptions` object with your certificate and key. A bare `true` is refused. |
| `idleTimeout` | `40000` | Drop a peer that has been silent this long. | | `idleTimeout` | `40000` | Drop a peer that has been silent this long. |
| `maxReassembly` | `1000` | Incomplete multipart messages held per session. | | `maxReassembly` | `1000` | Incomplete multipart messages held per session. |
| `maxOctets` | `67108864` | 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. | | `reassemblyTimeout` | `300000` | How long a late segment can still join an incomplete message. |
| `responseTimeout`, `shutdownTimeout`, `maxOutstanding`, `log`, `signal` | as for the client | | | `responseTimeout`, `shutdownTimeout`, `maxOutstanding`, `log`, `signal` | as for the client | |
@@ -286,7 +288,11 @@ await session.sendSms({
``` ```
**Addresses.** `sourceAddrTon` and `destinationAddrTon` default to 5 for an alphanumeric address **Addresses.** `sourceAddrTon` and `destinationAddrTon` default to 5 for an alphanumeric address
and 1 for a numeric one; the NPI fields default to 0. 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.** **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 - One id per segment. `smsIds` is positional with `pduObjs`, and an entry is `undefined` where the
SMSC took the segment without naming an id; some name one for the first segment only. No receipt SMSC took the segment without naming an id; some name one for the first segment only. No receipt
ever carries an empty id, so an unnamed entry matches nothing. ever carries an empty id, so an unnamed entry matches nothing.
- `err` is set when the SMSC refuses a segment, naming the status. Every segment goes out together, - `err` is set when the SMSC refuses a segment, naming the status, or leaves one unanswered. Every
so `pduObjs` and `smsIds` then hold what was accepted: enough to reconcile a later receipt, not segment goes out together, so `pduObjs` and `smsIds` then hold only the accepted segments, in send
enough to resend the rest. Treat a partial failure as a failed message. 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 - `unanswered` counts segments that went out and were never answered. The SMSC may have taken each
and lost only the response, so a message with `unanswered` above zero cannot be resent without and lost only the response, so a message with `unanswered` above zero cannot be resent without
risking a duplicate. risking a duplicate.
@@ -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. count, and a count past 99 days 23:59:59, since a count in seconds is spelled in days and below.
Name a later instant as a `Date`, which goes out absolute. Name a later instant as a `Date`, which goes out absolute.
**What gets checked.** The library checks what it composes: an alphabet or a time you named, a string **What gets checked.** The library checks what it composes: an address you gave as `from` or `to`,
body under a `data_coding` you named. What you formed yourself, a `Buffer` body or a stamp you an alphabet or a time you named, a string body under a `data_coding` you named. What you formed
formatted, passes through as written. The same rule holds for `session.send()`. 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 ## 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. message has failed. Answering through `sendReturn()` instead leaves the wait running.
3. Tear down what is left, resolving to an `err` that says what was lost. 3. Tear down what is left, resolving to an `err` that says what was lost.
At most 1000 unanswered messages are held, for five minutes each; what falls out of either bound is A message left unanswered for five minutes is no longer waited for.
dropped with a warning on the log and waited for no longer. Neither bound is an option.
`close({ signal })` cuts the wait short. `unbind()` takes no signal, and waits a further `close({ signal })` cuts the wait short. `unbind()` takes no signal, and waits a further
`responseTimeout` for its own response. `responseTimeout` for its own response.
@@ -413,8 +421,18 @@ const { err, pduObj } = await session.send({
- `acceptsOptionalParams()`: whether the peer declared SMPP 3.4 or later, the version from which - `acceptsOptionalParams()`: whether the peer declared SMPP 3.4 or later, the version from which
optional parameters may be sent to it. The library's own senders check it before attaching a TLV; optional parameters may be sent to it. The library's own senders check it before attaching a TLV;
a `send()` you build is passed through as written, so check it yourself. a `send()` you build is passed through as written, so check it yourself.
- `peerInterfaceVersion`: the version the peer declared, `0x00` if none. - `peerInterfaceVersion`: the version the peer declared, `0x00` if none, `undefined` before any bind.
- `bindAllows(cmdName)` and `boundAs`: what the bind direction carries: [Bind direction](#bind-direction). - `bindAllows(cmdName)` and `boundAs`: what the bind direction carries: [Bind direction](#bind-direction).
- `boundAs` and `peerInterfaceVersion` are read-only, and hold through a reconnect's gap until the
link binds again.
- `bound(bindType, declaredVersion)`: how a session you construct yourself records a bind, whichever
end accepted it, on every link it binds. `bindType` is `receiver`, `transceiver` or `transmitter`;
`declaredVersion` is 0-255, or `undefined` where the peer declared none. Anything else returns `err`
and records nothing.
- An ESME wired by hand sends its own `bind_<bindType>` through `session.send()`, after it is
constructed and again in `reconnect.onConnected`, and records each accepted one with
`session.bound(bindType, pduObj.tlvs.sc_interface_version?.tagValue)`, where `bindType` is the one
it sent and `pduObj` the `bind_resp` that `send()` resolved with.
## Receiving in depth ## Receiving in depth
@@ -424,6 +442,12 @@ const { err, pduObj } = await session.send({
each is two messages. each is two messages.
- **Answered on arrival.** Each segment was answered as it landed, before you see the message: - **Answered on arrival.** Each segment was answered as it landed, before you see the message:
[Server in depth](#server-in-depth). [Server in depth](#server-in-depth).
- **Unanswered messages.** While a session holds 1000 messages you have not called `sendResp()` on,
or 64 MiB of them counted the way `maxOctets` counts segments, every new message and segment is
refused so the peer retries it: `ESME_RTHROTTLED` on a submission, `ESME_RX_T_APPN` on a delivery.
No `sms` fires. Reaching the bound logs one `warn`, and the first message accepted once both are
down to half one `info`. A message left five minutes is dropped from the count with a `warn`; a
later `sendResp()` still answers it. None of the three is an option.
- **Where the body is.** A body in the `message_payload` TLV, SMPP's way of carrying up to 64 KB and - **Where the body is.** A body in the `message_payload` TLV, SMPP's way of carrying up to 64 KB and
the only place a `data_sm` has, reads exactly like one in `short_message`, concatenated messages the only place a `data_sm` has, reads exactly like one in `short_message`, concatenated messages
and receipts included. A PDU filling both is read from `short_message`. and receipts included. A PDU filling both is read from `short_message`.
@@ -472,19 +496,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 report never counts. Merging needs the SMSC to number its segment ids `<base>-<n>`, this library's
own server's convention; an SMSC that hands out unrelated ids per segment never fires it. A base is own server's convention; an SMSC that hands out unrelated ids per segment never fires it. A base is
merged once: a later message the SMSC gives the same ids is reported through `dlr` alone, and an merged once: a later message the SMSC gives the same ids is reported through `dlr` alone, and an
earlier one still collecting loses its merged report. 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 ## Server in depth
**Multipart is answered on arrival.** Each segment is answered as it lands, because a relaying SMSC **Multipart is answered on arrival.** Each segment is answered as it lands, because a relaying SMSC
will not send the next until the last is answered. The answer is `ESME_ROK`, unless the segment will not send the next until the last is answered. The answer is `ESME_ROK`, unless the segment
numbers itself into no message this session can join, which refuses it, or the reassembly buffer is numbers itself into no message this session can join, which refuses it, or the reassembly buffer or
full, which asks the SMSC to keep it and try again. `sms.answeredOnArrival` says whether the message the unanswered messages are at their bound, which asks the SMSC to keep it and try again.
you hold was answered that way; a segment count cannot, since a peer may number a message one part `sms.answeredOnArrival` says whether the message you hold was answered that way; a segment count
of one. 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 - The id was fixed with the first segment, so `sendResp()` there puts nothing on the wire and
returns `err` for an `smsId` or a refusing `status`. 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 - `sms.smsId` is the base. `sendDlr()` names `<smsId>-1`, `<smsId>-2` and so on: the ids the
`submit_sm` responses carried. `submit_sm` responses carried.
- A `deliver_sm` is answered with no id at all, since SMPP marks that field unused, so an inbound - A `deliver_sm` is answered with no id at all, since SMPP marks that field unused, so an inbound
@@ -524,7 +549,9 @@ if (err) throw err;
- `enquire_link` and `unbind` reach the hook too, and an unanswered `enquire_link` has the peer drop - `enquire_link` and `unbind` reach the hook too, and an unanswered `enquire_link` has the peer drop
the link. Guard on the command name, as above, and a failing hook costs only its own request. the link. Guard on the command name, as above, and a failing hook costs only its own request.
- A `Session` you construct yourself takes the same hook as a session option, and that is where a - A `Session` you construct yourself takes the same hook as a session option, and that is where a
peer's bind gets accepted, since a hand-wired session has no bind handling of its own. peer's bind gets accepted, since a hand-wired session has no bind handling of its own: call
`session.bound(pduObj.cmdName.slice('bind_'.length), pduObj.params.interface_version)` before
answering it, and refuse the bind with `ESME_RBINDFAIL` where that returns `err`.
**`sendDlr()`** takes `SCHEDULED`, `ENROUTE`, `DELIVERED`, `EXPIRED`, `DELETED`, `UNDELIVERABLE`, **`sendDlr()`** takes `SCHEDULED`, `ENROUTE`, `DELIVERED`, `EXPIRED`, `DELETED`, `UNDELIVERABLE`,
`ACCEPTED`, `UNKNOWN`, `REJECTED` or `SKIPPED`. The first two go out as intermediate delivery `ACCEPTED`, `UNKNOWN`, `REJECTED` or `SKIPPED`. The first two go out as intermediate delivery
@@ -598,6 +625,13 @@ if (isCommand(pduObj, 'submit_sm')) {
and hands back the UDH where the PDU carries one. and hands back the UDH where the PDU carries one.
- `concatOf(pduObj)`: the `part`, `total` and `reference` a PDU declares and the `spelling` that - `concatOf(pduObj)`: the `part`, `total` and `reference` a PDU declares and the `spelling` that
carried them, `'udh'` or `'sar'`, or `undefined` for a whole message. carried them, `'udh'` or `'sar'`, or `undefined` for a whole message.
- `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 - `messageClassOf(dataCoding)`: `0` for the flash class, `1`, `2` and `3` for the ME-, SIM- and
TE-specific ones, `undefined` where that `data_coding`'s coding group carries no class. TE-specific ones, `undefined` where that `data_coding`'s coding group carries no class.
@@ -606,6 +640,14 @@ if (isCommand(pduObj, 'submit_sm')) {
- A string `short_message` or `message_payload` is encoded in the alphabet the PDU's `data_coding` - A string `short_message` or `message_payload` is encoded in the alphabet the PDU's `data_coding`
names, detected from the text where you name none. One that alphabet cannot carry is refused, names, detected from the text where you name none. One that alphabet cannot carry is refused,
naming the character, its code point and where it is. naming the character, its code point and where it is.
- Every text field is latin1: addresses, `system_id`, `message_id`, `service_type` and the C-Octet
String TLVs. A character past `U+00FF` is refused, as is a `U+0000` in a C-Octet String.
- `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 - A `Buffer` goes out exactly as given under any `data_coding`: binary payloads, hand-built user
data headers, deliberately malformed bodies. data headers, deliberately malformed bodies.
- `session.send()` and `session.sendReturn()` build through the same codec and refuse the same bodies. - `session.send()` and `session.sendReturn()` build through the same codec and refuse the same bodies.
@@ -625,13 +667,97 @@ if (isCommand(pduObj, 'submit_sm')) {
| Messages | `encodeMessage`, `decodeMessage`, `splitMessage`, `bitCount`, `messageOctets`, `concatOf`, `concatInfo`, `detect`, `unencodable`, `messageClassOf`, `dataCodingByEncoding`, `encodingByDataCoding` | | Messages | `encodeMessage`, `decodeMessage`, `splitMessage`, `bitCount`, `messageOctets`, `concatOf`, `concatInfo`, `detect`, `unencodable`, `messageClassOf`, `dataCodingByEncoding`, `encodingByDataCoding` |
| Receipts | `dlrFromPdu`, `parseReceipt`, `receiptCodes` | | Receipts | `dlrFromPdu`, `parseReceipt`, `receiptCodes` |
| Time and ids | `smppDate`, `smppTime`, `uuidv7` | | 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`. | | 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 ## Migrating from larvitsmpp 0.4.0
See [MIGRATION.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/MIGRATION.md). 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 ## Development
Everything runs in the container; nothing is installed on the host. 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);
+837
View File
@@ -0,0 +1,837 @@
# 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
(`IncomingRequests`, `OutgoingRequests`, `ReconnectLoop`, `LinkTimers`, `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.** `bound()`
records what the peer declared, the ESME's `interface_version` or the SMSC's
`sc_interface_version`; 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.** Maintainer's call, 2026-09-27, on the
architecture and product-owner reviews of [#30](https://gitea.larvit.se/larvit/smpp-js/pulls/30). 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 resets `lifecycle` 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. A stream the framer or
the codec cannot read, an aborted `close({ signal })` and a peer's own `unbind` do not drain:
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. `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.
- **The bind state is the session's, `bound()` alone writes it, and it holds through a reconnect's
gap.** Maintainer's call, 2026-09-28. `client()` and `server()` record their bind through
`bound()`, the call a hand-wired session makes, so the state has one writer — goal 8. Clearing the state at `teardown()` was rejected: `bindAllows()` and
`acceptsOptionalParams()` then answer yes to everything while the link is down, so a
receiver-bound client queues a `submit_sm` the peer refuses and a receipt built then carries TLVs a
pre-3.4 peer must not get — goal 4. Valid while the reconnect loop binds again with the same bind
type to the same peer.
- **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()` marks the session attached the moment a socket is
handed over, one round trip before the bind is answered, so gating on that let a send arriving in
that window go out unbound and come back `ESME_RINVBNDSTS`. 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. The retry in `requestPastDrain()` asks `gate.awaitsNextLink()` rather than `canCarry()`,
which also reads the socket: a loop condition the gate does not gate on spins against a gate that
admits it straight back.
## Internals and tests
- **#30 merged under the comprehension floor, and Locality is the next work.** Maintainer's call,
2026-09-27. A four-seat scoring run, depth 1, read the project at 6, 6, 7 and 6 (mean 6.25), every
seat capped by Locality in the held-message and shutdown code #30 does not touch, where the floor
is 7.0. The chunks after #30 lift Locality to 7 before any other work. Serves goal 8's
reshapeable internals, which a reader has to understand before reshaping. Valid until a scoring
run reads 7.0 or above.
- **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 1. A worktree on a branch off `origin/main` (never `origin/v0.4.0`, the 0.4.0 code), named
for the defect. for the defect.
2. Regression tests in `test/` first, naming the behaviour with the reproducer from the findings; 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. 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 3. `/larv-review` on the branch, with the pull request based on `main`. When it marks the PR
ready, fast-forward it. ready, fast-forward it.
+2 -2
View File
@@ -1,6 +1,6 @@
# interop-tests # 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 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. 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 | | 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/` | | **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 | | **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/` | | **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 timeout: 2s
services: services:
# Network owner for every dumbclient-* service and the capture sidecar below (see the comment on # Network owner for every dumbclient-* service and the capture sidecar below: all four are pure
# `capture`): all four are pure outbound TCP clients with nothing of their own listening, so # outbound TCP clients, so sharing one netns is only a source-IP detail. It never exits, because a
# sharing one netns is only ever a source-IP detail, never a port collision. # 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: dumbclient-w2000:
build: ./interop-tests/peers/dumbclient build: ./interop-tests/peers/dumbclient
image: interop-dumbclient:de0334b image: interop-dumbclient:de0334b
<<: *log-limits <<: *log-limits
network_mode: "service:dumbclient-netns"
command: ["conf/window2000.yml"] command: ["conf/window2000.yml"]
healthcheck: *dumbclient-healthcheck healthcheck: *dumbclient-healthcheck
depends_on:
dumbclient-netns:
condition: service_started
# S9's comparison run: window below maxHeldMessages (1000, session-options.ts), where nothing # S9's comparison run: window below maxHeldMessages (1000, session-options.ts), where nothing
# should ever be evicted - see findings/07-load.md. # should ever be throttled - see findings/07-load.md.
dumbclient-w500: dumbclient-w500:
build: ./interop-tests/peers/dumbclient build: ./interop-tests/peers/dumbclient
image: interop-dumbclient:de0334b image: interop-dumbclient:de0334b
<<: *log-limits <<: *log-limits
network_mode: "service:dumbclient-w2000" network_mode: "service:dumbclient-netns"
command: ["conf/window500.yml"] command: ["conf/window500.yml"]
healthcheck: *dumbclient-healthcheck healthcheck: *dumbclient-healthcheck
depends_on: depends_on:
dumbclient-w2000: dumbclient-netns:
condition: service_started condition: service_started
# S6: sends one message, then never speaks again - the no-ping binary (see the Dockerfile) sends # S6: sends one message, then never speaks again - the no-ping binary (see the Dockerfile) sends
@@ -41,13 +51,13 @@ services:
build: ./interop-tests/peers/dumbclient build: ./interop-tests/peers/dumbclient
image: interop-dumbclient:de0334b image: interop-dumbclient:de0334b
<<: *log-limits <<: *log-limits
network_mode: "service:dumbclient-w2000" network_mode: "service:dumbclient-netns"
environment: environment:
DUMBCLIENT_BIN: /app/smpp-dumb-client-noping DUMBCLIENT_BIN: /app/smpp-dumb-client-noping
command: ["conf/idle.yml"] command: ["conf/idle.yml"]
healthcheck: *dumbclient-healthcheck healthcheck: *dumbclient-healthcheck
depends_on: depends_on:
dumbclient-w2000: dumbclient-netns:
condition: service_started condition: service_started
# The long soak: the longest run the time-box allows, fast handler, watched for anything that # The long soak: the longest run the time-box allows, fast handler, watched for anything that
@@ -56,25 +66,25 @@ services:
build: ./interop-tests/peers/dumbclient build: ./interop-tests/peers/dumbclient
image: interop-dumbclient:de0334b image: interop-dumbclient:de0334b
<<: *log-limits <<: *log-limits
network_mode: "service:dumbclient-w2000" network_mode: "service:dumbclient-netns"
command: ["conf/soak.yml"] command: ["conf/soak.yml"]
healthcheck: *dumbclient-healthcheck healthcheck: *dumbclient-healthcheck
depends_on: depends_on:
dumbclient-w2000: dumbclient-netns:
condition: service_started 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 # sees all four conversations with node:2775 - the same pattern compose.kannel.yaml uses for its
# four bearerbox variants, one namespace deeper. # four bearerbox variants, one namespace deeper.
capture: capture:
image: nicolaka/netshoot:v0.16 image: nicolaka/netshoot:v0.16
network_mode: "service:dumbclient-w2000" network_mode: "service:dumbclient-netns"
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN
- NET_RAW - NET_RAW
depends_on: depends_on:
dumbclient-w2000: dumbclient-netns:
condition: service_healthy condition: service_started
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/dumbclient.pcapng"] command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/dumbclient.pcapng"]
volumes: volumes:
- ./interop-tests/captures:/captures - ./interop-tests/captures:/captures
+22 -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 // S9 (target 11) and the backpressure-at-server scenario: window 2000 at a high rate against a
// handler slowed enough to build a real backlog. window500 is the same shape with a window below // handler slowed enough to build a real backlog. window500 is the same shape with a window below
// maxHeldMessages (1000, session-options.ts defaults.maxHeldMessages) - see findings/07-load.md for // maxHeldMessages (1000, session-options.ts defaults.maxHeldMessages), the bound past which a
// what that constant, rather than maxOutstanding, turns out to be the one that interacts with a // peer's window is answered ESME_RTHROTTLED. smpp-dumb-client counts a throttled message as sent
// peer's window. // and never resends it, so window 2000 accounts for 20,000 as answered plus throttled.
describe('S9 - bounded window against a slowed handler', () => { const throttleMessage = 'session - unanswered messages at their bound, asking the peer to retry';
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);
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 s = statsFor(name);
const expectedCount = 20_000 - throttled(name);
assert.equal(s.arrived, expectedCount); assert.equal(s.arrived, expectedCount);
assert.equal(s.answered, expectedCount); assert.equal(s.answered, expectedCount);
@@ -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 () => { test('window 2000 pressed past maxHeldMessages (1000): the peer is throttled, window500 never is', () => {
await waitFor(() => (statsFor('dumb-w2000').answered >= 20_000 ? true : undefined), 180_000); assert.ok(throttled('dumb-w2000') > 0, 'expected at least one ESME_RTHROTTLED under window 2000');
assert.ok(statsFor('dumb-w2000').peakOutstanding <= 1000);
const evictions = logEntries.filter(entry => entry.message === 'heldMessages - buffer full, dropping the oldest message');
// window500's peak (<=500) never reaches the 1000 default, so any eviction observed is
// necessarily from the w2000 session - the two runs share one server and one log.
assert.ok(evictions.length > 0, 'expected at least one held-message eviction under window 2000');
// The peer's own window, respected exactly both runs (peakOutstanding read 500 and 2000 on
// the nose) - the lower bound is what distinguishes this from window500's own eviction-free run.
assert.ok(statsFor('dumb-w2000').peakOutstanding > 1000 && statsFor('dumb-w2000').peakOutstanding <= 2000);
assert.equal(statsFor('dumb-w500').peakOutstanding <= 500, true); assert.equal(statsFor('dumb-w500').peakOutstanding <= 500, true);
}); });
@@ -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 // 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 // grows without bound (held messages, listeners, memory). Bounded by wall-clock, and asserting that
// target count: smpp-dumb-client's own TX-tracking window bookkeeping stalls under sustained load // arrived/answered stay in lockstep.
// (findings/07-load.md, Peer quirks) well short of the configured count, on the client's side only
// - our own arrived/answered stay in lockstep throughout, which is what this asserts.
describe('Long soak', () => { describe('Long soak', () => {
const SOAK_DURATION_MS = 300_000; const SOAK_DURATION_MS = 300_000;
+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 `rebar.config` pins), which builds the header as plain 32-bit bit-syntax
(`<<Len:32, CmdId:32, 0:32, SeqNum:32>>`) - correct on inspection, so the corruption happens (`<<Len:32, CmdId:32, 0:32, SeqNum:32>>`) - correct on inspection, so the corruption happens
somewhere between that call and the socket write, not chased further given the time-box. Reproduced somewhere between that call and the socket write, not chased further given the time-box. Reproduced
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 keeps a live reproducer asserting what our server does when it receives it (refuses the stream as
unframeable - see Scenarios) rather than removing the peer. `smpp-dumb-client` covers S9, and unframeable - see Scenarios) rather than removing the peer. `smpp-dumb-client` covers S9, and
substitutes for S6 and (partially) S8 - see below. substitutes for S6 and (partially) S8 - see below.
### smpp-dumb-client: builds and interoperates cleanly; 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 No build friction. Two binaries from the same pinned source: `smpp-dumb-client` (unmodified) and
`smpp-dumb-client-noping` (its two `enquireSender()` call sites in `smpp.go` commented out at build `smpp-dumb-client-noping` (its two `enquireSender()` call sites in `smpp.go` commented out at build
@@ -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, there directly. Fixed in the entrypoint: every `conf/*.yml` carries a `NODE_HOST` placeholder,
resolved with `getent hosts` and substituted into a writable copy before the real binary starts. resolved with `getent hosts` and substituted into a writable copy before the real binary starts.
Four one-shot scenarios share `dumbclient-w2000`'s network namespace (`network_mode: The four scenarios share one network namespace, owned by `dumbclient-netns`, a container that
"service:dumbclient-w2000"`) - they are pure outbound clients with nothing of their own listening, never exits - they are pure outbound clients with nothing of their own listening, so the only shared
so the only shared cost is a source IP, and one capture sidecar sees all four conversations with cost is a source IP, and one capture sidecar sees all four conversations with `node:2775` the same
`node:2775` the same way `compose.kannel.yaml`'s does for its four bearerbox variants. 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 Runs 1 and 2 had `dumbclient-w2000` own the namespace. It exits once it has sent its 20,000, which
a fast, immediate-response handler and a window of 100 - nothing our server should ever have took every other client's network with it: the soak's responses stopped arriving, and its log filled
trouble draining - the peer's own reported in-flight count (`GetTrackQueueSize`, read from with `Expired TX packet` lines (`libsmpp`'s 7000ms `TX_MAX_TIMEOUT_MS`). Those runs read that as the
`len(TrackTX)`) gets stuck pinned at the window within the first minute, and its log fills with peer's own window bookkeeping stalling; run 3 (2026-09-26), with the namespace owned by a container
`Expired TX packet` lines (`libsmpp`'s hardcoded, non-configurable 7000ms `TX_MAX_TIMEOUT_MS`) - that outlives them all, reached 173,820 soak messages in 300s where run 2 reached 22,440. The soak
throughput drops from ~500/s to a trickle of tens per second, gated by how many tracked entries stays bounded by wall-clock (5 minutes), asserting every arrival answered, nothing duplicated, and
individually cross that 7s mark each second rather than by real responses being matched. Our own the memory shape.
server-side counters (`arrived`/`answered`/`peakOutstanding`, tracked independently in
`dumbclient.test.ts`) stay in lockstep throughout with a low peak - see Scenarios - which places the
stall entirely on the peer's own window bookkeeping, not on anything our server did or failed to
do. The soak test was redesigned around this: bounded by wall-clock (5 minutes) rather than a
target count, asserting the invariants that matter regardless of how much the peer's own bug lets
through (every arrival answered, nothing duplicated, memory shape), and reporting whatever
throughput was actually reached rather than requiring a specific one.
One test-harness bug found and fixed between the two runs below, not a library defect: the S6 test's One test-harness bug found and fixed between the two runs below, not a library defect: the S6 test's
first version attached its `session.on('close', ...)` listener lazily inside the test body, after first version attached its `session.on('close', ...)` listener lazily inside the test body, after
@@ -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 event to a listener added after it fired. Fixed by attaching every session's `close` listener at
`session`-creation time, recording it in the same per-scenario stats every other assertion reads. `session`-creation time, recording it in the same per-scenario stats every other assertion reads.
Two runs of `./interop-tests/run.py dumbclient`. Run 1 (the original 300,000-count soak) surfaced Three 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 the S6 harness bug above; run 2 fixed it; run 3, with the namespace owner above and the held-message
reported below. `smppload.test.ts` passed on every run it was given (three, across the investigation throttle in `src/`, is the one Scenarios reports. The capture figures below are run 2's.
above); its one scenario needs no repeat - a second run reproduces the identical corrupted PDU, `smppload.test.ts` passed on every run it was given (three, across the investigation above); its one
adding nothing. 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 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 ## Throughput and memory
`dumb-w500` and `dumb-w2000` (S9) both ran to their full 20,000-message count in ~44s each, Run 3. `dumb-w500` ran to its full 20,000 in ~44s against a handler serialised to answer roughly
concurrently, against a handler serialised to answer roughly one message every 2ms one message every 2ms (`SLOW_HANDLER_DELAY_MS`), `peakOutstanding` exactly 500. `dumb-w2000`, run
(`SLOW_HANDLER_DELAY_MS`) - `peakOutstanding` read exactly 500 and exactly 2000, the two configured concurrently, held exactly 1000 and was throttled for the rest.
windows, confirming the peer never let more than its own window ride at once.
The soak (fast, immediate-response handler; window 100) reached 22,440 `submit_sm` over its fixed The soak (fast, immediate-response handler; window 100) reached 173,820 `submit_sm` over its fixed
300s observation window - about 75/s, well under the peer's own configured `rate: 500` and under 300s, about 580/s, `peakOutstanding` 15. Sampled every 5s across the whole run (69 samples over
what our server can sustain (see Setup: `smpp-dumb-client`'s own TX-tracking bookkeeping is the 340s, all four scenarios combined, the harness's own per-message bookkeeping included): rss
ceiling here, not our server - `peakOutstanding` stayed at 25 throughout). Sampled every 5s across first=165MiB, min=165MiB, max=298MiB, last=298MiB, heapUsed at the last sample 81MiB.
the whole run (69 samples over 340s, all four scenarios combined): rss first=170MiB, min=124MiB,
max=306MiB (during the two window runs' backlog), last=125MiB, heapUsed at the last sample 15MiB -
back below its own starting point once the backlog drained, not merely flat. No monotonic trend in
either direction.
## Scenarios (PLAN.md) ## Scenarios (PLAN.md)
| Id | Result | Evidence | | Id | Result | Evidence |
| --- | --- | --- | | --- | --- | --- |
| S6 (idleTimeout, no peer ever pings) | pass | `dumbclient.test.ts` "S6 - idle peer..." - dropped at idleTimeout, `linkTimers - closing an idle peer` logged, no response past the one owed | | S6 (idleTimeout, no peer ever pings) | pass | `dumbclient.test.ts` "S6 - idle peer..." - dropped at idleTimeout, `linkTimers - closing an idle peer` logged, no response past the one owed |
| S8 (throughput, long messages, receipts) | blocked (smppload) / partial substitute | smppload's own scenario is blocked - see Setup. The soak below gives a genuine submit_sm/s figure without long messages or receipts, which `smpp-dumb-client` does not support (`research/esme-clients-and-validators.md` section B) - and is itself capped well below what our server can sustain by the peer's own TX-tracking stall, also see Setup | | 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 | `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 | | 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 | 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) | | 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 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 | | 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 | | smppload bind corruption (not in PLAN.md - found this phase) | blocked | `smppload.test.ts` - our server refuses the unreadable stream instead of hanging |
## Defects in @larvit/smpp ## Defects in @larvit/smpp
@@ -136,19 +145,16 @@ either direction.
None found. `smppload.test.ts`'s own scenario is smppload's defect, not ours: our server's reaction None found. `smppload.test.ts`'s own scenario is smppload's defect, not ours: our server's reaction
(refusing the stream as unframeable, per the decision in the root `AGENTS.md`, "A stream this (refusing the stream as unframeable, per the decision in the root `AGENTS.md`, "A stream this
library cannot frame...") is the documented behaviour working exactly as designed against a peer library cannot frame...") is the documented behaviour working exactly as designed against a peer
that never gets as far as a readable PDU. The soak's throughput ceiling is the peer's own defect that never gets as far as a readable PDU.
(see Setup) - our own `arrived`/`answered`/`peakOutstanding` counters stayed clean throughout every
run.
## Peer quirks ## Peer quirks
- **smppload's `bind_transceiver` is corrupted on the wire** - see Setup. Not chased past `oserl`'s - **smppload's `bind_transceiver` is corrupted on the wire** - see Setup. Not chased past `oserl`'s
`pack/2` (which is correct on inspection) given the time-box. `pack/2` (which is correct on inspection) given the time-box.
- **`smpp-dumb-client`'s window bookkeeping stalls under sustained load, throttling its own - **`smpp-dumb-client` treats `ESME_RTHROTTLED` as final** - a throttled message counts as sent
throughput far below what a promptly-answering server can sustain** - see Setup. Its `enquire_link` and is never resubmitted. Its `enquire_link` interval (10s once bound as an ESME) is hardcoded
interval (10s once bound as an ESME) is also hardcoded (`smpp.go`, `enquireSender(10)`), not (`smpp.go`, `enquireSender(10)`), not exposed through `config.yml` at all - the no-ping binary
exposed through `config.yml` at all - the no-ping binary built for S6 patches the call site out built for S6 patches the call site out rather than configuring it.
rather than configuring it.
- **`smpp.remote` takes a literal IP, never a hostname** (`net.ParseIP`, no DNS resolution) - see - **`smpp.remote` takes a literal IP, never a hostname** (`net.ParseIP`, no DNS resolution) - see
Setup. Setup.
@@ -156,7 +162,3 @@ run.
- Whether smppload's bind corruption is in `oserl`'s `gen_esme_session`/`smpp_session` send path - Whether smppload's bind corruption is in `oserl`'s `gen_esme_session`/`smpp_session` send path
(not reached, given the time-box) or something specific to this build's dependency versions. (not reached, given the time-box) or something specific to this build's dependency versions.
- Whether `smpp-dumb-client`'s stall is a sequence-number correlation bug (a response failing to
match its `TrackTX` entry, falling back to the 7s expiry) or something else in its own window
accounting - not chased past the observation in Setup, given the time-box and that the fault is
clearly on the peer's side (our own counters stayed clean throughout).
@@ -64,6 +64,7 @@ public final class Driver {
server.createContext("/bind", Driver::handleBind); server.createContext("/bind", Driver::handleBind);
server.createContext("/unbind", Driver::handleUnbind); server.createContext("/unbind", Driver::handleUnbind);
server.createContext("/submit", Driver::handleSubmit); server.createContext("/submit", Driver::handleSubmit);
server.createContext("/load", Driver::handleLoad);
server.createContext("/windowBurst", Driver::handleWindowBurst); server.createContext("/windowBurst", Driver::handleWindowBurst);
server.createContext("/sendWindowSize", Driver::handleSendWindowSize); server.createContext("/sendWindowSize", Driver::handleSendWindowSize);
server.setExecutor(null); server.setExecutor(null);
@@ -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. */ /** Fires `count` submits at once, each tagged by index in its text, to probe window pressure. */
private static void handleWindowBurst(HttpExchange exchange) { private static void handleWindowBurst(HttpExchange exchange) {
Map<String, String> p = queryParams(exchange); Map<String, String> p = queryParams(exchange);
@@ -38,6 +38,10 @@ import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/** /**
* An HTTP-driven jsmpp ESME: each request binds (if needed), performs one scenario action against * An HTTP-driven jsmpp ESME: each request binds (if needed), performs one scenario action against
@@ -64,6 +68,7 @@ public final class Driver {
server.createContext("/unbind", Driver::handleUnbind); server.createContext("/unbind", Driver::handleUnbind);
server.createContext("/enquireLink", Driver::handleEnquireLink); server.createContext("/enquireLink", Driver::handleEnquireLink);
server.createContext("/submit", Driver::handleSubmit); server.createContext("/submit", Driver::handleSubmit);
server.createContext("/load", Driver::handleLoad);
server.createContext("/querySm", exchange -> handleUnhandledCommand(exchange, "query")); server.createContext("/querySm", exchange -> handleUnhandledCommand(exchange, "query"));
server.createContext("/cancelSm", exchange -> handleUnhandledCommand(exchange, "cancel")); server.createContext("/cancelSm", exchange -> handleUnhandledCommand(exchange, "cancel"));
server.createContext("/replaceSm", exchange -> handleUnhandledCommand(exchange, "replace")); server.createContext("/replaceSm", exchange -> handleUnhandledCommand(exchange, "replace"));
@@ -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, private static void submitPlain(SMPPSession session, String from, String to, String text, String encoding,
java.util.List<Map<String, Object>> segments) throws Exception { java.util.List<Map<String, Object>> segments) throws Exception {
SubmitSmResult r = session.submitShortMessage("CMT", SubmitSmResult r = session.submitShortMessage("CMT",
+1 -1
View File
@@ -62,6 +62,6 @@ describe('smppload (blocked)', () => {
await waitFor(() => (closed ? true : undefined), 5000); await waitFor(() => (closed ? true : undefined), 5000);
assert.equal(closed, true); assert.equal(closed, true);
assert.ok(bound); assert.ok(bound);
assert.equal(bound.loggedIn, false); assert.equal(bound.boundAs, undefined);
}); });
}); });
+34 -9
View File
@@ -8,7 +8,7 @@ export type { BindType };
import { ReconnectLoop } from './reconnect-loop.ts'; import { ReconnectLoop } from './reconnect-loop.ts';
import { Session } from './session.ts'; import { Session } from './session.ts';
import { checkSessionOptions, undeclaredInterfaceVersion } from './session-options.ts'; import { checkSessionOptions } from './session-options.ts';
import { connect as netConnect } from 'node:net'; import { connect as netConnect } from 'node:net';
import { connect as tlsConnect } from 'node:tls'; import { connect as tlsConnect } from 'node:tls';
import { defaultInterfaceVersion } from './defs/constants.ts'; import { defaultInterfaceVersion } from './defs/constants.ts';
@@ -22,6 +22,7 @@ export type ClientOptions = {
addrNpi?: number; addrNpi?: number;
addrTon?: number; addrTon?: number;
bindType?: BindType; bindType?: BindType;
connectTimeout?: number | false;
enquireLinkInterval?: number; enquireLinkInterval?: number;
host?: string; host?: string;
idleTimeout?: number; idleTimeout?: number;
@@ -42,6 +43,7 @@ export type ClientOptions = {
const defaults = { const defaults = {
bindType: 'transceiver', bindType: 'transceiver',
connectTimeout: 10_000,
enquireLinkInterval: 20_000, enquireLinkInterval: 20_000,
host: 'localhost', host: 'localhost',
/** The idle timeout is what notices a dead link, so it has to outlast one silent probe. */ /** 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', username: 'user',
} as const; } 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 }>> { function openSocket(options: ClientOptions): Promise<Result<{ sock: Socket }>> {
const connectTimeout = options.connectTimeout ?? defaults.connectTimeout;
const host = options.host ?? defaults.host; const host = options.host ?? defaults.host;
const port = options.port ?? defaults.port; const port = options.port ?? defaults.port;
const secure = options.tls !== undefined && options.tls !== false; const secure = options.tls !== undefined && options.tls !== false;
@@ -77,11 +102,14 @@ function openSocket(options: ClientOptions): Promise<Result<{ sock: Socket }>> {
return; 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); sock.removeListener('error', onError);
signal?.removeEventListener('abort', onAbort); signal?.removeEventListener('abort', onAbort);
resolve(result); resolve(result);
}; }
function onError(err: Error): void { function onError(err: Error): void {
settle({ err }); settle({ err });
@@ -146,13 +174,10 @@ async function bind(session: Session, options: ClientOptions): Promise<VoidResul
return { err: new Error(`Remote host refused login: ${sent.pduObj.cmdStatus ?? 'unknown'}`) }; return { err: new Error(`Remote host refused login: ${sent.pduObj.cmdStatus ?? 'unknown'}`) };
} }
const declared = sent.pduObj.tlvs.sc_interface_version?.tagValue; const recorded = session.bound(bindType, sent.pduObj.tlvs.sc_interface_version?.tagValue);
if (recorded.err) return recorded;
session.boundAs = bindType;
session.loggedIn = true;
session.peerInterfaceVersion = typeof declared === 'number'
? declared
: undeclaredInterfaceVersion;
session.log.info('client - bound', { bindType, systemId }); session.log.info('client - bound', { bindType, systemId });
return {}; return {};
-2
View File
@@ -4,7 +4,6 @@ import { buffer, cstring, dest_address_array, int8, unsuccess_sme_array } from '
type CommandSpec = { type CommandSpec = {
id: number; id: number;
params?: Record<string, WireType>; params?: Record<string, WireType>;
tlvMap?: Record<string, string>;
}; };
const bindParams = { const bindParams = {
@@ -59,7 +58,6 @@ const specs = {
broadcast_sm_resp: { broadcast_sm_resp: {
id: 0x80000111, id: 0x80000111,
params: { message_id: cstring }, params: { message_id: cstring },
tlvMap: { broadcast_area_identifier: 'failed_broadcast_area_identifier' },
}, },
cancel_broadcast_sm: { cancel_broadcast_sm: {
id: 0x00000113, id: 0x00000113,
+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 type { Result } from '../result.ts';
import { tlv } from './types.ts'; import { tlv } from './types.ts';
export type TlvDefinition = { /** Only a tag read as octets or as a number may repeat, since its occurrences are listed as one of those. */
id: number; type Definition<Tag> = { id: number; multiple?: false; tag: Tag; type: WireType<Buffer | number | string> }
multiple?: boolean; | { id: number; multiple: true; tag: Tag; type: WireType<Buffer> | WireType<number> };
tag: string;
type: WireType; export type TlvDefinition = Definition<string>;
};
/** The constraint keys every definition to its own name, so a `tag` that drifts fails to compile. */ /** The constraint keys every definition to its own name, so a `tag` that drifts fails to compile. */
const tlvSpecs = <T extends { [K in keyof T]: { id: number; multiple?: boolean; tag: K; type: WireType } }>( const tlvSpecs = <T extends { [K in keyof T]: Definition<K> }>(definitions: T): T => definitions;
definitions: T,
): T => definitions;
// Ordered by tag id, mirroring the SMPP 5.0 TLV table. // Ordered by tag id, mirroring the SMPP 5.0 TLV table.
const specs = tlvSpecs({ const specs = tlvSpecs({
@@ -82,15 +79,18 @@ const specs = tlvSpecs({
its_session_info: { id: 0x1383, tag: 'its_session_info', type: tlv.buffer }, 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> = { export type TlvName = keyof Specs;
...specs,
// Alternate spellings; the definition behind each keeps its canonical name. // SMPP 5.0's other spellings, which only name the tag to key instead.
alert_on_msg_delivery: specs.alert_on_message_delivery, const alternates: Record<string, TlvName> = {
failed_broadcast_area_identifier: specs.broadcast_area_identifier, 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> = {}; export const tlvsById: Record<number, TlvDefinition> = {};
for (const definition of Object.values<TlvDefinition>(specs)) { 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. */ /** 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 = { type Repeated<K extends TlvName, V> = Specs[K] extends { multiple: true } ? V[] : V;
tagId: number;
tagName: string | undefined;
tagValue: ParamValue;
};
export type TlvInput = { type WireValue<K extends TlvName> = Specs[K]['type']['default'];
/** Resolved from the record key; pass it for a tag the TLV table does not define. */
tagId?: number | undefined;
tagValue: ParamValue;
};
export function tagIdOf(name: string, input: TlvInput): Result<{ tagId: number }> { type ReadValue<K extends TlvName> = Repeated<K, WireValue<K>>;
const tagId = input.tagId ?? tlvs[name]?.id;
if (tagId === undefined) { /** A lone text field also takes a number, and an octet field text, which goes out as latin1. */
return { err: new Error(`TLV "${name}": unknown tag name, give it a tagId`) }; 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) { const tagId = Number(name);
return { err: new Error(`TLV "${name}": tagId ${String(tagId)} out of range 0-65535`) };
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. */ /** 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[] = []; const chunks: Buffer[] = [];
for (const [name, input] of Object.entries(inputs ?? {})) { for (const [name, input] of Object.entries<unknown>(inputs ?? {})) {
const tag = tagIdOf(name, input); const tag = entryOf(name, input);
if (tag.err) return { err: tag.err }; if (tag.err) return { err: tag.err };
const type = tlvsById[tag.tagId]?.type ?? tlvDefault; const definition = tlvsById[tag.tagId];
const sized = type.size(input.tagValue); const values = occurrences(tag.tagValue, definition?.multiple === true);
if (sized.err) { if (values.err) return { err: new Error(`TLV "${name}": ${values.err.message}`) };
return { err: new Error(`TLV "${name}": ${sized.err.message}`) };
for (const value of values.values) {
const chunk = writeTlv(tag.tagId, definition?.type ?? tlvDefault, value);
if (chunk.err) return { err: new Error(`TLV "${name}": ${chunk.err.message}`) };
chunks.push(chunk.chunk);
} }
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 }; 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 };
}
+143 -54
View File
@@ -1,4 +1,5 @@
import type { Result, VoidResult } from '../result.ts'; import type { Result, VoidResult } from '../result.ts';
import { unencodableText } from './encodings.ts';
export type DestAddress = export type DestAddress =
| { dest_addr_npi: number; dest_addr_ton: number; destination_addr: string } | { dest_addr_npi: number; dest_addr_ton: number; destination_addr: string }
@@ -13,6 +14,24 @@ export type UnsuccessSme = {
export type ParamValue = Buffer | DestAddress[] | UnsuccessSme[] | number | string; export type ParamValue = Buffer | DestAddress[] | UnsuccessSme[] | number | string;
/** A tag defined `multiple` holds every occurrence, in wire order; any other tag holds one value. */
export type TlvValue = Buffer | Buffer[] | number | number[] | string;
/** Octets a value holds, counting a string by its length. */
export function tlvOctets(value: TlvValue): number {
if (Buffer.isBuffer(value)) return value.length;
if (typeof value === 'string') return value.length;
if (typeof value === 'number') return 0;
let octets = 0;
for (const one of value) {
octets += typeof one === 'number' ? 0 : one.length;
}
return octets;
}
/** /**
* One field on the wire. `read` reports how many octets it consumed so callers never have to * One field on the wire. `read` reports how many octets it consumed so callers never have to
* re-derive a length that could disagree with what was actually written. * re-derive a length that could disagree with what was actually written.
@@ -25,10 +44,10 @@ export type WireType<T extends ParamValue = ParamValue> = {
}; };
/** Renders a parameter as text without ever falling back to "[object Object]". */ /** Renders a parameter as text without ever falling back to "[object Object]". */
export function paramText(value: ParamValue | undefined): string { export function paramText(value: ParamValue | TlvValue | undefined): string {
if (typeof value === 'string') return value; if (typeof value === 'string') return value;
if (typeof value === 'number') return value.toString(); if (typeof value === 'number') return value.toString();
if (Buffer.isBuffer(value)) return value.toString('ascii'); if (Buffer.isBuffer(value)) return value.toString('latin1');
return ''; return '';
} }
@@ -37,6 +56,11 @@ export function paramNumber(value: ParamValue | undefined, fallback: number): nu
return typeof value === 'number' ? value : fallback; return typeof value === 'number' ? value : fallback;
} }
/** Spells a refused value for the caller who wrote it; JSON spells NaN and the infinities `null`. */
export function valueText(value: ParamValue): string {
return typeof value === 'number' ? String(value) : JSON.stringify(value);
}
function outOfRange(buffer: Buffer, offset: number, needed: number): Error | undefined { function outOfRange(buffer: Buffer, offset: number, needed: number): Error | undefined {
if (offset < 0 || needed < 0 || offset + needed > buffer.length) { if (offset < 0 || needed < 0 || offset + needed > buffer.length) {
return new Error( return new Error(
@@ -49,7 +73,7 @@ function outOfRange(buffer: Buffer, offset: number, needed: number): Error | und
function wantInt(value: ParamValue, max: number): Result<{ int: number }> { function wantInt(value: ParamValue, max: number): Result<{ int: number }> {
if (typeof value !== 'number' || !Number.isInteger(value)) { if (typeof value !== 'number' || !Number.isInteger(value)) {
return { err: new Error(`Expected an integer, got ${JSON.stringify(value)}`) }; return { err: new Error(`Expected an integer, got ${valueText(value)}`) };
} }
if (value < 0 || value > max) { if (value < 0 || value > max) {
@@ -79,11 +103,46 @@ function writeInt32(value: ParamValue, buf: Buffer, offset: number): VoidResult
return {}; return {};
} }
function wantText(value: ParamValue): Result<{ text: string }> { function pastLatin1(text: string): { err: Error } | undefined {
if (typeof value === 'string') return { text: value }; const index = text.search(/[\u0100-\uFFFF]/);
if (typeof value === 'number') return { text: value.toString() };
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 }> { function wantBytes(value: ParamValue): Result<{ bytes: Buffer }> {
@@ -91,7 +150,7 @@ function wantBytes(value: ParamValue): Result<{ bytes: Buffer }> {
const { err, text } = wantText(value); const { err, text } = wantText(value);
return err ? { err } : { bytes: Buffer.from(text, 'ascii') }; return err ? { err } : { bytes: Buffer.from(text, 'latin1') };
} }
function isDestAddress(value: unknown): value is DestAddress { 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 { 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 }; if (err) return { err };
buffer.write(text, offset, 'ascii'); buffer.write(text, offset, 'latin1');
buffer[offset + text.length] = 0; buffer[offset + text.length] = 0;
return {}; return {};
@@ -248,7 +307,7 @@ export const string: WireType<string> = {
if (err) return { err }; 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) { size(value) {
const { err, text } = wantText(value); const { err, text } = wantText(value);
@@ -271,7 +330,7 @@ export const string: WireType<string> = {
if (rangeErr) return { err: rangeErr }; if (rangeErr) return { err: rangeErr };
buffer.writeUInt8(text.length, offset); buffer.writeUInt8(text.length, offset);
buffer.write(text, offset + 1, 'ascii'); buffer.write(text, offset + 1, 'latin1');
return {}; return {};
}, },
@@ -288,12 +347,12 @@ export const cstring: WireType<string> = {
default: '', default: '',
read: readCstring, read: readCstring,
size(value) { size(value) {
const { err, text } = wantText(value); const { err, text } = wantCstringText(value);
return err ? { err } : { size: text.length + 1 }; return err ? { err } : { size: text.length + 1 };
}, },
write(value, buffer, offset) { write(value, buffer, offset) {
const { err, text } = wantText(value); const { err, text } = wantCstringText(value);
return err ? { err } : writeCstring(text, buffer, offset); return err ? { err } : writeCstring(text, buffer, offset);
}, },
@@ -326,14 +385,52 @@ export const buffer: WireType<Buffer> = {
}, },
}; };
function sizeDestAddresses(addresses: DestAddress[]): number { function writeSizedCstring(value: ParamValue, buf: Buffer, offset: number): Result<{ size: number }> {
const { err, size } = cstring.size(value);
if (err) return { err };
const written = cstring.write(value, buf, offset);
return written.err ? { err: written.err } : { size };
}
function sizeDestAddresses(addresses: DestAddress[]): Result<{ size: number }> {
let size = 1; let size = 1;
for (const dest of addresses) { for (const dest of addresses) {
size += 'dl_name' in dest ? dest.dl_name.length + 2 : dest.destination_addr.length + 4; const [header, text] = 'dl_name' in dest ? [1, cstring.size(dest.dl_name)] : [3, cstring.size(dest.destination_addr)];
if (text.err) return { err: text.err };
size += header + text.size;
} }
return size; return { size };
}
function writeDestAddress(dest: DestAddress, buf: Buffer, offset: number): Result<{ size: number }> {
if ('dl_name' in dest) {
buf.writeUInt8(2, offset);
const name = writeSizedCstring(dest.dl_name, buf, offset + 1);
return name.err ? { err: name.err } : { size: 1 + name.size };
}
buf.writeUInt8(1, offset);
const ton = writeInt8(dest.dest_addr_ton, buf, offset + 1);
if (ton.err) return { err: ton.err };
const npi = writeInt8(dest.dest_addr_npi, buf, offset + 2);
if (npi.err) return { err: npi.err };
const addr = writeSizedCstring(dest.destination_addr, buf, offset + 3);
return addr.err ? { err: addr.err } : { size: 3 + addr.size };
} }
export const dest_address_array: WireType<DestAddress[]> = { export const dest_address_array: WireType<DestAddress[]> = {
@@ -382,14 +479,18 @@ export const dest_address_array: WireType<DestAddress[]> = {
size(value) { size(value) {
const { addresses, err } = wantDestAddresses(value); const { addresses, err } = wantDestAddresses(value);
return err ? { err } : { size: sizeDestAddresses(addresses) }; return err ? { err } : sizeDestAddresses(addresses);
}, },
write(value, buf, offset) { write(value, buf, offset) {
const { addresses, err } = wantDestAddresses(value); const { addresses, err } = wantDestAddresses(value);
if (err) return { err }; if (err) return { err };
const rangeErr = outOfRange(buf, offset, sizeDestAddresses(addresses)); const total = sizeDestAddresses(addresses);
if (total.err) return { err: total.err };
const rangeErr = outOfRange(buf, offset, total.size);
if (rangeErr) return { err: rangeErr }; if (rangeErr) return { err: rangeErr };
@@ -398,45 +499,29 @@ export const dest_address_array: WireType<DestAddress[]> = {
if (count.err) return { err: count.err }; if (count.err) return { err: count.err };
for (const dest of addresses) { for (const dest of addresses) {
if ('dl_name' in dest) { const written = writeDestAddress(dest, buf, offset);
buf.writeUInt8(2, offset++);
const name = writeCstring(dest.dl_name, buf, offset); if (written.err) return { err: written.err };
if (name.err) return { err: name.err }; offset += written.size;
offset += dest.dl_name.length + 1;
} else {
buf.writeUInt8(1, offset++);
const ton = writeInt8(dest.dest_addr_ton, buf, offset++);
if (ton.err) return { err: ton.err };
const npi = writeInt8(dest.dest_addr_npi, buf, offset++);
if (npi.err) return { err: npi.err };
const addr = writeCstring(dest.destination_addr, buf, offset);
if (addr.err) return { err: addr.err };
offset += dest.destination_addr.length + 1;
}
} }
return {}; return {};
}, },
}; };
function sizeUnsuccessSmes(smes: UnsuccessSme[]): number { function sizeUnsuccessSmes(smes: UnsuccessSme[]): Result<{ size: number }> {
let size = 1; let size = 1;
for (const sme of smes) { for (const sme of smes) {
size += sme.destination_addr.length + 7; const addr = cstring.size(sme.destination_addr);
if (addr.err) return { err: addr.err };
size += addr.size + 6;
} }
return size; return { size };
} }
export const unsuccess_sme_array: WireType<UnsuccessSme[]> = { export const unsuccess_sme_array: WireType<UnsuccessSme[]> = {
@@ -481,14 +566,18 @@ export const unsuccess_sme_array: WireType<UnsuccessSme[]> = {
size(value) { size(value) {
const { err, smes } = wantUnsuccessSmes(value); const { err, smes } = wantUnsuccessSmes(value);
return err ? { err } : { size: sizeUnsuccessSmes(smes) }; return err ? { err } : sizeUnsuccessSmes(smes);
}, },
write(value, buf, offset) { write(value, buf, offset) {
const { err, smes } = wantUnsuccessSmes(value); const { err, smes } = wantUnsuccessSmes(value);
if (err) return { err }; if (err) return { err };
const rangeErr = outOfRange(buf, offset, sizeUnsuccessSmes(smes)); const total = sizeUnsuccessSmes(smes);
if (total.err) return { err: total.err };
const rangeErr = outOfRange(buf, offset, total.size);
if (rangeErr) return { err: rangeErr }; if (rangeErr) return { err: rangeErr };
@@ -505,11 +594,11 @@ export const unsuccess_sme_array: WireType<UnsuccessSme[]> = {
if (npi.err) return { err: npi.err }; if (npi.err) return { err: npi.err };
const addr = writeCstring(sme.destination_addr, buf, offset); const addr = writeSizedCstring(sme.destination_addr, buf, offset);
if (addr.err) return { err: addr.err }; if (addr.err) return { err: addr.err };
offset += sme.destination_addr.length + 1; offset += addr.size;
const status = writeInt32(sme.error_status_code, buf, offset); const status = writeInt32(sme.error_status_code, buf, offset);
@@ -538,15 +627,15 @@ export const tlv = {
? offset + length ? offset + length
: terminator; : terminator;
return { bytesRead: length, value: buf.toString('ascii', offset, end) }; return { bytesRead: length, value: buf.toString('latin1', offset, end) };
}, },
size(value: ParamValue) { size(value: ParamValue) {
const { err, text } = wantText(value); const { err, text } = wantCstringText(value);
return err ? { err } : { size: text.length + 1 }; return err ? { err } : { size: text.length + 1 };
}, },
write(value: ParamValue, buf: Buffer, offset: number) { write(value: ParamValue, buf: Buffer, offset: number) {
const { err, text } = wantText(value); const { err, text } = wantCstringText(value);
return err ? { err } : writeCstring(text, buf, offset); return err ? { err } : writeCstring(text, buf, offset);
}, },
@@ -559,7 +648,7 @@ export const tlv = {
read(buf: Buffer, offset: number, length = 0) { read(buf: Buffer, offset: number, length = 0) {
const err = outOfRange(buf, offset, length); const err = outOfRange(buf, offset, length);
return err ? { err } : { bytesRead: length, value: buf.toString('ascii', offset, offset + length) }; return err ? { err } : { bytesRead: length, value: buf.toString('latin1', offset, offset + length) };
}, },
size(value: ParamValue) { size(value: ParamValue) {
const { err, text } = wantText(value); const { err, text } = wantText(value);
@@ -575,7 +664,7 @@ export const tlv = {
if (rangeErr) return { err: rangeErr }; if (rangeErr) return { err: rangeErr };
buf.write(text, offset, 'ascii'); buf.write(text, offset, 'latin1');
return {}; return {};
}, },
+4 -4
View File
@@ -1,5 +1,5 @@
import type { MessageState } from './defs/constants.ts'; import type { MessageState } from './defs/constants.ts';
import type { ParamValue } from './defs/types.ts'; import type { TlvValue } from './defs/types.ts';
import type { PduObject } from './pdu.ts'; import type { PduObject } from './pdu.ts';
import type { SmsIdFormat } from './sms-id.ts'; import type { SmsIdFormat } from './sms-id.ts';
import { consts, constsById, hasUdh, messageTypeOf } from './defs/constants.ts'; import { consts, constsById, hasUdh, messageTypeOf } from './defs/constants.ts';
@@ -150,7 +150,7 @@ const smeMessageTypes: readonly number[] = [
consts.ESM_CLASS.USER_ACKNOWLEDGEMENT, consts.ESM_CLASS.USER_ACKNOWLEDGEMENT,
]; ];
function nonEmptyText(value: ParamValue | undefined): string | undefined { function nonEmptyText(value: TlvValue | undefined): string | undefined {
return typeof value === 'string' && value !== '' ? value : undefined; return typeof value === 'string' && value !== '' ? value : undefined;
} }
@@ -178,7 +178,7 @@ function receiptBody(pduObj: PduObject): string {
} }
function receiptId( function receiptId(
tlvId: ParamValue | undefined, tlvId: TlvValue | undefined,
receipt: Receipt | undefined, receipt: Receipt | undefined,
format: SmsIdFormat, format: SmsIdFormat,
): string | undefined { ): string | undefined {
@@ -198,7 +198,7 @@ function isMessageState(name: string | undefined): name is MessageState {
/** The state TLV wins where it names a state we know; an unnameable one leaves the body to say. */ /** The state TLV wins where it names a state we know; an unnameable one leaves the body to say. */
function receiptStatus( function receiptStatus(
tlvState: ParamValue | undefined, tlvState: TlvValue | undefined,
receipt: Receipt | undefined, receipt: Receipt | undefined,
): { statusId: number; statusMsg: MessageState | undefined } { ): { statusId: number; statusMsg: MessageState | undefined } {
const scraped = receiptStates[receipt?.stat?.toUpperCase() ?? '']; const scraped = receiptStates[receipt?.stat?.toUpperCase() ?? ''];
+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 { 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;
} }
+48 -12
View File
@@ -1,8 +1,10 @@
export type ExpiringGroupsOptions = { export type ExpiringGroupsOptions = {
max: number; max: number;
/** Enforced by weigh() alone; set() never evicts. */
maxWeight?: number | undefined;
/** Injected so expiry can be exercised without a wall clock. */ /** Injected so expiry can be exercised without a wall clock. */
now?: (() => number) | undefined; now?: (() => number) | undefined;
/** Runs on the sweeper's own timer; the owner reports whatever it takes out. */ /** Must call takeExpired(): the timer itself removes nothing. */
onSweep: () => void; onSweep: () => void;
timeout: number; timeout: number;
}; };
@@ -10,22 +12,23 @@ export type ExpiringGroupsOptions = {
type Entry<T> = { type Entry<T> = {
deadline: number; deadline: number;
group: T; group: T;
weight: number;
}; };
/** /** Enforces neither max nor timeout itself: owners check full and call takeExpired(); only weigh() evicts. */
* A capped store of groups that expire. Nothing is dropped silently: the owner takes the expired
* and the evicted out itself, so the accounting and the log line stay where the group is understood.
*/
export class ExpiringGroups<T> { export class ExpiringGroups<T> {
private readonly entries = new Map<string, Entry<T>>(); private readonly entries = new Map<string, Entry<T>>();
private readonly max: number; private readonly max: number;
private readonly maxWeight: number;
private readonly now: () => number; private readonly now: () => number;
private readonly onSweep: () => void; private readonly onSweep: () => void;
private readonly timeout: number; private readonly timeout: number;
private sweeper: NodeJS.Timeout | undefined; private sweeper: NodeJS.Timeout | undefined;
private total = 0;
constructor(options: ExpiringGroupsOptions) { constructor(options: ExpiringGroupsOptions) {
this.max = options.max; this.max = options.max;
this.maxWeight = options.maxWeight ?? Infinity;
this.now = options.now ?? Date.now; this.now = options.now ?? Date.now;
this.onSweep = options.onSweep; this.onSweep = options.onSweep;
this.timeout = options.timeout; this.timeout = options.timeout;
@@ -39,13 +42,18 @@ export class ExpiringGroups<T> {
return this.entries.size; return this.entries.size;
} }
get weight(): number {
return this.total;
}
get(key: string): T | undefined { get(key: string): T | undefined {
return this.entries.get(key)?.group; return this.entries.get(key)?.group;
} }
/** Starts the group's deadline, and the sweeper if this is the only group held. */ /** Replacing a key restarts its deadline and zeroes its weight; weigh() it again. */
set(key: string, group: T): void { set(key: string, group: T): void {
this.entries.set(key, { deadline: this.now() + this.timeout, group }); this.remove(key);
this.entries.set(key, { deadline: this.now() + this.timeout, group, weight: 0 });
if (this.sweeper) return; if (this.sweeper) return;
@@ -54,11 +62,31 @@ export class ExpiringGroups<T> {
} }
delete(key: string): void { delete(key: string): void {
this.entries.delete(key); this.remove(key);
this.idle(); this.idle();
} }
/** Removes every group and hands them over, so an owner that must account for them can. */ /** The returned groups are already removed, and may include key itself. */
weigh(key: string, weight: number): [string, T][] {
const entry = this.entries.get(key);
const taken: [string, T][] = [];
if (entry) {
this.total += weight - entry.weight;
entry.weight = weight;
}
while (this.total > this.maxWeight) {
const oldest = this.takeOldest();
if (!oldest) break;
taken.push(oldest);
}
return taken;
}
takeAll(): [string, T][] { takeAll(): [string, T][] {
const taken: [string, T][] = []; const taken: [string, T][] = [];
@@ -67,12 +95,12 @@ export class ExpiringGroups<T> {
} }
this.entries.clear(); this.entries.clear();
this.total = 0;
this.idle(); this.idle();
return taken; return taken;
} }
/** Removes every group past its deadline and hands them over. */
takeExpired(): [string, T][] { takeExpired(): [string, T][] {
const now = this.now(); const now = this.now();
const taken: [string, T][] = []; const taken: [string, T][] = [];
@@ -81,7 +109,7 @@ export class ExpiringGroups<T> {
if (entry.deadline > now) continue; if (entry.deadline > now) continue;
taken.push([key, entry.group]); taken.push([key, entry.group]);
this.entries.delete(key); this.remove(key);
} }
this.idle(); this.idle();
@@ -89,7 +117,6 @@ export class ExpiringGroups<T> {
return taken; return taken;
} }
/** Removes the group held longest and hands it over. Undefined means there was none. */
takeOldest(): [string, T] | undefined { takeOldest(): [string, T] | undefined {
const oldest = this.entries.entries().next(); const oldest = this.entries.entries().next();
@@ -102,6 +129,15 @@ export class ExpiringGroups<T> {
return [key, entry.group]; return [key, entry.group];
} }
private remove(key: string): void {
const entry = this.entries.get(key);
if (!entry) return;
this.entries.delete(key);
this.total -= entry.weight;
}
private idle(): void { private idle(): void {
if (!this.sweeper || this.entries.size > 0) return; if (!this.sweeper || this.entries.size > 0) return;
+93 -23
View File
@@ -2,10 +2,12 @@ import type { PduObject } from './pdu.ts';
import type { SmppLog } from './log.ts'; import type { SmppLog } from './log.ts';
import { ExpiringGroups } from './expiring-groups.ts'; import { ExpiringGroups } from './expiring-groups.ts';
import { IdleWaiters } from './idle-waiters.ts'; import { IdleWaiters } from './idle-waiters.ts';
import { retainedOctets } from './retained-pdu.ts';
export type HeldMessagesOptions = { export type HeldMessagesOptions = {
log: SmppLog; log: SmppLog;
max: number; max: number;
maxOctets: number;
/** Injected so expiry can be exercised without a wall clock. */ /** Injected so expiry can be exercised without a wall clock. */
now?: (() => number) | undefined; now?: (() => number) | undefined;
timeout: number; timeout: number;
@@ -18,12 +20,56 @@ function keyOf(pduObjs: PduObject[]): string | undefined {
return first ? String(first.seqNr) : undefined; return first ? String(first.seqNr) : undefined;
} }
type HoldEntry = {
isHeld: () => boolean;
release: () => void;
};
/**
* One message offered to the application. A drain waits on it until the first of: `answered()`,
* every listener that took it rejecting, no listener taking it or one throwing, a later message on
* its sequence number, its deadline, or the link going.
*/
export class MessageHold {
private readonly entry: HoldEntry;
private working: number;
constructor(entry: HoldEntry, listeners: number) {
this.entry = entry;
this.working = listeners;
}
/** Whether a drain is still waiting for this message to be answered. */
isHeld(): boolean {
return this.entry.isHeld();
}
/** A turn later, so a listener sending its receipt straight after the response still holds. */
answered(): void {
setImmediate(() => { this.entry.release(); });
}
/** A rejection leaves the other listeners running, so only the last one to fail gives the message up. */
listenerGaveUp(): void {
this.working--;
if (this.working <= 0) this.answered();
}
/** At once, for a message nobody took or a listener threw on: that is not work a shutdown can wait for. */
release(): void {
this.entry.release();
}
}
/** The messages handed to the application that it has not answered yet, held by their segments. */ /** The messages handed to the application that it has not answered yet, held by their segments. */
export class HeldMessages { export class HeldMessages {
private readonly held: ExpiringGroups<PduObject[]>; private readonly held: ExpiringGroups<PduObject[]>;
private readonly idleWaiters = new IdleWaiters(); private readonly idleWaiters = new IdleWaiters();
private readonly log: SmppLog; private readonly log: SmppLog;
private readonly max: number; private readonly maxOctets: number;
/** A rejecting listener hands the message back as an `unknown`, so its hold is found by identity. */
private readonly offered = new WeakMap<object, MessageHold>();
constructor(options: HeldMessagesOptions) { constructor(options: HeldMessagesOptions) {
this.held = new ExpiringGroups({ this.held = new ExpiringGroups({
@@ -33,38 +79,75 @@ export class HeldMessages {
timeout: options.timeout, timeout: options.timeout,
}); });
this.log = options.log; this.log = options.log;
this.max = options.max; this.maxOctets = options.maxOctets;
}
get octetsHeld(): number {
return this.held.weight;
} }
get size(): number { get size(): number {
return this.held.size; 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. */
hold(pduObjs: PduObject[]): void { full(): boolean {
this.sweep();
return this.held.full || this.held.weight >= this.maxOctets;
}
private hold(pduObjs: PduObject[], listeners: number): MessageHold {
const hold = new MessageHold({
isHeld: () => this.has(pduObjs),
release: () => { this.release(pduObjs); },
}, listeners);
const key = keyOf(pduObjs); const key = keyOf(pduObjs);
if (key === undefined) return; if (key === undefined) return hold;
this.sweep(); this.sweep();
if (this.held.get(key)) { if (this.held.get(key)) {
this.log.warn('heldMessages - replacing a message on a re-used sequence number', { seqNr: Number(key) }); this.log.warn('heldMessages - replacing a message on a re-used sequence number', { seqNr: Number(key) });
} else if (this.held.full) {
this.dropOldest();
} }
this.held.set(key, pduObjs); this.held.set(key, pduObjs);
this.held.weigh(key, pduObjs.reduce((sum, pduObj) => sum + retainedOctets(pduObj), 0));
return hold;
} }
/** Whether a drain is still waiting for this message to be answered. */ offer<T extends object>(
has(pduObjs: PduObject[]): boolean { pduObjs: PduObject[],
listeners: number,
build: (hold: MessageHold) => T,
emit: (message: T) => boolean,
): MessageHold {
const hold = this.hold(pduObjs, listeners);
const message = build(hold);
this.offered.set(message, hold);
if (!emit(message)) hold.release();
return hold;
}
/** One listener gave up on a message; the last one to do so is what releases it. */
listenerRejected(message: unknown): void {
if (typeof message !== 'object' || message === null) return;
this.offered.get(message)?.listenerGaveUp();
}
private has(pduObjs: PduObject[]): boolean {
const key = keyOf(pduObjs); const key = keyOf(pduObjs);
return key !== undefined && this.held.get(key) === pduObjs; return key !== undefined && this.held.get(key) === pduObjs;
} }
release(pduObjs: PduObject[]): void { private release(pduObjs: PduObject[]): void {
const key = keyOf(pduObjs); const key = keyOf(pduObjs);
// Identity, not the key: a wrapped sequence number must not release someone else's message. // Identity, not the key: a wrapped sequence number must not release someone else's message.
@@ -85,19 +168,6 @@ export class HeldMessages {
return this.idleWaiters.wait(() => this.held.size, timeout, signal); return this.idleWaiters.wait(() => this.held.size, timeout, signal);
} }
private dropOldest(): void {
const oldest = this.held.takeOldest();
if (!oldest) return;
const [seqNr] = oldest;
this.log.warn('heldMessages - buffer full, dropping the oldest message', {
max: this.max,
seqNr: Number(seqNr),
});
}
/** Drops every message past its deadline. Runs before each hold and on its own timer. */ /** Drops every message past its deadline. Runs before each hold and on its own timer. */
sweep(): void { sweep(): void {
const expired = this.held.takeExpired(); const expired = this.held.takeExpired();
+60 -30
View File
@@ -13,11 +13,17 @@ import { Reassembler, decodeSegments } from './reassembly.ts';
import { bindCommands, defaults, standsInFor } from './session-options.ts'; import { bindCommands, defaults, standsInFor } from './session-options.ts';
import { concatOf } from './concat.ts'; import { concatOf } from './concat.ts';
import { createSms } from './sms.ts'; import { createSms } from './sms.ts';
import { detach } from './retained-pdu.ts';
import { dlrFromPdu } from './dlr.ts'; import { dlrFromPdu } from './dlr.ts';
import { paramText } from './defs/types.ts'; import { paramText } from './defs/types.ts';
import { respIdParams, segmentId } from './sms-id.ts'; import { respIdParams, segmentId } from './sms-id.ts';
import { respNameFor } from './defs/commands.ts';
/** Asks the peer to keep the message and retry. */
function throttledStatus(carriedAs: string): ErrorName {
return carriedAs === 'submit_sm' ? 'ESME_RTHROTTLED' : 'ESME_RX_T_APPN';
}
/** SMPP 3.4 lists ESME_RMSGQFUL under submit_sm_resp only; 4.6.2's retryable code is another. */
export function refusedSegmentStatus( export function refusedSegmentStatus(
carriedAs: string, carriedAs: string,
refusal: Refusal, refusal: Refusal,
@@ -28,7 +34,7 @@ export function refusedSegmentStatus(
return spelling === 'sar' ? 'ESME_RINVTLVVAL' : 'ESME_RINVESMCLASS'; 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> = { const lostReasons: Record<LostGroup['reason'], string> = {
@@ -54,8 +60,6 @@ export type IncomingRequestsOptions = {
/** Everything the peer asks of a session: messages, receipts, links and the answers to them. */ /** Everything the peer asks of a session: messages, receipts, links and the answers to them. */
export class IncomingRequests { export class IncomingRequests {
private readonly dlrMerger: DlrMerger; private readonly dlrMerger: DlrMerger;
/** The rejection handler is handed the Sms back as an `unknown`, so its hold is found by identity. */
private readonly emitted = new WeakMap<object, () => void>();
private readonly held: HeldMessages; private readonly held: HeldMessages;
private readonly log: SmppLog; private readonly log: SmppLog;
private readonly onRequest: OnRequest | undefined; private readonly onRequest: OnRequest | undefined;
@@ -65,12 +69,14 @@ export class IncomingRequests {
private readonly smsIdFormat: SmsIdFormat; private readonly smsIdFormat: SmsIdFormat;
private readonly systemId: string; private readonly systemId: string;
private linkGeneration = 0; private linkGeneration = 0;
private refusing = false;
constructor(options: IncomingRequestsOptions) { constructor(options: IncomingRequestsOptions) {
this.dlrMerger = options.dlrMerger; this.dlrMerger = options.dlrMerger;
this.held = new HeldMessages({ this.held = new HeldMessages({
log: options.log, log: options.log,
max: defaults.maxHeldMessages, max: defaults.maxHeldMessages,
maxOctets: defaults.maxHeldOctets,
timeout: defaults.heldMessageTimeout, timeout: defaults.heldMessageTimeout,
}); });
this.log = options.log; this.log = options.log;
@@ -90,8 +96,10 @@ export class IncomingRequests {
async handle(pduObj: PduObject): Promise<void> { async handle(pduObj: PduObject): Promise<void> {
const generation = this.linkGeneration; const generation = this.linkGeneration;
const { onRequest } = this;
if (this.onRequest && await this.onRequest(this.session, pduObj)) return; // Called unbound, so the application's hook never sees this class as its `this`.
if (onRequest && await onRequest(this.session, pduObj)) return;
// The link it arrived on went while the hook ran, so nothing we answer now correlates. // The link it arrived on went while the hook ran, so nothing we answer now correlates.
if (this.linkGeneration !== generation) { if (this.linkGeneration !== generation) {
@@ -141,15 +149,13 @@ export class IncomingRequests {
/** Drops the segments of every message that never became whole, and of every one still held. */ /** Drops the segments of every message that never became whole, and of every one still held. */
clear(): void { clear(): void {
this.linkGeneration++; this.linkGeneration++;
this.refusing = false;
this.held.clear(); this.held.clear();
this.reassembler.clear(); this.reassembler.clear();
} }
/** One `sms` listener gave up on a message; the last one to do so is what releases the hold. */
listenerRejected(sms: unknown): void { listenerRejected(sms: unknown): void {
if (typeof sms !== 'object' || sms === null) return; this.held.listenerRejected(sms);
this.emitted.get(sms)?.();
} }
/** Waits out the messages the application still holds, and says how many it never answered. */ /** Waits out the messages the application still holds, and says how many it never answered. */
@@ -171,6 +177,12 @@ export class IncomingRequests {
return; return;
} }
if (!respNameFor(pduObj.cmdName)) {
this.log.verbose('session - ignoring a command SMPP gives no response', { cmdName: pduObj.cmdName });
return;
}
this.log.info('session - no handler for command', { cmdName: pduObj.cmdName }); this.log.info('session - no handler for command', { cmdName: pduObj.cmdName });
await this.session.sendReturn(pduObj, 'ESME_RINVCMDID'); await this.session.sendReturn(pduObj, 'ESME_RINVCMDID');
} }
@@ -198,15 +210,49 @@ export class IncomingRequests {
await this.session.sendReturn(pduObj); await this.session.sendReturn(pduObj);
} }
private async refusedAtBound(pduObj: PduObject): Promise<boolean> {
if (this.held.full()) {
if (!this.refusing) {
this.refusing = true;
this.log.warn('session - unanswered messages at their bound, refusing new ones until the application answers', {
messages: this.held.size,
octets: this.held.octetsHeld,
});
}
this.log.verbose('session - unanswered messages at their bound, asking the peer to retry', {
cmdName: pduObj.cmdName,
seqNr: pduObj.seqNr,
});
await this.session.sendReturn(pduObj, throttledStatus(this.carriedAs(pduObj)));
return true;
}
// Half, so a peer keeping its window full does not flip this on every answer.
if (
this.refusing
&& this.held.size <= defaults.maxHeldMessages / 2
&& this.held.octetsHeld <= defaults.maxHeldOctets / 2
) {
this.refusing = false;
this.log.info('session - unanswered messages down to half their bound, accepting again', { messages: this.held.size });
}
return false;
}
/** /**
* A concatenated message is answered segment by segment as it arrives: a peer that dispatches * A concatenated message is answered segment by segment as it arrives: a peer that dispatches
* one request at a time never sends the second segment until the first has been answered. * one request at a time never sends the second segment until the first has been answered.
*/ */
private async onMessage(pduObj: PduObject): Promise<void> { private async onMessage(pduObj: PduObject): Promise<void> {
if (await this.refusedAtBound(pduObj)) return;
const concat = concatOf(pduObj); const concat = concatOf(pduObj);
if (!concat) { if (!concat) {
this.emitSms([pduObj]); this.emitSms([detach(pduObj)]);
return; return;
} }
@@ -243,10 +289,8 @@ export class IncomingRequests {
if (!first) return; if (!first) return;
const generation = this.linkGeneration; const generation = this.linkGeneration;
// A turn later, so a listener sending its receipt straight after the response still holds.
const release = (): void => { setImmediate(() => { this.held.release(pduObjs); }); };
const sms = createSms({ this.held.offer(pduObjs, this.session.listenerCount('sms'), hold => createSms({
answeredAs, answeredAs,
from: paramText(first.params.source_addr), from: paramText(first.params.source_addr),
message: decodeSegments(pduObjs), message: decodeSegments(pduObjs),
@@ -255,22 +299,8 @@ export class IncomingRequests {
to: paramText(first.params.destination_addr), to: paramText(first.params.destination_addr),
}, { }, {
lostLink: () => this.linkGeneration !== generation, lostLink: () => this.linkGeneration !== generation,
onAnswered: release, onAnswered: () => { hold.answered(); },
// Past the refusal only while a drain is still waiting for this message; an ordinary send after. send: input => (hold.isHeld() ? this.sendPastDrain(input) : this.session.send(input)),
send: input => (this.held.has(pduObjs) ? this.sendPastDrain(input) : this.session.send(input)), }), sms => this.session.emit('sms', sms));
});
// A rejection leaves the other listeners running, so only the last one to fail gives the message up.
let working = this.session.listenerCount('sms');
this.held.hold(pduObjs);
this.emitted.set(sms, () => {
working--;
if (working <= 0) release();
});
// A message nobody took is not work a shutdown can wait for.
if (!this.session.emit('sms', sms)) this.held.release(pduObjs);
} }
} }
+4 -4
View File
@@ -6,7 +6,7 @@ export { cmds, cmdsById, commandNameById, isCommandName } from './defs/commands.
export { consts, constsById } from './defs/constants.ts'; export { consts, constsById } from './defs/constants.ts';
export { dataCodingByEncoding, detect, encodingByDataCoding, encodings, isEncodingName, messageClassOf, unencodable } from './defs/encodings.ts'; export { dataCodingByEncoding, detect, encodingByDataCoding, encodings, isEncodingName, messageClassOf, unencodable } from './defs/encodings.ts';
export { errorNameById, errors, errorsById, isErrorName } from './defs/errors.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 { types } from './defs/types.ts';
export { 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 { ConstGroup, MessageState, SubmitMessagingMode } from './defs/constants.ts';
export type { Encoding, EncodingName, Unencodable } from './defs/encodings.ts'; export type { Encoding, EncodingName, Unencodable } from './defs/encodings.ts';
export type { ErrorName } from './defs/errors.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 { PduHeader } from './pdu-refusal.ts';
export type { SplitOptions } from './message.ts'; export type { SplitOptions } from './message.ts';
export type { Tlv, TlvDefinition, TlvName } from './defs/tlvs.ts'; export type { Tlv, TlvDefinition, TlvName, Tlvs } from './defs/tlvs.ts';
export type { DestAddress, ParamValue, UnsuccessSme, WireType } from './defs/types.ts'; export type { DestAddress, ParamValue, TlvValue, UnsuccessSme, WireType } from './defs/types.ts';
/** The spec tables, grouped the way `larvitsmpp.defs` was in 0.4.0. */ /** The spec tables, grouped the way `larvitsmpp.defs` was in 0.4.0. */
export { defs } from './defs/index.ts'; export { defs } from './defs/index.ts';
+5
View File
@@ -42,6 +42,11 @@ export class LinkGate {
return this.up; return this.up;
} }
/** Shut, with another link on its way to reopen it. */
awaitsNextLink(): boolean {
return !this.up && this.returning;
}
/** Why the gate will never admit a request, or undefined while one may still get through. */ /** Why the gate will never admit a request, or undefined while one may still get through. */
refusal(): Error | undefined { refusal(): Error | undefined {
return this.up || this.returning ? undefined : over(); return this.up || this.returning ? undefined : over();
+25 -13
View File
@@ -51,9 +51,13 @@ export class OutgoingRequests {
this.window = new SendWindow({ limit: options.maxOutstanding, log: options.log }); this.window = new SendWindow({ limit: options.maxOutstanding, log: options.log });
} }
/** Read through a method: a drop can land while a request is awaiting. */ canCarry(): boolean {
linkDown(): boolean { return this.gate.isUp() && !this.transport.sock.destroyed;
return !this.gate.isUp() || this.transport.sock.destroyed; }
/** The link went before the drain finished, so an empty window says nothing about the peer. */
droppedWhileDraining(): boolean {
return this.draining && !this.canCarry();
} }
/** A link is up and bound, so everything held for one goes out on it. */ /** A link is up and bound, so everything held for one goes out on it. */
@@ -84,16 +88,16 @@ export class OutgoingRequests {
if (wrong) return Promise.resolve({ err: wrong }); if (wrong) return Promise.resolve({ err: wrong });
// A drain on a live link. A link that is down is the gate's answer, which says closed instead. // With no link, the request is refused as closed further on.
if (this.draining && !this.linkDown()) { if (this.draining && this.canCarry()) {
return Promise.resolve({ err: new Error('Session is shutting down') }); return Promise.resolve({ err: new Error('Session is shutting down') });
} }
return this.pastDrain(input, options); return this.requestPastDrain(input, options);
} }
/** The same path without that refusal, which a receipt for a held message has to take. */ /** request() without the drain's refusal, which a receipt for a held message has to take. */
async pastDrain( async requestPastDrain(
input: PduObjectInput, input: PduObjectInput,
options: SendOptions, options: SendOptions,
): Promise<Result<{ pduObj: PduObject }>> { ): Promise<Result<{ pduObj: PduObject }>> {
@@ -105,7 +109,7 @@ export class OutgoingRequests {
if (bindCommands.includes(input.cmdName)) { if (bindCommands.includes(input.cmdName)) {
const shut = this.gate.refusal(); const shut = this.gate.refusal();
return shut ? { err: shut } : this.now(input, options); return shut ? { err: shut } : this.requestPastDrainGateAndWindow(input, options);
} }
const waitForLink = this.gate.hold(options.signal); const waitForLink = this.gate.hold(options.signal);
@@ -121,13 +125,15 @@ export class OutgoingRequests {
const attempt = await this.attempt(input, options).finally(() => { this.window.release(); }); const attempt = await this.attempt(input, options).finally(() => { this.window.release(); });
// Nothing reached the socket, so the next link carries it instead of the caller resending. if (!this.retriesOnNextLink(attempt)) return attempt.result;
if (!attempt.retryOnNextLink || this.gate.isUp() || this.gate.refusal()) return attempt.result;
} }
} }
/** Past the gate, the window and a drain, for what has to go out either way. */ /** Straight onto the current link, for what has to go out either way. */
async now(input: PduObjectInput, options: SendOptions = {}): Promise<Result<{ pduObj: PduObject }>> { async requestPastDrainGateAndWindow(
input: PduObjectInput,
options: SendOptions = {},
): Promise<Result<{ pduObj: PduObject }>> {
return (await this.attempt(input, options)).result; return (await this.attempt(input, options)).result;
} }
@@ -147,6 +153,12 @@ export class OutgoingRequests {
return { err: new Error(`Shut down with ${String(unfinished)} request(s) unfinished`) }; return { err: new Error(`Shut down with ${String(unfinished)} request(s) unfinished`) };
} }
/** Nothing reached the socket, so the next link carries it instead of the caller resending. */
private retriesOnNextLink(attempt: Attempt): boolean {
// Until the gate is shut it admits the retry straight back onto the dead socket, and the loop spins.
return attempt.retryOnNextLink && this.gate.awaitsNextLink();
}
/** Why a request cannot go out at all, as opposed to not yet. */ /** Why a request cannot go out at all, as opposed to not yet. */
private refuse(input: PduObjectInput, options: SendOptions): Error | undefined { private refuse(input: PduObjectInput, options: SendOptions): Error | undefined {
// Before the gate and the window, or an aborted call waits for what it will never use. // Before the gate and the window, or an aborted call waits for what it will never use.
+56 -110
View File
@@ -3,14 +3,14 @@ import type { ErrorName } from './defs/errors.ts';
import type { ParamValue } from './defs/types.ts'; import type { ParamValue } from './defs/types.ts';
import type { PduHeader } from './pdu-refusal.ts'; import type { PduHeader } from './pdu-refusal.ts';
import type { Result, VoidResult } from './result.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 { PduRefusedError, framingRefusal } from './pdu-refusal.ts';
import { cmds, commandNameById, respNameFor } from './defs/commands.ts'; import { cmds, commandNameById, respNameFor } from './defs/commands.ts';
import { hasUdh } from './defs/constants.ts'; import { hasUdh } from './defs/constants.ts';
import { decodeMessage, encodeBody } from './message.ts'; import { decodeMessage, encodeBody } from './message.ts';
import { errorNameById, errors, isErrorName } from './defs/errors.ts'; import { errorNameById, errors, isErrorName } from './defs/errors.ts';
import { paramNumber } from './defs/types.ts'; import { paramNumber, valueText } from './defs/types.ts';
import { tagIdOf, tlvDefault, tlvs, tlvsById, writeTlvs } from './defs/tlvs.ts'; import { parseTlvs, writeTlvs } from './defs/tlvs.ts';
/** The highest sequence number this library hands out; SMPP 3.4 4.7.1 reserves 0x7fffffff. */ /** The highest sequence number this library hands out; SMPP 3.4 4.7.1 reserves 0x7fffffff. */
export const maxSeqNr = 2147483646; export const maxSeqNr = 2147483646;
@@ -23,7 +23,7 @@ export type PduObjectInput<C extends CommandName = CommandName> = {
cmdStatus?: ErrorName; cmdStatus?: ErrorName;
params?: PduParamsInput<C>; params?: PduParamsInput<C>;
seqNr?: number; 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. * the peer put in `message_payload` is not here; `messageOctets()` is what reads either.
*/ */
shortMessageOctets: Buffer | undefined; shortMessageOctets: Buffer | undefined;
tlvs: Record<string, Tlv>; tlvs: Tlvs;
}; };
export type { TlvInput }; export type { TlvInputs };
const respBit = 0x80000000; const respBit = 0x80000000;
@@ -68,96 +68,71 @@ export function isCommand<C extends CommandName>(
type ResolvedBody = { type ResolvedBody = {
params: Record<string, ParamValue | undefined>; params: Record<string, ParamValue | undefined>;
tlvs: Record<string, TlvInput> | undefined; tlvs: TlvInputs | undefined;
}; };
/**
* What the PDU's data_coding describes, and so what may set it: short_message wherever it holds an
* octet, since messageOctets() reads it there, and message_payload only where it does not.
*/
type CodingSource = 'message_payload' | 'short_message';
function codingOf(params: Record<string, ParamValue | undefined>): number | undefined { function codingOf(params: Record<string, ParamValue | undefined>): number | undefined {
return typeof params.data_coding === 'number' ? params.data_coding : undefined; return typeof params.data_coding === 'number' ? params.data_coding : undefined;
} }
type CarriedBody = { name: string; text: string; tlv: TlvInput }; function resolveShortMessage(
/** 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;
}
/** The short_message the command's own table will write, since writeParams() ignores any other. */
function writtenBody(definition: CommandDefinition, value: ParamValue | undefined): ParamValue | undefined {
return definition.params?.short_message === undefined ? undefined : value;
}
/** Encoded in place, settling data_coding where `settles` says no mandatory field will carry it. */
function resolveCarried(
resolved: ResolvedBody,
inputs: Record<string, TlvInput> | undefined,
dataCoding: number | undefined,
settles: boolean,
): VoidResult {
for (const carried of carriedBodies(inputs)) {
const encoded = encodeBody(carried.text, dataCoding);
if (encoded.err) return { err: new Error(`TLV "${carried.name}": ${encoded.err.message}`) };
if (settles) resolved.params.data_coding = encoded.dataCoding;
resolved.tlvs = { ...resolved.tlvs, [carried.name]: { ...carried.tlv, tagValue: encoded.buffer } };
}
return {};
}
/** data_coding names the alphabet of the body, and short_message settles it where it carries octets. */
function resolveBody(
params: Record<string, ParamValue | undefined>, params: Record<string, ParamValue | undefined>,
tlvs: Record<string, TlvInput> | undefined,
definition: CommandDefinition, definition: CommandDefinition,
): Result<ResolvedBody> { ): Result<{ params: Record<string, ParamValue | undefined>; source: CodingSource }> {
const message = writtenBody(definition, params.short_message); // Only the short_message the command's own table will write, since writeParams() ignores any other.
const resolved: ResolvedBody = { params: { ...params }, tlvs }; const message = definition.params?.short_message === undefined ? undefined : params.short_message;
if (Buffer.isBuffer(message) && params.sm_length === undefined) { if (Buffer.isBuffer(message)) {
resolved.params.sm_length = message.length; return {
params: params.sm_length === undefined ? { ...params, sm_length: message.length } : params,
source: message.length > 0 ? 'short_message' : 'message_payload',
};
} }
if (typeof message === 'string') { if (typeof message !== 'string') return { params, source: 'message_payload' };
const encoded = encodeBody(message, codingOf(params)); const encoded = encodeBody(message, codingOf(params));
if (encoded.err) { if (encoded.err) {
return { err: new Error(`Parameter "short_message" of "${definition.command}": ${encoded.err.message}`) }; return { err: new Error(`Parameter "short_message" of "${definition.command}": ${encoded.err.message}`) };
} }
if (carriesOctets(encoded.buffer)) resolved.params.data_coding = encoded.dataCoding; const written = { ...params, short_message: encoded.buffer, sm_length: encoded.buffer.length };
resolved.params.short_message = encoded.buffer; if (encoded.buffer.length === 0) return { params: written, source: 'message_payload' };
resolved.params.sm_length = encoded.buffer.length;
}
// Only octets the command's own table will write can settle the alphabet the PDU declares. return { params: { ...written, data_coding: encoded.dataCoding }, source: 'short_message' };
const settles = !carriesOctets(writtenBody(definition, resolved.params.short_message)); }
const carried = resolveCarried(
resolved,
tlvs,
settles ? codingOf(params) : codingOf(resolved.params),
settles,
);
return carried.err ? { err: carried.err } : resolved; function resolveBody(
params: Record<string, ParamValue | undefined>,
tlvs: TlvInputs | undefined,
definition: CommandDefinition,
): Result<ResolvedBody> {
const shortMessage = resolveShortMessage(params, definition);
if (shortMessage.err) return { err: shortMessage.err };
const text = tlvs?.message_payload?.tagValue;
if (typeof text !== 'string') return { params: shortMessage.params, tlvs };
const encoded = encodeBody(text, codingOf(shortMessage.params));
if (encoded.err) return { err: new Error(`TLV "message_payload": ${encoded.err.message}`) };
return {
params: shortMessage.source === 'message_payload'
? { ...shortMessage.params, data_coding: encoded.dataCoding }
: shortMessage.params,
tlvs: { ...tlvs, message_payload: { tagValue: encoded.buffer } },
};
} }
function writeParams( function writeParams(
@@ -197,7 +172,7 @@ function buildBody(
cmdName: CommandName, cmdName: CommandName,
cmdStatus: ErrorName, cmdStatus: ErrorName,
params: Record<string, ParamValue | undefined>, params: Record<string, ParamValue | undefined>,
tlvs: Record<string, TlvInput> | undefined, tlvs: TlvInputs | undefined,
): Result<{ body: Buffer }> { ): Result<{ body: Buffer }> {
if (errors[cmdStatus] !== 0 && definition.id >= respBit) return { body: Buffer.alloc(0) }; if (errors[cmdStatus] !== 0 && definition.id >= respBit) return { body: Buffer.alloc(0) };
@@ -221,7 +196,7 @@ function buildPdu(
cmdStatus: ErrorName, cmdStatus: ErrorName,
seqNr: number, seqNr: number,
params: Record<string, ParamValue | undefined>, params: Record<string, ParamValue | undefined>,
tlvs: Record<string, TlvInput> | undefined, tlvs: TlvInputs | undefined,
): Result<{ buffer: Buffer }> { ): Result<{ buffer: Buffer }> {
const definition = cmds[cmdName]; const definition = cmds[cmdName];
@@ -234,7 +209,7 @@ function buildPdu(
} }
if (!Number.isInteger(seqNr) || seqNr < 0 || seqNr > maxWireSeqNr) { if (!Number.isInteger(seqNr) || seqNr < 0 || seqNr > maxWireSeqNr) {
return { err: new Error(`Invalid seqNr: ${JSON.stringify(seqNr)}`) }; return { err: new Error(`Invalid seqNr: ${valueText(seqNr)}`) };
} }
const built = buildBody(definition, cmdName, cmdStatus, params, tlvs); const built = buildBody(definition, cmdName, cmdStatus, params, tlvs);
@@ -262,35 +237,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( function readParams(
cmdName: CommandName, cmdName: CommandName,
pdu: Buffer, pdu: Buffer,
@@ -322,7 +268,7 @@ function readOptionalParams(
pdu: Buffer, pdu: Buffer,
start: number, start: number,
afterShortMessage: boolean, afterShortMessage: boolean,
): Result<{ tlvs: Record<string, Tlv> }> { ): Result<{ tlvs: Tlvs }> {
const plain = parseTlvs(pdu, start); const plain = parseTlvs(pdu, start);
if (!plain.err && plain.offset === pdu.length) return { tlvs: plain.tlvs }; if (!plain.err && plain.offset === pdu.length) return { tlvs: plain.tlvs };
@@ -447,7 +393,7 @@ export function pduReturn(
pdu: Buffer | PduObject, pdu: Buffer | PduObject,
status: ErrorName = 'ESME_ROK', status: ErrorName = 'ESME_ROK',
params: Record<string, ParamValue> = {}, params: Record<string, ParamValue> = {},
tlvs?: Record<string, TlvInput>, tlvs?: TlvInputs,
): Result<{ buffer: Buffer }> { ): Result<{ buffer: Buffer }> {
if (Buffer.isBuffer(pdu)) { if (Buffer.isBuffer(pdu)) {
const parsed = pduToObj(pdu); const parsed = pduToObj(pdu);
+23 -80
View File
@@ -1,10 +1,9 @@
import type { Concat } from './concat.ts'; import type { Concat } from './concat.ts';
import type { ParamValue } from './defs/types.ts';
import type { PduObject } from './pdu.ts'; import type { PduObject } from './pdu.ts';
import type { SmppLog } from './log.ts'; import type { SmppLog } from './log.ts';
import type { Tlv } from './defs/tlvs.ts';
import { ExpiringGroups } from './expiring-groups.ts'; import { ExpiringGroups } from './expiring-groups.ts';
import { decodeMessage } from './message.ts'; import { decodeMessage } from './message.ts';
import { detach, retainedOctets } from './retained-pdu.ts';
import { messageOctets } from './message-body.ts'; import { messageOctets } from './message-body.ts';
import { paramNumber, paramText } from './defs/types.ts'; import { paramNumber, paramText } from './defs/types.ts';
import { uuidv7 } from './uuid.ts'; import { uuidv7 } from './uuid.ts';
@@ -43,59 +42,14 @@ export type Collected =
whole?: PduObject[] | undefined; whole?: PduObject[] | undefined;
}; };
const defaultMaxOctets = 64 * 1024 * 1024; export const defaultMaxOctets = 64 * 1024 * 1024;
type Group = { type Group = {
octets: number;
parts: Map<number, PduObject>; parts: Map<number, PduObject>;
smsId: string; smsId: string;
total: number; total: number;
}; };
/** Wire reads hand back views, so retaining one segment would pin the whole PDU it arrived in. */
function detach(pduObj: PduObject): PduObject {
const params: Record<string, ParamValue> = {};
const tlvs: Record<string, Tlv> = {};
for (const [name, value] of Object.entries(pduObj.params)) {
params[name] = Buffer.isBuffer(value) ? Buffer.from(value) : value;
}
for (const [name, tlv] of Object.entries(pduObj.tlvs)) {
tlvs[name] = Buffer.isBuffer(tlv.tagValue)
? { ...tlv, tagValue: Buffer.from(tlv.tagValue) }
: tlv;
}
// short_message holds the same octets wherever it was not decoded, so one copy covers both.
const octets = Buffer.isBuffer(params.short_message)
? params.short_message
: pduObj.shortMessageOctets && Buffer.from(pduObj.shortMessageOctets);
return { ...pduObj, params, shortMessageOctets: octets, tlvs };
}
// A cstring param arrives as a string, and source_addr alone can carry most of a 1 MiB PDU.
function sizeOf(value: unknown): number {
if (Buffer.isBuffer(value)) return value.length;
return typeof value === 'string' ? value.length : 0;
}
function octetsOf(pduObj: PduObject): number {
let octets = 0;
for (const value of Object.values(pduObj.params)) {
octets += sizeOf(value);
}
for (const tlv of Object.values(pduObj.tlvs)) {
octets += sizeOf(tlv.tagValue);
}
return octets;
}
// NUL: the one octet a C-Octet String address cannot hold, so no sender can forge another's key. // NUL: the one octet a C-Octet String address cannot hold, so no sender can forge another's key.
function groupKey(pduObj: PduObject, concat: Concat): string { function groupKey(pduObj: PduObject, concat: Concat): string {
return [ return [
@@ -133,18 +87,18 @@ export class Reassembler {
private readonly maxOctets: number; private readonly maxOctets: number;
private readonly newId: () => string; private readonly newId: () => string;
private readonly onLost: (lost: LostGroup) => void; private readonly onLost: (lost: LostGroup) => void;
private octets = 0;
constructor(options: ReassemblerOptions) { constructor(options: ReassemblerOptions) {
this.maxOctets = options.maxOctets ?? defaultMaxOctets;
this.groups = new ExpiringGroups<Group>({ this.groups = new ExpiringGroups<Group>({
max: options.max, max: options.max,
maxWeight: this.maxOctets,
now: options.now, now: options.now,
onSweep: () => { this.sweep(); }, onSweep: () => { this.sweep(); },
timeout: options.timeout, timeout: options.timeout,
}); });
this.log = options.log; this.log = options.log;
this.max = options.max; this.max = options.max;
this.maxOctets = options.maxOctets ?? defaultMaxOctets;
this.newId = options.newId ?? uuidv7; this.newId = options.newId ?? uuidv7;
this.onLost = options.onLost; this.onLost = options.onLost;
} }
@@ -163,25 +117,17 @@ export class Reassembler {
if (!this.placeable(concat, existing)) return { kept: false, refusal: 'unplaceable' }; if (!this.placeable(concat, existing)) return { kept: false, refusal: 'unplaceable' };
const group = existing ?? this.open(key, concat.total); 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));
group.parts.set(concat.part, segment); group.parts.set(concat.part, detach(pduObj));
group.octets += delta;
this.octets += delta;
if (group.parts.size < group.total) { if (group.parts.size < group.total) {
this.trim(key);
// Its own arrival overran the octet cap, so the peer keeps it rather than being told we did. // Its own arrival overran the octet cap, so the peer keeps it rather than being told we did.
if (this.groups.get(key) !== group) return { kept: false, refusal: 'full' }; if (!this.trim(key, group)) return { kept: false, refusal: 'full' };
return { kept: true, smsId: group.smsId }; return { kept: true, smsId: group.smsId };
} }
this.groups.delete(key); this.groups.delete(key);
this.octets -= group.octets;
return { return {
kept: true, kept: true,
@@ -194,14 +140,11 @@ export class Reassembler {
for (const [, group] of this.groups.takeAll()) { for (const [, group] of this.groups.takeAll()) {
this.lost(group, 'linkGone'); this.lost(group, 'linkGone');
} }
this.octets = 0;
} }
/** Drops every group past its deadline. Runs before each collect and on its own timer. */ /** Drops every group past its deadline. Runs before each collect and on its own timer. */
sweep(): void { sweep(): void {
for (const [, group] of this.groups.takeExpired()) { for (const [, group] of this.groups.takeExpired()) {
this.octets -= group.octets;
this.lost(group, 'expired'); this.lost(group, 'expired');
} }
} }
@@ -234,37 +177,37 @@ export class Reassembler {
private open(key: string, total: number): Group { private open(key: string, total: number): Group {
if (this.groups.full) this.dropOldest(); if (this.groups.full) this.dropOldest();
const group: Group = { octets: 0, parts: new Map(), smsId: this.newId(), total }; const group: Group = { parts: new Map(), smsId: this.newId(), total };
this.groups.set(key, group); this.groups.set(key, group);
return group; return group;
} }
/** Drops the oldest groups until the retained payload is back under the octet cap. */ /** Drops the oldest groups until the retained payload is back under the octet cap. False if the current one went. */
private trim(current: string): void { private trim(current: string, group: Group): boolean {
while (this.octets > this.maxOctets) { let octets = 0;
const oldest = this.takeOldest();
if (!oldest) return; for (const part of group.parts.values()) {
octets += retainedOctets(part);
}
let survived = true;
for (const [key, oldest] of this.groups.weigh(current, octets)) {
if (key === current) survived = false;
// The refused segment is in the group but stays with the peer, so it is none of the loss. // The refused segment is in the group but stays with the peer, so it is none of the loss.
const answered = oldest[0] === current ? oldest[1].parts.size - 1 : oldest[1].parts.size; const answered = key === current ? oldest.parts.size - 1 : oldest.parts.size;
if (answered > 0) this.lost(oldest[1], 'evicted', answered); if (answered > 0) this.lost(oldest, 'evicted', answered);
}
} }
private takeOldest(): [string, Group] | undefined { return survived;
const oldest = this.groups.takeOldest();
if (oldest) this.octets -= oldest[1].octets;
return oldest;
} }
private dropOldest(): void { private dropOldest(): void {
const oldest = this.takeOldest(); const oldest = this.groups.takeOldest();
if (oldest) this.lost(oldest[1], 'evicted'); if (oldest) this.lost(oldest[1], 'evicted');
} }
@@ -277,7 +220,7 @@ export class Reassembler {
...lost, ...lost,
max: this.max, max: this.max,
maxOctets: this.maxOctets, maxOctets: this.maxOctets,
octets: this.octets, octets: this.groups.weight,
}); });
this.onLost(lost); this.onLost(lost);
} }
+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 type { SmsIdNotation } from './sms-id.ts';
import { UnansweredError } from './unanswered-error.ts'; import { UnansweredError } from './unanswered-error.ts';
import { consts, defaultMessagingMode, isMessagingMode, isSubmitMessagingMode, submitMessagingModes } from './defs/constants.ts'; import { consts, defaultMessagingMode, isMessagingMode, isSubmitMessagingMode, submitMessagingModes } from './defs/constants.ts';
import { cstring, paramText } from './defs/types.ts';
import { dataCodingByEncoding, detect, encodingNames, isEncodingName, unencodable, unencodableText } from './defs/encodings.ts'; import { dataCodingByEncoding, detect, encodingNames, isEncodingName, unencodable, unencodableText } from './defs/encodings.ts';
import { namedValue } from './error-from.ts'; import { namedValue } from './error-from.ts';
import { normaliseSmsId } from './sms-id.ts'; import { normaliseSmsId } from './sms-id.ts';
import { paramText } from './defs/types.ts';
import { maxSegments, smppTime, splitMessage } from './message.ts'; import { maxSegments, smppTime, splitMessage } from './message.ts';
export type SendSmsOptions = { export type SendSmsOptions = {
@@ -211,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'); return new Error('flash has no Latin-1 spelling: a message class carries GSM 7-bit, 8-bit data or UCS2, and 8-bit data is not text a handset will display, so send it as UCS2 or drop flash');
} }
/** Asked of the wire type itself, so the codec cannot refuse an address this let through. */
function checkAddresses(sms: SendSmsInput): Error | undefined {
for (const option of ['from', 'to'] as const) {
const { err } = cstring.size(sms[option]);
if (err) return new Error(`${option}: ${err.message}`);
}
return undefined;
}
/** Every option a send can be refused for, so nothing is built for a message that will not go. */ /** Every option a send can be refused for, so nothing is built for a message that will not go. */
function checkOptions(sms: SendSmsInput): Result<CheckedOptions> { function checkOptions(sms: SendSmsInput): Result<CheckedOptions> {
const unwritable = checkAddresses(sms);
if (unwritable) return { err: unwritable };
const mode = checkMessagingMode(sms.messagingMode, sms.dlr === true); const mode = checkMessagingMode(sms.messagingMode, sms.dlr === true);
if (mode.err) return { err: mode.err }; if (mode.err) return { err: mode.err };
@@ -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 }); 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 // Segments go out together: a receiver that waits for every segment before answering would otherwise deadlock.
// segment before answering — this library's own server does — would otherwise deadlock.
const sent = await Promise.all(segments.map(segment => deps.send({ const sent = await Promise.all(segments.map(segment => deps.send({
cmdName: 'submit_sm', cmdName: 'submit_sm',
params: submitSmParams(sms, segment, { params: submitSmParams(sms, segment, {
+24 -26
View File
@@ -1,18 +1,19 @@
import type { CloseOptions, OnRequest } from './session-options.ts'; import type { BindType, 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 { Result, VoidResult } from './result.ts';
import type { Server as NetServer, Socket } from 'node:net'; import type { Server as NetServer, Socket } from 'node:net';
import type { Server as TlsServer, TlsOptions } from 'node:tls'; import type { Server as TlsServer, TlsOptions } from 'node:tls';
import type { SmppLog } from './log.ts'; import type { SmppLog } from './log.ts';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { Session, bindCommands, defaultSystemId } from './session.ts'; import { Session, defaultSystemId } from './session.ts';
import { bindTypeFromCommand, checkSessionOptions, undeclaredInterfaceVersion } from './session-options.ts'; import { bindTypeFromCommand, checkSessionOptions } from './session-options.ts';
import { createServer as createNetServer } from 'node:net'; import { createServer as createNetServer } from 'node:net';
import { createServer as createTlsServer } from 'node:tls'; import { createServer as createTlsServer } from 'node:tls';
import { defaultInterfaceVersion } from './defs/constants.ts'; import { defaultInterfaceVersion } from './defs/constants.ts';
import { errorFrom } from './error-from.ts'; import { errorFrom } from './error-from.ts';
import { paramText } from './defs/types.ts'; import { paramText } from './defs/types.ts';
import { guardedLog } from './log.ts'; import { guardedLog } from './log.ts';
import { respNameFor } from './defs/commands.ts';
export type AuthenticateResult = { userData?: unknown } | boolean; export type AuthenticateResult = { userData?: unknown } | boolean;
@@ -161,7 +162,7 @@ async function authenticate(
} }
/** An ESME reads a missing sc_interface_version as this SMSC having none. */ /** 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; if (!session.acceptsOptionalParams()) return undefined;
return { return {
@@ -169,24 +170,12 @@ function bindRespTlvs(session: Session, options: ServerOptions): Record<string,
}; };
} }
async function acceptBind( async function onBind(
session: Session, session: Session,
pduObj: PduObject, pduObj: PduObject,
bindType: BindType,
options: ServerOptions, options: ServerOptions,
identity: Record<string, string>,
): Promise<void> { ): Promise<void> {
const declared = pduObj.params.interface_version;
session.boundAs = bindTypeFromCommand(pduObj.cmdName);
session.loggedIn = true;
session.peerInterfaceVersion = typeof declared === 'number'
? declared
: undeclaredInterfaceVersion;
await session.sendReturn(pduObj, 'ESME_ROK', identity, bindRespTlvs(session, options));
}
async function onBind(session: Session, pduObj: PduObject, options: ServerOptions): Promise<void> {
const identity = { system_id: options.systemId ?? defaults.systemId }; const identity = { system_id: options.systemId ?? defaults.systemId };
const systemId = paramText(pduObj.params.system_id); const systemId = paramText(pduObj.params.system_id);
@@ -197,7 +186,16 @@ async function onBind(session: Session, pduObj: PduObject, options: ServerOption
return; return;
} }
await acceptBind(session, pduObj, options, identity); const recorded = session.bound(bindType, pduObj.params.interface_version);
if (recorded.err) {
session.log.info('server - bind refused', { message: recorded.err.message, systemId });
await session.sendReturn(pduObj, 'ESME_RBINDFAIL', identity);
return;
}
await session.sendReturn(pduObj, 'ESME_ROK', identity, bindRespTlvs(session, options));
session.log.verbose('server - bound', { systemId }); session.log.verbose('server - bound', { systemId });
} }
@@ -210,21 +208,21 @@ async function handleRequest(
pduObj: PduObject, pduObj: PduObject,
options: ServerOptions, options: ServerOptions,
): Promise<boolean> { ): Promise<boolean> {
const isBind = bindCommands.includes(pduObj.cmdName); const bindType = bindTypeFromCommand(pduObj.cmdName);
if (session.loggedIn) { if (session.boundAs !== undefined) {
if (isBind || !options.onRequest) return false; if (bindType || !options.onRequest) return false;
return options.onRequest(session, pduObj); return options.onRequest(session, pduObj);
} }
if (isBind) { if (bindType) {
await onBind(session, pduObj, options); await onBind(session, pduObj, bindType, options);
return true; 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 }); session.log.debug('server - command before bind', { cmdName: pduObj.cmdName });
await session.sendReturn(pduObj, 'ESME_RINVBNDSTS'); await session.sendReturn(pduObj, 'ESME_RINVBNDSTS');
+64 -8
View File
@@ -9,6 +9,7 @@ import type { SmsIdFormat } from './sms-id.ts';
import type { Sms } from './sms.ts'; import type { Sms } from './sms.ts';
import type { Socket } from 'node:net'; import type { Socket } from 'node:net';
import { backoffDefaults } from './reconnect-loop.ts'; import { backoffDefaults } from './reconnect-loop.ts';
import { defaultMaxOctets } from './reassembly.ts';
import { isSmsIdNotation, smsIdNotations, smsIdPlaces } from './sms-id.ts'; import { isSmsIdNotation, smsIdNotations, smsIdPlaces } from './sms-id.ts';
import { namedValue } from './error-from.ts'; import { namedValue } from './error-from.ts';
@@ -120,6 +121,31 @@ export const defaultSystemId = '';
/** SMPP 3.4: a peer that declares no version at all is one from before optional parameters. */ /** SMPP 3.4: a peer that declares no version at all is one from before optional parameters. */
export const undeclaredInterfaceVersion = 0x00; export const undeclaredInterfaceVersion = 0x00;
export type SessionBind = { as: BindType; peerVersion: number };
function quoted(value: unknown): string {
return typeof value === 'string' ? JSON.stringify(value) : namedValue(value);
}
function isBindType(value: unknown): value is BindType {
return typeof value === 'string' && bindTypeFromCommand(`bind_${value}`) !== undefined;
}
/** A bind as `Session.bound()` records it: undefined declares no version, which is pre-3.4. */
export function checkedBind(bindType: unknown, declaredVersion: unknown): Result<{ bind: SessionBind }> {
if (!isBindType(bindType)) {
return { err: new Error(`bindType must be receiver, transceiver or transmitter, the bind command's name without "bind_", got ${quoted(bindType)}`) };
}
if (declaredVersion === undefined) return { bind: { as: bindType, peerVersion: undeclaredInterfaceVersion } };
if (typeof declaredVersion !== 'number' || !Number.isInteger(declaredVersion) || declaredVersion < 0 || declaredVersion > 0xFF) {
return { err: new Error(`declaredVersion must be an integer 0-255, the interface_version param or the sc_interface_version TLV's tagValue, or undefined where the peer declared none, got ${quoted(declaredVersion)}`) };
}
return { bind: { as: bindType, peerVersion: declaredVersion } };
}
export const defaults = { export const defaults = {
/** Receipts of a multipart message can be a working day apart, so the cap does the bounding. */ /** Receipts of a multipart message can be a working day apart, so the cap does the bounding. */
dlrMergeTimeout: 86_400_000, dlrMergeTimeout: 86_400_000,
@@ -127,6 +153,7 @@ export const defaults = {
heldMessageTimeout: 300_000, heldMessageTimeout: 300_000,
maxDlrMerges: 1000, maxDlrMerges: 1000,
maxHeldMessages: 1000, maxHeldMessages: 1000,
maxHeldOctets: 64 * 1024 * 1024,
maxOutstanding: 10, maxOutstanding: 10,
maxReassembly: 1000, maxReassembly: 1000,
reassemblyTimeout: 300_000, reassemblyTimeout: 300_000,
@@ -144,14 +171,11 @@ export function checkSessionOptions(options: CheckableOptions): VoidResult {
return { err: new Error('fromStart is part of the reconnect policy, spell it reconnect: { fromStart: true }') }; return { err: new Error('fromStart is part of the reconnect policy, spell it reconnect: { fromStart: true }') };
} }
const checked = checkLimits([ const connect = checkConnectTimeout(options.connectTimeout);
['idleTimeout', options.idleTimeout ?? 0, 0],
['maxOutstanding', options.maxOutstanding ?? defaults.maxOutstanding, 1], if (connect.err) return connect;
['maxReassembly', options.maxReassembly ?? defaults.maxReassembly, 1],
['reassemblyTimeout', options.reassemblyTimeout ?? defaults.reassemblyTimeout, 0], const checked = checkLimits(limitsOf(options));
['responseTimeout', options.responseTimeout ?? defaults.responseTimeout, 0],
['shutdownTimeout', options.shutdownTimeout ?? defaults.shutdownTimeout, 0],
]);
if (checked.err) return checked; if (checked.err) return checked;
@@ -160,6 +184,36 @@ export function checkSessionOptions(options: CheckableOptions): VoidResult {
return backoff.err ? backoff : checkSmsIdFormat(options.smsIdFormat); 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 = quoted(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 { function checkLimits(limits: [string, number, number][]): VoidResult {
for (const [name, value, min] of limits) { for (const [name, value, min] of limits) {
if (!Number.isInteger(value) || value < min) { if (!Number.isInteger(value) || value < min) {
@@ -238,9 +292,11 @@ function checkSmsIdFormat(smsIdFormat: unknown): VoidResult {
/** What the checker reads, as it arrives: a caller without types can put anything in it. */ /** What the checker reads, as it arrives: a caller without types can put anything in it. */
export type CheckableOptions = { export type CheckableOptions = {
connectTimeout?: unknown;
/** Not an option: the one spelling is inside reconnect, and this is where the other is refused. */ /** Not an option: the one spelling is inside reconnect, and this is where the other is refused. */
fromStart?: unknown; fromStart?: unknown;
idleTimeout?: number | undefined; idleTimeout?: number | undefined;
maxOctets?: number | undefined;
maxOutstanding?: number | undefined; maxOutstanding?: number | undefined;
maxReassembly?: number | undefined; maxReassembly?: number | undefined;
reassemblyTimeout?: number | undefined; reassemblyTimeout?: number | undefined;
+58 -45
View File
@@ -1,9 +1,9 @@
import type { ErrorName } from './defs/errors.ts'; import type { ErrorName } from './defs/errors.ts';
import type { MessageDlr } from './dlr-merger.ts'; import type { MessageDlr } from './dlr-merger.ts';
import type { ParamValue } from './defs/types.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 { PduRefusedError } from './pdu-refusal.ts';
import type { BindType, CloseOptions, LinkEnd, ReconnectOptions, SendOptions, SessionEvents, SessionOptions } from './session-options.ts'; import type { BindType, CloseOptions, LinkEnd, ReconnectOptions, SendOptions, SessionBind, SessionEvents, SessionOptions } from './session-options.ts';
import type { Result, VoidResult } from './result.ts'; import type { Result, VoidResult } from './result.ts';
import type { SendSmsOptions, SendSmsResult } from './send-sms.ts'; import type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
import type { SmppLog } from './log.ts'; import type { SmppLog } from './log.ts';
@@ -18,7 +18,7 @@ import { ReconnectLoop } from './reconnect-loop.ts';
import { leftOf } from './idle-waiters.ts'; import { leftOf } from './idle-waiters.ts';
import { errorFrom } from './error-from.ts'; import { errorFrom } from './error-from.ts';
import { optionalParamsMinVersion } from './defs/constants.ts'; import { optionalParamsMinVersion } from './defs/constants.ts';
import { bindCarries, bindCommands, defaultSystemId, defaults } from './session-options.ts'; import { bindCarries, bindCommands, checkedBind, defaultSystemId, defaults } from './session-options.ts';
import { isResp, objToPdu, pduReturn } from './pdu.ts'; import { isResp, objToPdu, pduReturn } from './pdu.ts';
import { refusalAnswer } from './pdu-refusal.ts'; import { refusalAnswer } from './pdu-refusal.ts';
import { guardedLog } from './log.ts'; import { guardedLog } from './log.ts';
@@ -52,15 +52,12 @@ export class Session extends EventEmitter<SessionEvents> {
readonly log: SmppLog; readonly log: SmppLog;
/** The role the ESME bound with, whichever end of the link this is. Undefined before any bind. */
boundAs: BindType | undefined = undefined;
/** Which end of the link this is. `server()` sets it; a hand-wired SMSC must set it too. */ /** Which end of the link this is. `server()` sets it; a hand-wired SMSC must set it too. */
linkEnd: LinkEnd = 'esme'; linkEnd: LinkEnd = 'esme';
loggedIn = false;
/** What the peer declared when binding: 0x00 if it declared none, undefined before any bind. */
peerInterfaceVersion: number | undefined = undefined;
userData: unknown = undefined; userData: unknown = undefined;
private bind: SessionBind | undefined = undefined;
private readonly concatReference = new ConcatReference(); private readonly concatReference = new ConcatReference();
private readonly dlrMerger: DlrMerger; private readonly dlrMerger: DlrMerger;
private readonly incoming: IncomingRequests; private readonly incoming: IncomingRequests;
@@ -70,8 +67,8 @@ export class Session extends EventEmitter<SessionEvents> {
private readonly timers: LinkTimers; private readonly timers: LinkTimers;
private readonly transport: PduTransport; private readonly transport: PduTransport;
private closed = false; /** `ended` is final: end() stops the reconnect loop before any attach() can run. */
private ended = false; private lifecycle: 'attached' | 'ended' | 'torn-down' = 'attached';
/** A listener that throws is the application's bug; it must not become ours. Hard rule 1. */ /** A listener that throws is the application's bug; it must not become ours. Hard rule 1. */
override emit<K extends keyof SessionEvents>( override emit<K extends keyof SessionEvents>(
@@ -117,18 +114,6 @@ export class Session extends EventEmitter<SessionEvents> {
max: defaults.maxDlrMerges, max: defaults.maxDlrMerges,
timeout: defaults.dlrMergeTimeout, timeout: defaults.dlrMergeTimeout,
}); });
this.incoming = new IncomingRequests({
dlrMerger: this.dlrMerger,
log: this.log,
maxOctets: options.maxOctets,
maxReassembly: options.maxReassembly,
onRequest: options.onRequest,
reassemblyTimeout: options.reassemblyTimeout,
sendPastDrain: input => this.outgoing.pastDrain(input, {}),
session: this,
smsIdFormat: options.smsIdFormat,
systemId: options.systemId,
});
this.reconnectLoop = this.loopFor(options.reconnect); this.reconnectLoop = this.loopFor(options.reconnect);
this.timers = new LinkTimers({ this.timers = new LinkTimers({
enquireLinkInterval: options.enquireLinkInterval, enquireLinkInterval: options.enquireLinkInterval,
@@ -145,6 +130,18 @@ export class Session extends EventEmitter<SessionEvents> {
responseTimeout: options.responseTimeout ?? defaults.responseTimeout, responseTimeout: options.responseTimeout ?? defaults.responseTimeout,
transport: this.transport, transport: this.transport,
}); });
this.incoming = new IncomingRequests({
dlrMerger: this.dlrMerger,
log: this.log,
maxOctets: options.maxOctets,
maxReassembly: options.maxReassembly,
onRequest: options.onRequest,
reassemblyTimeout: options.reassemblyTimeout,
sendPastDrain: input => this.outgoing.requestPastDrain(input, {}),
session: this,
smsIdFormat: options.smsIdFormat,
systemId: options.systemId,
});
this.resetTimers(); this.resetTimers();
} }
@@ -154,6 +151,25 @@ export class Session extends EventEmitter<SessionEvents> {
return this.transport.sock; return this.transport.sock;
} }
/** The role the ESME bound with, whichever end of the link this is. Undefined before any bind. */
get boundAs(): BindType | undefined {
return this.bind?.as;
}
/** What the peer declared when binding: 0x00 if it declared none, undefined before any bind. */
get peerInterfaceVersion(): number | undefined {
return this.bind?.peerVersion;
}
/** Records a bind this link accepted or had accepted, until the next one. */
bound(bindType: string, declaredVersion: unknown): VoidResult {
const checked = checkedBind(bindType, declaredVersion);
if (!checked.err) this.bind = checked.bind;
return checked.err ? { err: checked.err } : {};
}
/** Whether this session's bind direction carries a command. Consulted by the library's senders. */ /** Whether this session's bind direction carries a command. Consulted by the library's senders. */
bindAllows(cmdName: string): boolean { bindAllows(cmdName: string): boolean {
return bindCarries(this.boundAs, cmdName, this.linkEnd); return bindCarries(this.boundAs, cmdName, this.linkEnd);
@@ -161,8 +177,7 @@ export class Session extends EventEmitter<SessionEvents> {
/** SMPP 3.4 forbids sending optional parameters to a peer that declared an older version. */ /** SMPP 3.4 forbids sending optional parameters to a peer that declared an older version. */
acceptsOptionalParams(): boolean { acceptsOptionalParams(): boolean {
return this.peerInterfaceVersion === undefined return this.peerInterfaceVersion === undefined || this.peerInterfaceVersion >= optionalParamsMinVersion;
|| this.peerInterfaceVersion >= optionalParamsMinVersion;
} }
/** Sends a request and resolves with the peer's response. */ /** Sends a request and resolves with the peer's response. */
@@ -171,22 +186,20 @@ export class Session extends EventEmitter<SessionEvents> {
} }
/** Answers a request the peer sent us. Responses are never waited on. */ /** Answers a request the peer sent us. Responses are never waited on. */
async sendReturn( sendReturn(
pdu: PduObject, pdu: PduObject,
status: ErrorName = 'ESME_ROK', status: ErrorName = 'ESME_ROK',
params: Record<string, ParamValue> = {}, params: Record<string, ParamValue> = {},
tlvs?: Record<string, TlvInput>, tlvs?: TlvInputs,
): Promise<VoidResult> { ): Promise<VoidResult> {
return Promise.resolve( return Promise.resolve(this.answer(pduReturn(pdu, status, params, tlvs), pdu.cmdName, pdu.seqNr));
this.answer(pduReturn(pdu, status, params, tlvs), pdu.cmdName, pdu.seqNr),
);
} }
private answer(built: Result<{ buffer: Buffer }>, cmdName: string, seqNr: number): VoidResult { private answer(built: Result<{ buffer: Buffer }>, cmdName: string, seqNr: number): VoidResult {
const sent = built.err ? { err: built.err } : this.transport.write(built.buffer); const sent = built.err ? { err: built.err } : this.transport.write(built.buffer);
// A peer that unbinds and drops the link takes our response with it; that is not a failure. // A peer that unbinds and drops the link takes our response with it; that is not a failure.
if (sent.err && !this.closed) { if (sent.err && this.lifecycle === 'attached') {
this.log.warn('session - could not answer a request', { this.log.warn('session - could not answer a request', {
cmdName, cmdName,
message: sent.err.message, message: sent.err.message,
@@ -221,12 +234,11 @@ export class Session extends EventEmitter<SessionEvents> {
*/ */
async unbind(): Promise<VoidResult> { async unbind(): Promise<VoidResult> {
const drained = await this.drain(undefined); const drained = await this.drain(undefined);
const wasOpen = !this.closed; const wasOpen = this.lifecycle === 'attached';
// now(), not send(): a drain refuses a send, and the unbind goes out either way.
const sent = wasOpen const sent = wasOpen
? await this.outgoing.now({ cmdName: 'unbind' }) ? await this.outgoing.requestPastDrainGateAndWindow({ cmdName: 'unbind' })
: { err: new Error('Session is closed') }; : { err: new Error('Session is closed') };
const closedOnUnbind = wasOpen && this.closed; const closedOnUnbind = wasOpen && this.lifecycle !== 'attached';
this.end(); this.end();
@@ -304,7 +316,7 @@ export class Session extends EventEmitter<SessionEvents> {
private attach(sock: Socket): void { private attach(sock: Socket): void {
this.transport.attach(sock); this.transport.attach(sock);
this.closed = false; this.lifecycle = 'attached';
} }
/** Stops new sends and waits out the messages we hold and the requests already issued. */ /** Stops new sends and waits out the messages we hold and the requests already issued. */
@@ -312,7 +324,8 @@ export class Session extends EventEmitter<SessionEvents> {
this.reconnectLoop?.stop(); this.reconnectLoop?.stop();
this.outgoing.stopAccepting(); this.outgoing.stopAccepting();
if (this.outgoing.linkDown()) return {}; // No link, so nothing is on the wire to wait out.
if (!this.outgoing.canCarry()) return {};
const timeout = this.options.shutdownTimeout ?? defaults.shutdownTimeout; const timeout = this.options.shutdownTimeout ?? defaults.shutdownTimeout;
const deadline = timeout > 0 ? Date.now() + timeout : 0; const deadline = timeout > 0 ? Date.now() + timeout : 0;
@@ -320,8 +333,7 @@ export class Session extends EventEmitter<SessionEvents> {
const messages = await this.incoming.drain(this.answering(timeout), signal); const messages = await this.incoming.drain(this.answering(timeout), signal);
const requests = await this.outgoing.drain(leftOf(deadline), signal); const requests = await this.outgoing.drain(leftOf(deadline), signal);
// The window empties on a teardown too, which settles everything the link was carrying. if (this.outgoing.droppedWhileDraining()) {
if (this.outgoing.linkDown()) {
return { err: new Error('The session closed before the drain finished') }; return { err: new Error('The session closed before the drain finished') };
} }
@@ -350,17 +362,17 @@ export class Session extends EventEmitter<SessionEvents> {
} }
private emitClose(): void { private emitClose(): void {
if (this.ended) return; if (this.lifecycle === 'ended') return;
this.ended = true; this.lifecycle = 'ended';
this.outgoing.linkLost(false); this.outgoing.linkLost(false);
this.emit('close'); this.emit('close');
} }
private teardown(): void { private teardown(): void {
if (this.closed) return; if (this.lifecycle !== 'attached') return;
this.closed = true; this.lifecycle = 'torn-down';
// Read once: clear() reports lost segments, and a listener could stop the loop between reads. // Read once: clear() reports lost segments, and a listener could stop the loop between reads.
const retrying = this.retrying(); const retrying = this.retrying();
@@ -374,6 +386,7 @@ export class Session extends EventEmitter<SessionEvents> {
else this.emitClose(); else this.emitClose();
} }
// Copied into the gate at teardown, so stopping the loop anywhere but drain() and end() has to shut the gate too.
private retrying(): boolean { private retrying(): boolean {
return this.reconnectLoop !== undefined && !this.reconnectLoop.isStopped(); return this.reconnectLoop !== undefined && !this.reconnectLoop.isStopped();
} }
@@ -423,15 +436,15 @@ export class Session extends EventEmitter<SessionEvents> {
} }
private resetTimers(): void { private resetTimers(): void {
if (this.closed) return; if (this.lifecycle !== 'attached') return;
this.timers.reset(); this.timers.reset();
} }
private onClose(): void { private onClose(): void {
if (this.reconnectLoop && !this.reconnectLoop.isStopped()) { if (this.retrying()) {
this.teardown(); this.teardown();
this.reconnectLoop.schedule(); this.reconnectLoop?.schedule();
return; return;
} }
+11 -9
View File
@@ -1,6 +1,6 @@
import type { ErrorName } from './defs/errors.ts'; import type { ErrorName } from './defs/errors.ts';
import type { MessageState } from './defs/constants.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 { Result, VoidResult } from './result.ts';
import type { Session } from './session.ts'; import type { Session } from './session.ts';
import { UnansweredError } from './unanswered-error.ts'; import { UnansweredError } from './unanswered-error.ts';
@@ -89,9 +89,9 @@ export function createSms(input: SmsInput, handlers: SmsHandlers): Sms {
from: input.from, from: input.from,
message: input.message, message: input.message,
pduObjs: input.pduObjs, pduObjs: input.pduObjs,
sendDlr: status => sendDlr(sms, handlers.send, status), sendDlr: status => sendDlr(sms, input.session, handlers, status),
sendResp: options => (input.answeredAs === undefined sendResp: options => (input.answeredAs === undefined
? sendResp(sms, answered, options ?? {}, handlers) ? sendResp(sms, input.session, answered, options ?? {}, handlers)
: answeredOnArrival(options ?? {}, handlers)), : answeredOnArrival(options ?? {}, handlers)),
session: input.session, session: input.session,
get smsId(): string { get smsId(): string {
@@ -128,6 +128,7 @@ function answeredOnArrival(
async function sendResp( async function sendResp(
sms: Sms, sms: Sms,
session: Session,
answered: { smsId: string }, answered: { smsId: string },
options: SendRespOptions, options: SendRespOptions,
handlers: Pick<SmsHandlers, 'lostLink' | 'onAnswered'>, handlers: Pick<SmsHandlers, 'lostLink' | 'onAnswered'>,
@@ -149,7 +150,7 @@ async function sendResp(
return { err: new Error('The link this message arrived on is gone, so nothing would correlate the response') }; return { err: new Error('The link this message arrived on is gone, so nothing would correlate the response') };
} }
const results = await Promise.all(sms.pduObjs.map((pduObj, index) => sms.session.sendReturn( const results = await Promise.all(sms.pduObjs.map((pduObj, index) => session.sendReturn(
pduObj, pduObj,
options.status ?? 'ESME_ROK', options.status ?? 'ESME_ROK',
respIdParams(pduObj.cmdName, segmentId(answered.smsId, index, total)), respIdParams(pduObj.cmdName, segmentId(answered.smsId, index, total)),
@@ -179,7 +180,7 @@ function receiptText(sms: Sms, smsId: string, status: MessageState): string {
].join(' '); ].join(' ');
} }
function receiptTlvs(smsId: string, status: MessageState): Record<string, TlvInput> { function receiptTlvs(smsId: string, status: MessageState): TlvInputs {
return { return {
message_state: { tagValue: consts.MESSAGE_STATE[status] }, message_state: { tagValue: consts.MESSAGE_STATE[status] },
receipted_message_id: { tagValue: smsId }, receipted_message_id: { tagValue: smsId },
@@ -210,10 +211,11 @@ function collectReceipt(sent: Result<{ pduObj: PduObject }>[]): SendDlrResult {
async function sendDlr( async function sendDlr(
sms: Sms, sms: Sms,
send: SmsHandlers['send'], session: Session,
handlers: Pick<SmsHandlers, 'send'>,
status: MessageState = 'DELIVERED', status: MessageState = 'DELIVERED',
): Promise<SendDlrResult> { ): Promise<SendDlrResult> {
if (!sms.session.bindAllows('deliver_sm')) { if (!session.bindAllows('deliver_sm')) {
return { return {
err: new Error('A transmitter-bound session does not carry deliver_sm'), err: new Error('A transmitter-bound session does not carry deliver_sm'),
pduObjs: [], pduObjs: [],
@@ -226,7 +228,7 @@ async function sendDlr(
const sent = await Promise.all(sms.pduObjs.map((_segment, index) => { const sent = await Promise.all(sms.pduObjs.map((_segment, index) => {
const smsId = segmentId(sms.smsId, index, total); const smsId = segmentId(sms.smsId, index, total);
return send({ return handlers.send({
cmdName: 'deliver_sm', cmdName: 'deliver_sm',
params: { params: {
destination_addr: sms.from, destination_addr: sms.from,
@@ -236,7 +238,7 @@ async function sendDlr(
short_message: receiptText(sms, smsId, status), short_message: receiptText(sms, smsId, status),
source_addr: sms.to, source_addr: sms.to,
}, },
...(sms.session.acceptsOptionalParams() ? { tlvs: receiptTlvs(smsId, status) } : {}), ...(session.acceptsOptionalParams() ? { tlvs: receiptTlvs(smsId, status) } : {}),
}); });
})); }));
return collectReceipt(sent); return collectReceipt(sent);
+2 -2
View File
@@ -4,7 +4,7 @@ import { consts } from '../src/defs/constants.ts';
import { dlrFromPdu, parseReceipt, receiptCodes } from '../src/dlr.ts'; import { dlrFromPdu, parseReceipt, receiptCodes } from '../src/dlr.ts';
import { encodeMessage } from '../src/message.ts'; import { encodeMessage } from '../src/message.ts';
import { objToPdu, pduToObj } from '../src/pdu.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'; 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( function deliverSm(
message: Buffer | string, message: Buffer | string,
tlvs?: Record<string, TlvInput>, tlvs?: TlvInputs,
esmClass: number = consts.ESM_CLASS.MC_DELIVERY_RECEIPT, esmClass: number = consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
dataCoding = 0, dataCoding = 0,
): PduObject { ): PduObject {
+2 -2
View File
@@ -95,8 +95,8 @@ describe('our encoder against the reference parser', () => {
}, },
seqNr: 77, seqNr: 77,
tlvs: { tlvs: {
message_state: { tagId: 0x0427, tagValue: 2 }, message_state: { tagValue: 2 },
receipted_message_id: { tagId: 0x001E, tagValue: 'abc123' }, 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 test, { describe } from 'node:test';
import type { Dlr, Receipt } from '../src/dlr.ts'; import type { Dlr, Receipt } from '../src/dlr.ts';
import type { MessageDlr } from '../src/session.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 { bindToSmsc, dummySmsc } from './dummy-smsc.ts';
import { consts } from '../src/defs/constants.ts'; import { consts } from '../src/defs/constants.ts';
import { dlrFromPdu, parseReceipt, receiptCodes, transientStates } from '../src/dlr.ts'; import { dlrFromPdu, parseReceipt, receiptCodes, transientStates } from '../src/dlr.ts';
@@ -16,7 +16,7 @@ import { objToPdu, pduToObj } from '../src/pdu.ts';
function deliverSm( function deliverSm(
body: string, body: string,
tlvs?: Record<string, TlvInput>, tlvs?: TlvInputs,
esmClass: number = consts.ESM_CLASS.MC_DELIVERY_RECEIPT, esmClass: number = consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
): PduObject { ): PduObject {
const { buffer } = objToPdu({ const { buffer } = objToPdu({
@@ -53,7 +53,7 @@ type ReceiptFixture = {
name: string; name: string;
receipt: Receipt; receipt: Receipt;
source: string; source: string;
tlvs?: Record<string, TlvInput>; tlvs?: TlvInputs;
}; };
const fixtures: readonly ReceiptFixture[] = [ const fixtures: readonly ReceiptFixture[] = [
+178 -23
View File
@@ -3,6 +3,7 @@ import test, { describe } from 'node:test';
import { PduRefusedError, refusalAnswer } from '../src/pdu-refusal.ts'; import { PduRefusedError, refusalAnswer } from '../src/pdu-refusal.ts';
import { isCommand, isResp, objToPdu, pduReturn, pduToObj } from '../src/pdu.ts'; import { isCommand, isResp, objToPdu, pduReturn, pduToObj } from '../src/pdu.ts';
import { paramText } from '../src/defs/types.ts'; import { paramText } from '../src/defs/types.ts';
import { isTlvName, tlvsById } from '../src/defs/tlvs.ts';
function encode(...args: Parameters<typeof objToPdu>): Buffer { function encode(...args: Parameters<typeof objToPdu>): Buffer {
const { buffer, err } = objToPdu(...args); 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: 0x80000001 })).seqNr, 0x80000001);
assert.equal(decode(encode({ cmdName: 'enquire_link', seqNr: 0xFFFFFFFF })).seqNr, 0xFFFFFFFF); assert.equal(decode(encode({ cmdName: 'enquire_link', seqNr: 0xFFFFFFFF })).seqNr, 0xFFFFFFFF);
assert.ok(objToPdu({ cmdName: 'submit_sm', seqNr: 0x100000000 }).err instanceof Error); assert.ok(objToPdu({ cmdName: 'submit_sm', seqNr: 0x100000000 }).err instanceof Error);
const notANumber = objToPdu({ cmdName: 'submit_sm', seqNr: NaN });
assert.ok(notANumber.err instanceof Error);
assert.match(notANumber.err.message, /NaN/);
}); });
}); });
@@ -127,6 +133,36 @@ describe('parsing real PDUs', () => {
assert.equal(pduObj.params.short_message, 'hej 一'); assert.equal(pduObj.params.short_message, 'hej 一');
assert.equal(pduObj.tlvs.message_state?.tagValue, 2); assert.equal(pduObj.tlvs.message_state?.tagValue, 2);
}); });
test('keeps an alphanumeric sender whole through the wire and back', () => {
const pdu = encode({
cmdName: 'deliver_sm',
params: {
destination_addr: '46709771337',
short_message: 'hej',
source_addr: 'Kaffeé',
source_addr_ton: 5,
},
seqNr: 9,
});
assert.ok(pdu.includes(Buffer.from('Kaffeé', 'latin1')));
assert.equal(decode(pdu).params.source_addr, 'Kaffeé');
});
test('refuses an address the field cannot carry rather than truncating or coercing it', () => {
const smuggled = objToPdu({
cmdName: 'submit_sm',
params: { destination_addr: '46709771337', source_addr: '46701113311\u0000EVIL' },
});
assert.ok(smuggled.err instanceof Error);
assert.equal(smuggled.buffer, undefined);
assert.ok(objToPdu({ cmdName: 'deliver_sm', params: { source_addr: '一' } }).err instanceof Error);
assert.ok(objToPdu({ cmdName: 'deliver_sm', params: { source_addr: NaN } }).err instanceof Error);
assert.ok(objToPdu({ cmdName: 'deliver_sm', params: { source_addr: Infinity } }).err instanceof Error);
});
}); });
describe('encoding submit_sm', () => { describe('encoding submit_sm', () => {
@@ -342,7 +378,7 @@ describe('TLVs', () => {
}, },
seqNr: 393, seqNr: 393,
tlvs: { tlvs: {
5142: { tagId: 5142, tagValue: Buffer.from('blajfoo', 'ascii') }, 5142: { tagValue: Buffer.from('blajfoo', 'ascii') },
receipted_message_id: { tagValue: '293f293' }, receipted_message_id: { tagValue: '293f293' },
}, },
})); }));
@@ -356,7 +392,7 @@ describe('TLVs', () => {
assert.deepEqual(unknown.tagValue, Buffer.from('blajfoo', 'ascii')); 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 payload = Buffer.from('deadbeef00ff', 'hex');
const params = { const params = {
destination_addr: '46709771337', destination_addr: '46709771337',
@@ -364,16 +400,21 @@ describe('TLVs', () => {
short_message: 'binary payload follows', short_message: 'binary payload follows',
source_addr: '46701113311', source_addr: '46701113311',
}; };
const parsed = decode(encode({ const pdu = encode({
cmdName: 'deliver_sm', cmdName: 'deliver_sm',
params, params,
seqNr: 7, seqNr: 7,
tlvs: { message_payload: { tagValue: payload } }, 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.ok(carried);
assert.deepEqual(carried.tagValue, payload); 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({ const rebuilt = decode(encode({
cmdName: 'deliver_sm', cmdName: 'deliver_sm',
@@ -397,25 +438,27 @@ describe('TLVs', () => {
assert.deepEqual(pduObj.tlvs.source_port, { tagId: 0x020A, tagName: 'source_port', tagValue: 1234 }); 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', () => { test('refuses a TLV keyed any way but by its name, or by its decimal id where the table names none', () => {
const { buffer, err } = objToPdu({ const params = { destination_addr: '46709771337', short_message: 'hi', source_addr: '46701113311' };
cmdName: 'deliver_sm', const refusals = [
params: { destination_addr: '46709771337', short_message: 'hi', source_addr: '46701113311' }, // @ts-expect-error nils is no tag name
tlvs: { nils: { tagValue: 'blajfoo' } }, { 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 \}/ },
];
for (const { built: { buffer, err }, reason } of refusals) {
assert.equal(buffer, undefined); assert.equal(buffer, undefined);
assert.ok(err instanceof Error); assert.ok(err instanceof Error);
}); assert.match(err.message, reason);
}
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);
}); });
test('refuses a TLV too long for the two octet length field', () => { test('refuses a TLV too long for the two octet length field', () => {
@@ -428,6 +471,118 @@ describe('TLVs', () => {
assert.ok(err instanceof Error); assert.ok(err instanceof Error);
}); });
test('keeps every occurrence of a repeatable TLV, in wire order', () => {
const first = Buffer.from('0146709771337', 'hex');
const second = Buffer.from('0146701113311', 'hex');
const pduObj = decode(encode({
cmdName: 'submit_sm',
params: { destination_addr: '46709771337', short_message: 'hi', source_addr: '46701113311' },
tlvs: {
callback_num: { tagValue: [first, second] },
callback_num_pres_ind: { tagValue: [1] },
},
}));
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', () => { test('round-trips a receipt with message_state and receipted_message_id', () => {
const receipt = 'id:450 sub:001 dlvrd:1 submit date:1504031342 done date:1504031342 stat:DELIVRD err:0 text:xxx'; const receipt = 'id:450 sub:001 dlvrd:1 submit date:1504031342 done date:1504031342 stat:DELIVRD err:0 text:xxx';
const pduObj = decode(encode({ const pduObj = decode(encode({
@@ -440,8 +595,8 @@ describe('TLVs', () => {
}, },
seqNr: 323, seqNr: 323,
tlvs: { tlvs: {
message_state: { tagId: 1063, tagValue: 2 }, message_state: { tagValue: 2 },
receipted_message_id: { tagId: 30, tagValue: 450 }, receipted_message_id: { tagValue: 450 },
}, },
})); }));
+359 -60
View File
@@ -4,6 +4,8 @@ import test, { describe } from 'node:test';
import type { Collected, LostGroup } from '../src/reassembly.ts'; import type { Collected, LostGroup } from '../src/reassembly.ts';
import type { Dlr } from '../src/dlr.ts'; import type { Dlr } from '../src/dlr.ts';
import type { ErrorName } from '../src/defs/errors.ts'; import type { ErrorName } from '../src/defs/errors.ts';
import type { IncomingRequestsOptions } from '../src/incoming-requests.ts';
import type { MessageHold } from '../src/held-messages.ts';
import type { MessageState } from '../src/defs/constants.ts'; import type { MessageState } from '../src/defs/constants.ts';
import type { MessageDlr } from '../src/session.ts'; import type { MessageDlr } from '../src/session.ts';
import type { PduObject, PduObjectInput } from '../src/pdu.ts'; import type { PduObject, PduObjectInput } from '../src/pdu.ts';
@@ -24,7 +26,7 @@ import { Session } from '../src/session.ts';
import { DlrMerger } from '../src/dlr-merger.ts'; import { DlrMerger } from '../src/dlr-merger.ts';
import { PduRefusedError } from '../src/pdu-refusal.ts'; import { PduRefusedError } from '../src/pdu-refusal.ts';
import { objToPdu } from '../src/pdu.ts'; import { objToPdu } from '../src/pdu.ts';
import { checkSessionOptions, standsInFor } from '../src/session-options.ts'; import { checkSessionOptions, defaults, standsInFor } from '../src/session-options.ts';
import { client } from '../src/client.ts'; import { client } from '../src/client.ts';
import { closeAfter, closeListenerAfter } from './teardown.ts'; import { closeAfter, closeListenerAfter } from './teardown.ts';
import { concatOf } from '../src/concat.ts'; import { concatOf } from '../src/concat.ts';
@@ -84,6 +86,31 @@ function within<T>(ms: number, promise: Promise<T>): Promise<T | undefined> {
return Promise.race([promise, delay(ms).then((): undefined => 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 incomingOn(session: Session, options: Partial<IncomingRequestsOptions> = {}): IncomingRequests {
return new IncomingRequests({
dlrMerger: new DlrMerger({ log: silentLog, max: 10, timeout: 10_000 }),
log: silentLog,
sendPastDrain: () => Promise.resolve({ err: new Error('never sent') }),
session,
...options,
});
}
function submitPdu(seqNr: number, cmdStatus: ErrorName = 'ESME_ROK'): PduObject { function submitPdu(seqNr: number, cmdStatus: ErrorName = 'ESME_ROK'): PduObject {
return { return {
cmdId: 0x00000004, cmdId: 0x00000004,
@@ -592,6 +619,10 @@ describe('reconnect', () => {
const reconnected = once<true>(resolve => { session.on('reconnected', () => { resolve(true); }); }); const reconnected = once<true>(resolve => { session.on('reconnected', () => { resolve(true); }); });
const halfPdu = once<true>(resolve => { session.on('data', () => { resolve(true); }); }); const halfPdu = once<true>(resolve => { session.on('data', () => { resolve(true); }); });
const boundBefore = [session.boundAs, session.peerInterfaceVersion];
const whileDown = once<unknown[]>(resolve => {
session.on('disconnected', () => { resolve([session.boundAs, session.peerInterfaceVersion]); });
});
// A PDU header promising 32 octets and sending 8: the next link must not continue it. // A PDU header promising 32 octets and sending 8: the next link must not continue it.
peerOf(smpp).sock.write(Buffer.from([0, 0, 0, 32, 0, 0, 0, 4])); peerOf(smpp).sock.write(Buffer.from([0, 0, 0, 32, 0, 0, 0, 4]));
@@ -604,7 +635,9 @@ describe('reconnect', () => {
await reconnected; await reconnected;
assert.ok(session.loggedIn); // The loop rebinds as before, so the gap keeps answering bindAllows() for the bind to come.
assert.deepEqual(await whileDown, boundBefore);
assert.equal(session.boundAs, 'transceiver');
// The session object survives the drop, so listeners stay attached and it is usable again. // The session object survives the drop, so listeners stay attached and it is usable again.
const sent = await session.sendSms({ const sent = await session.sendSms({
@@ -714,15 +747,8 @@ describe('reconnect', () => {
const session = new Session({ sock: new net.Socket() }); const session = new Session({ sock: new net.Socket() });
closeAfter(t, session); closeAfter(t, session);
session.boundAs = 'transceiver';
const incoming = new IncomingRequests({ const incoming = incomingOn(session, { onRequest: async () => { await delay(10); return false; } });
dlrMerger: new DlrMerger({ log: silentLog, max: 10, timeout: 10_000 }),
log: silentLog,
onRequest: async () => { await delay(10); return false; },
sendPastDrain: () => Promise.resolve({ err: new Error('never sent') }),
session,
});
let messages = 0; let messages = 0;
session.on('sms', () => { messages++; }); session.on('sms', () => { messages++; });
@@ -798,21 +824,6 @@ describe('reconnect from the first bind', () => {
return port; 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 () => { test('gives up on the first attempt where reconnect alone is asked for', async () => {
const port = await closedPort(); const port = await closedPort();
const spy = logSpy(); const spy = logSpy();
@@ -849,7 +860,7 @@ describe('reconnect from the first bind', () => {
assert.equal(err, undefined); assert.equal(err, undefined);
assert.ok(session); assert.ok(session);
assert.equal(session.loggedIn, true); assert.equal(session.boundAs, 'transceiver');
assert.ok(spy.delays.length >= 3, 'the SMSC was down for several attempts'); assert.ok(spy.delays.length >= 3, 'the SMSC was down for several attempts');
assert.deepEqual(spy.delays.slice(0, 3), [10, 20, 40], 'each wait doubles, up to maxDelay'); assert.deepEqual(spy.delays.slice(0, 3), [10, 20, 40], 'each wait doubles, up to maxDelay');
assert.ok( assert.ok(
@@ -1018,6 +1029,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', () => { describe('sends across a reconnect', () => {
/** Answers every message after the first, which is left to hold the send window open. */ /** Answers every message after the first, which is left to hold the send window open. */
function answerAfterTheFirst(smpp: SmppServer, arrived: string[]): Latch { function answerAfterTheFirst(smpp: SmppServer, arrived: string[]): Latch {
@@ -1315,6 +1441,18 @@ describe('LinkGate', () => {
assert.match(held.err?.message ?? '', /Aborted while waiting for a link/); assert.match(held.err?.message ?? '', /Aborted while waiting for a link/);
}); });
test('awaits the next link only while shut with one on its way', () => {
const gate = new LinkGate({ log: silentLog, timeout: 100 });
assert.equal(gate.awaitsNextLink(), false, 'up');
gate.shut(true);
assert.equal(gate.awaitsNextLink(), true, 'shut, returning');
gate.open();
assert.equal(gate.awaitsNextLink(), false, 'reopened');
gate.shut(false);
assert.equal(gate.awaitsNextLink(), false, 'shut for good');
});
}); });
describe('SendWindow', () => { describe('SendWindow', () => {
@@ -1367,34 +1505,131 @@ describe('held message bounds', () => {
return [submitPdu(seqNr)]; return [submitPdu(seqNr)];
} }
test('drops the message held longest rather than holding every one', () => { function offer(held: HeldMessages, seqNr: number): MessageHold {
const held = new HeldMessages({ log: silentLog, max: 2, timeout: 10_000 }); return held.offer(message(seqNr), 1, () => ({}), () => true);
const oldest = message(1); }
held.hold(oldest); test('is full at its count, and a re-used sequence number replaces rather than adding', () => {
held.hold(message(2)); const held = new HeldMessages({ log: silentLog, max: 2, maxOctets: 1_000_000, timeout: 10_000 });
held.hold(message(2)); const first = offer(held, 1);
const replaced = offer(held, 2);
assert.equal(held.size, 2, 'a re-used sequence number replaces rather than evicting'); offer(held, 2);
assert.equal(held.has(oldest), true);
held.hold(message(3));
assert.equal(held.size, 2); assert.equal(held.size, 2);
assert.equal(held.has(oldest), false); assert.equal(held.octetsHeld, 2 * 1026, 'the replaced message leaves its octets with it');
assert.equal(held.full(), true);
assert.equal(first.isHeld(), true);
assert.equal(replaced.isHeld(), false);
held.clear(); 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 = offer(held, 1);
assert.equal(held.full(), false);
offer(held, 2);
assert.equal(held.full(), true);
answered.release();
assert.equal(held.full(), false, 'after a release');
offer(held, 3);
now = 20_000;
held.sweep();
now = 0;
assert.equal(held.full(), false, 'after a sweep');
offer(held, 4);
offer(held, 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);
const warnings: string[] = [];
const incoming = incomingOn(session, { log: { ...silentLog, warn: message => { warnings.push(message); } } });
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);
const incoming = incomingOn(session);
const chunk = Buffer.alloc(64 * 1024);
const carried = submitPdu(1);
let received: Sms | undefined;
session.on('sms', sms => { received = sms; });
await incoming.handle({ ...carried, params: { ...carried.params, short_message: chunk.subarray(16, 20) } });
const retained = received?.pduObjs[0]?.params.short_message;
assert.ok(Buffer.isBuffer(retained));
assert.notEqual(retained.buffer, chunk.buffer);
incoming.clear();
});
test('gives up on a message the application never answers', () => { test('gives up on a message the application never answers', () => {
let now = 0; let now = 0;
const held = new HeldMessages({ log: silentLog, max: 10, now: () => now, timeout: 60 }); const held = new HeldMessages({ log: silentLog, max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 });
held.hold(message(1)); offer(held, 1);
now = 61; now = 61;
// The next message sweeps the one that expired, so only the new one is still waited for. // The next message sweeps the one that expired, so only the new one is still waited for.
held.hold(message(2)); offer(held, 2);
assert.equal(held.size, 1); assert.equal(held.size, 1);
@@ -1404,9 +1639,9 @@ describe('held message bounds', () => {
// Without this the drain sits out its whole budget before returning what a sweep already settled. // Without this the drain sits out its whole budget before returning what a sweep already settled.
test('wakes a waiting drain when the last message expires', async () => { test('wakes a waiting drain when the last message expires', async () => {
let now = 0; let now = 0;
const held = new HeldMessages({ log: silentLog, max: 10, now: () => now, timeout: 60 }); const held = new HeldMessages({ log: silentLog, max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 });
held.hold(message(1)); offer(held, 1);
const waiting = held.idle(1000, undefined); const waiting = held.idle(1000, undefined);
@@ -1420,13 +1655,14 @@ describe('held message bounds', () => {
describe('sendResp()', () => { describe('sendResp()', () => {
// A response the wire never carried leaves the peer owed one, so nothing may count it answered. // A response the wire never carried leaves the peer owed one, so nothing may count it answered.
test('does not count a response that never reached the wire as an answer', async t => { test('does not count a response that never reached the wire as an answer', async t => {
const sock = new net.Socket(); const session = new Session({ sock: new net.Socket() });
const session = new Session({ sock });
closeAfter(t, session); closeAfter(t, session);
sock.destroy();
let answered = 0; let answered = 0;
session.sendReturn = () => Promise.resolve({ err: new Error('Socket is closed') });
const sms = createSms({ const sms = createSms({
from: '46701113311', from: '46701113311',
message: 'never answered', message: 'never answered',
@@ -1588,7 +1824,7 @@ describe('where a segment says it is concatenated', () => {
}); });
test('reads no concatenation where a sar_* TLV is missing', () => { test('reads no concatenation where a sar_* TLV is missing', () => {
const lone = { const lone: PduObject = {
...sarSegment(5, 1, 2), ...sarSegment(5, 1, 2),
tlvs: { sar_msg_ref_num: { tagId: 0x020c, tagName: 'sar_msg_ref_num', tagValue: 5 } }, tlvs: { sar_msg_ref_num: { tagId: 0x020c, tagName: 'sar_msg_ref_num', tagValue: 5 } },
}; };
@@ -1690,7 +1926,7 @@ describe('reassembly bounds', () => {
assert.deepEqual(lost, [], 'the peer holds the only segment there was, so nothing was lost'); assert.deepEqual(lost, [], 'the peer holds the only segment there was, so nothing was lost');
}); });
// The two addresses 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', () => { test('counts a body carried in message_payload against the octet cap', () => {
function collectPayload(maxOctets: number): Collected { function collectPayload(maxOctets: number): Collected {
const reassembler = new Reassembler({ const reassembler = new Reassembler({
@@ -1705,8 +1941,45 @@ describe('reassembly bounds', () => {
return collectPdu(reassembler, payloadSegment(9, 1, 2)); 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(1330).kept, false, 'a TLV body the cap cannot hold is refused, not dropped later');
assert.equal(collectPayload(40).kept, true); 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. // The segments before it were answered ESME_ROK, so dropping those is not the same as refusing one.
@@ -1715,8 +1988,8 @@ describe('reassembly bounds', () => {
const reassembler = new Reassembler({ const reassembler = new Reassembler({
log: silentLog, log: silentLog,
max: 10, max: 10,
// One segment is 36 octets, so the second overruns a group already holding the first. // One segment is 1036 octets, so the second overruns a group already holding the first.
maxOctets: 50, maxOctets: 1050,
now: () => 0, now: () => 0,
onLost: one => { lost.push(one); }, onLost: one => { lost.push(one); },
timeout: 60_000, timeout: 60_000,
@@ -1780,9 +2053,9 @@ describe('reassembly bounds', () => {
assert.equal(counted.size, 1, 'the second group evicted the first, as a UDH group would'); assert.equal(counted.size, 1, 'the second group evicted the first, as a UDH group would');
counted.clear(); counted.clear();
// A sar_* segment is the two 11-octet addresses plus an 8-octet body, with no UDH to carry. // A sar_* segment is the two 11-octet addresses, an 8-octet body, its own object and three TLVs'.
assert.equal(collectSar(capped(20), 3, 1, 2).kept, false); assert.equal(collectSar(capped(1920), 3, 1, 2).kept, false);
assert.equal(collectSar(capped(30), 3, 1, 2).kept, true); 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. // Nothing else says a message the peer has already been answered for was thrown away.
@@ -1898,8 +2171,8 @@ describe('reassembly bounds', () => {
const reassembler = new Reassembler({ const reassembler = new Reassembler({
log: silentLog, log: silentLog,
max: 10, max: 10,
// One segment is 36 octets: 14 of short_message plus the two 11-octet addresses. // One segment is 1036 octets: 14 of short_message, the two 11-octet addresses and its object.
maxOctets: 80, maxOctets: 2100,
now: () => 0, now: () => 0,
onLost: () => undefined, onLost: () => undefined,
timeout: 60_000, timeout: 60_000,
@@ -1918,6 +2191,8 @@ describe('reassembly bounds', () => {
assert.ok(reopened.kept); assert.ok(reopened.kept);
assert.equal(reopened.whole, undefined); assert.equal(reopened.whole, undefined);
assert.ok(collect(reassembler, 3, 1, 2).kept);
assert.equal(reassembler.size, 2, 'a segment sent again replaces its octets rather than adding them');
reassembler.clear(); reassembler.clear();
}); });
@@ -1982,16 +2257,15 @@ describe('reassembly bounds', () => {
// Jasmin dispatches one request per connector at a time: holding a group unanswered until it was // Jasmin dispatches one request per connector at a time: holding a group unanswered until it was
// whole deadlocked every multi-segment message against it (interop-tests/findings/03-jasmin.md). // whole deadlocked every multi-segment message against it (interop-tests/findings/03-jasmin.md).
describe('the status a refused segment is answered with', () => { describe('the status a refused segment is answered with', () => {
// SMPP 3.4 lists ESME_RMSGQFUL under submit_sm_resp only; 4.6.2's retryable code is another.
test('names one the command the segment arrived on defines', () => { test('names one the command the segment arrived on defines', () => {
assert.equal(refusedSegmentStatus('submit_sm', 'full', 'udh'), 'ESME_RMSGQFUL'); assert.equal(refusedSegmentStatus('submit_sm', 'full', 'udh'), 'ESME_RTHROTTLED');
assert.equal(refusedSegmentStatus('deliver_sm', 'full', 'udh'), 'ESME_RX_T_APPN'); assert.equal(refusedSegmentStatus('deliver_sm', 'full', 'udh'), 'ESME_RX_T_APPN');
assert.equal(refusedSegmentStatus('submit_sm', 'unplaceable', 'udh'), 'ESME_RINVESMCLASS'); assert.equal(refusedSegmentStatus('submit_sm', 'unplaceable', 'udh'), 'ESME_RINVESMCLASS');
assert.equal(refusedSegmentStatus('deliver_sm', 'unplaceable', 'udh'), 'ESME_RINVESMCLASS'); assert.equal(refusedSegmentStatus('deliver_sm', 'unplaceable', 'udh'), 'ESME_RINVESMCLASS');
// esm_class is 0x00 on a sar_* segment and entirely valid: the TLV values are what cannot be honoured. // esm_class is 0x00 on a sar_* segment and entirely valid: the TLV values are what cannot be honoured.
assert.equal(refusedSegmentStatus('submit_sm', 'unplaceable', 'sar'), 'ESME_RINVTLVVAL'); assert.equal(refusedSegmentStatus('submit_sm', 'unplaceable', 'sar'), 'ESME_RINVTLVVAL');
assert.equal(refusedSegmentStatus('deliver_sm', 'unplaceable', 'sar'), 'ESME_RINVTLVVAL'); assert.equal(refusedSegmentStatus('deliver_sm', 'unplaceable', 'sar'), 'ESME_RINVTLVVAL');
assert.equal(refusedSegmentStatus('submit_sm', 'full', 'sar'), 'ESME_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. // Which command that is, for the one that travels both ways, is what the end it arrived at says.
@@ -2001,7 +2275,7 @@ describe('the status a refused segment is answered with', () => {
assert.equal(standsInFor('deliver_sm', 'esme'), 'deliver_sm'); assert.equal(standsInFor('deliver_sm', 'esme'), 'deliver_sm');
assert.equal(standsInFor('submit_sm', 'smsc'), 'submit_sm'); assert.equal(standsInFor('submit_sm', 'smsc'), 'submit_sm');
assert.equal(standsInFor('enquire_link', 'smsc'), 'enquire_link'); assert.equal(standsInFor('enquire_link', 'smsc'), 'enquire_link');
assert.equal(refusedSegmentStatus(standsInFor('data_sm', 'smsc'), 'full', 'udh'), 'ESME_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'); assert.equal(refusedSegmentStatus(standsInFor('data_sm', 'esme'), 'full', 'udh'), 'ESME_RX_T_APPN');
}); });
}); });
@@ -2814,6 +3088,31 @@ describe('graceful shutdown', () => {
assert.deepEqual(reported, []); assert.deepEqual(reported, []);
}); });
test('answers a peer\'s unbind before asking the session to end', async t => {
const session = new Session({ sock: new net.Socket() });
closeAfter(t, session);
const calls: string[] = [];
const incoming = incomingOn(session);
const close = session.close.bind(session);
session.sendReturn = pduObj => {
calls.push(pduObj.cmdName);
return Promise.resolve({});
};
session.close = options => {
calls.push('close');
return close(options);
};
await incoming.handle({ ...submitPdu(1), cmdId: 0x00000006, cmdName: 'unbind', params: {} });
assert.deepEqual(calls, ['unbind', 'close']);
});
}); });
describe('message id notation', () => { describe('message id notation', () => {
+86 -5
View File
@@ -12,6 +12,7 @@ import { DlrMerger } from '../src/dlr-merger.ts';
import { PduFramer } from '../src/pdu-framer.ts'; import { PduFramer } from '../src/pdu-framer.ts';
import { ReconnectLoop } from '../src/reconnect-loop.ts'; import { ReconnectLoop } from '../src/reconnect-loop.ts';
import { Session, bindCommands } from '../src/session.ts'; import { Session, bindCommands } from '../src/session.ts';
import { checkSessionOptions } from '../src/session-options.ts';
import { client } from '../src/client.ts'; import { client } from '../src/client.ts';
import { closeAfter, closeListenerAfter } from './teardown.ts'; import { closeAfter, closeListenerAfter } from './teardown.ts';
import { consts } from '../src/defs/constants.ts'; import { consts } from '../src/defs/constants.ts';
@@ -228,7 +229,7 @@ describe('bind', () => {
assert.equal(err, undefined); assert.equal(err, undefined);
assert.ok(session); assert.ok(session);
assert.ok(session.loggedIn); assert.equal(session.boundAs, 'transceiver');
assert.deepEqual(await session.unbind(), {}); assert.deepEqual(await session.unbind(), {});
}); });
@@ -316,6 +317,25 @@ describe('bind', () => {
assert.equal(await responded, '00000010800000150000000400000001'); assert.equal(await responded, '00000010800000150000000400000001');
}); });
test('leaves an outbind from an unbound peer unanswered', async t => {
const smpp = await startServer(t);
const errors: Error[] = [];
smpp.on('session', session => { session.on('sessionError', err => { errors.push(err); }); });
const peer = rawPeer(t, smpp.port);
peer.write({ cmdName: 'outbind', params: { password: 'pass', system_id: 'smsc' }, seqNr: 1 });
peer.write({ cmdName: 'enquire_link', seqNr: 2 });
const answered = await raceWithin(2000, peer.next());
assert.ok(answered, 'the peer was never answered');
assert.equal(answered.cmdStatus, 'ESME_RINVBNDSTS');
assert.equal(answered.seqNr, 2);
assert.deepEqual(errors, []);
});
test('answers the enquire_link a bound peer sends', async t => { test('answers the enquire_link a bound peer sends', async t => {
const smpp = await startServer(t); const smpp = await startServer(t);
const peer = rawPeer(t, smpp.port); const peer = rawPeer(t, smpp.port);
@@ -428,6 +448,35 @@ describe('bind', () => {
assert.ok(session.acceptsOptionalParams()); assert.ok(session.acceptsOptionalParams());
}); });
test('records a hand-wired bind through bound(), and nothing else writes it', t => {
const session = new Session({ sock: new net.Socket() });
closeAfter(t, session);
assert.equal(session.boundAs, undefined);
assert.equal(session.peerInterfaceVersion, undefined);
assert.deepEqual(session.bound('receiver', 0x34), {});
assert.equal(session.boundAs, 'receiver');
assert.equal(session.peerInterfaceVersion, 0x34);
assert.equal(session.bindAllows('submit_sm'), false);
assert.deepEqual(session.bound('transmitter', undefined), {});
assert.equal(session.peerInterfaceVersion, 0x00, 'no declared version is pre-3.4');
// The likeliest mistakes: the TLV object for its value, a version past int8, the command's name.
assert.match(session.bound('receiver', { tagValue: 0x34 }).err?.message ?? '', /tagValue, or undefined .* got object$/);
assert.ok(session.bound('receiver', 0x100).err);
assert.ok(session.bound('receiver', 3.4).err);
assert.match(session.bound('bind_receiver', 0x34).err?.message ?? '', /without "bind_", got "bind_receiver"$/);
assert.equal(session.boundAs, 'transmitter', 'a refused bind leaves the recorded one alone');
assert.equal(session.peerInterfaceVersion, 0x00);
assert.equal(Reflect.set(session, 'boundAs', 'transceiver'), false);
assert.equal(Reflect.set(session, 'peerInterfaceVersion', 0x50), false);
assert.equal(session.boundAs, 'transmitter');
assert.equal('loggedIn' in session, false);
});
// The spec: an absent sc_interface_version means the SMSC supports no optional parameters. // The spec: an absent sc_interface_version means the SMSC supports no optional parameters.
test('takes an SMSC that declares no version as older than 3.4', async t => { test('takes an SMSC that declares no version as older than 3.4', async t => {
const peer = await smscPeer(t); const peer = await smscPeer(t);
@@ -890,8 +939,8 @@ describe('receiving', () => {
// The refusal a submission gets is the one submit_sm_resp defines, whichever command carried it. // The refusal a submission gets is the one submit_sm_resp defines, whichever command carried it.
test('refuses a data_sm segment a server has no room for with the submit code', async t => { test('refuses a data_sm segment a server has no room for with the submit code', async t => {
// The two addresses are 22 octets, so the 6-octet UDH and its text are what overrun 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: 30 }); const smpp = await startServer(t, { maxOctets: 1330 });
const { session } = await connect(t, smpp, { bindType: 'transmitter' }); const { session } = await connect(t, smpp, { bindType: 'transmitter' });
assert.ok(session); assert.ok(session);
@@ -912,7 +961,7 @@ describe('receiving', () => {
assert.ok(refused.pduObj); assert.ok(refused.pduObj);
assert.equal(refused.pduObj.cmdName, 'data_sm_resp'); assert.equal(refused.pduObj.cmdName, 'data_sm_resp');
assert.equal(refused.pduObj.cmdStatus, 'ESME_RMSGQFUL'); assert.equal(refused.pduObj.cmdStatus, 'ESME_RTHROTTLED');
}); });
test('reassembles a concatenated message whose segments arrived in message_payload', async t => { test('reassembles a concatenated message whose segments arrived in message_payload', async t => {
@@ -1493,7 +1542,6 @@ describe('robustness', () => {
// 0.4.0 registered a listener per sequence number and waited forever, leaking one per call. // 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 => { test('gives up on a peer that never answers', async t => {
const accepted: net.Socket[] = []; 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(); }); const silent = net.createServer(sock => { accepted.push(sock); sock.resume(); });
await new Promise<void>(resolve => silent.listen(0, resolve)); await new Promise<void>(resolve => silent.listen(0, resolve));
@@ -1508,6 +1556,30 @@ describe('robustness', () => {
assert.ok(Date.now() - started < 5000, 'should have given up quickly'); assert.ok(Date.now() - started < 5000, 'should have given up quickly');
}); });
test('leaves an alert_notification and an outbind unanswered, since SMPP names no response', async t => {
const peer = await smscPeer(t);
const { session } = await client({ port: peer.port });
const errors: Error[] = [];
assert.ok(session);
closeAfter(t, session);
session.on('sessionError', err => { errors.push(err); });
peer.writeRaw(pduBytes({
cmdName: 'alert_notification',
params: { esme_addr: '46709771337', source_addr: '46701113311' },
seqNr: 10,
}));
peer.writeRaw(pduBytes({ cmdName: 'outbind', params: { password: 'pass', system_id: 'smsc' }, seqNr: 11 }));
peer.writeRaw(pduBytes({ cmdName: 'enquire_link', seqNr: 12 }));
const answered = await raceWithin(2000, peer.next());
assert.ok(answered, 'the peer was never answered');
assert.equal(answered.cmdName, 'enquire_link_resp');
assert.equal(answered.seqNr, 12);
assert.deepEqual(errors, []);
});
test('stops a connection attempt on an aborted signal', async () => { test('stops a connection attempt on an aborted signal', async () => {
const controller = new AbortController(); const controller = new AbortController();
@@ -2646,6 +2718,15 @@ describe('option validation', () => {
assert.match(hookRefused.err?.message ?? '', /onRequest must be a function/); assert.match(hookRefused.err?.message ?? '', /onRequest must be a function/);
}); });
test('refuses a reassembly octet cap below 1 at startup', async () => {
const listening = await server({ maxOctets: 0, port: 0 });
if (listening.server) await listening.server.close();
assert.match(listening.err?.message ?? '', /maxOctets must be 1 or more, got 0/);
assert.match(checkSessionOptions({ maxOctets: Infinity }).err?.message ?? '', /maxOctets must be 1 or more, got Infinity/);
});
test('returns an error rather than rejecting on an impossible port', async () => { test('returns an error rather than rejecting on an impossible port', async () => {
const listening = await server({ port: 70_000 }); const listening = await server({ port: 70_000 });
+1 -1
View File
@@ -135,7 +135,7 @@ describe('tls', () => {
assert.equal(err, undefined); assert.equal(err, undefined);
assert.ok(session); assert.ok(session);
assert.ok(session.loggedIn); assert.equal(session.boundAs, 'transceiver');
closeAfter(t, session); closeAfter(t, session);
const sock = session.sock; const sock = session.sock;
+114 -2
View File
@@ -1,8 +1,8 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test, { describe } from 'node:test'; import test, { describe } from 'node:test';
import type { DestAddress, UnsuccessSme } from '../src/defs/types.ts'; import type { DestAddress, UnsuccessSme } from '../src/defs/types.ts';
import { paramText, types } from '../src/defs/types.ts';
import { tlvs } from '../src/defs/tlvs.ts'; import { tlvs } from '../src/defs/tlvs.ts';
import { types } from '../src/defs/types.ts';
describe('integers', () => { describe('integers', () => {
test('int8 reads, sizes and writes one octet', () => { test('int8 reads, sizes and writes one octet', () => {
@@ -39,6 +39,11 @@ describe('integers', () => {
assert.ok(types.int8.write(-1, Buffer.alloc(1), 0).err instanceof Error); assert.ok(types.int8.write(-1, Buffer.alloc(1), 0).err instanceof Error);
assert.ok(types.int16.write(1.5, Buffer.alloc(2), 0).err instanceof Error); assert.ok(types.int16.write(1.5, Buffer.alloc(2), 0).err instanceof Error);
assert.ok(types.int8.write('nope', Buffer.alloc(1), 0).err instanceof Error); assert.ok(types.int8.write('nope', Buffer.alloc(1), 0).err instanceof Error);
const notANumber = types.int8.write(NaN, Buffer.alloc(1), 0);
assert.ok(notANumber.err instanceof Error);
assert.match(notANumber.err.message, /NaN/);
}); });
}); });
@@ -69,6 +74,27 @@ describe('string (Octet String)', () => {
assert.deepEqual(target, encoded); assert.deepEqual(target, encoded);
}); });
test('carries every latin1 octet, and refuses a character past it', () => {
const target = Buffer.alloc(4);
assert.deepEqual(types.string.read(Buffer.from([3, 0xE9, 0x80, 0xFF]), 0), {
bytesRead: 4,
value: 'é\u0080ÿ',
});
assert.deepEqual(types.string.write('é\u0080ÿ', target, 0), {});
assert.deepEqual(target, Buffer.from([3, 0xE9, 0x80, 0xFF]));
assert.ok(types.string.size('一').err instanceof Error);
assert.ok(types.string.write('一', Buffer.alloc(4), 0).err instanceof Error);
});
test('carries a NULL octet, which its length octet already bounds', () => {
const target = Buffer.alloc(4);
assert.deepEqual(types.string.write('a\u0000b', target, 0), {});
assert.deepEqual(target, Buffer.from([3, 0x61, 0x00, 0x62]));
});
}); });
describe('cstring (C-Octet String)', () => { describe('cstring (C-Octet String)', () => {
@@ -91,13 +117,39 @@ describe('cstring (C-Octet String)', () => {
assert.deepEqual(target, encoded); 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); const target = Buffer.alloc(4);
types.cstring.write(123, target, 0); types.cstring.write(123, target, 0);
assert.deepEqual(target, Buffer.from([0x31, 0x32, 0x33, 0x00])); assert.deepEqual(target, Buffer.from([0x31, 0x32, 0x33, 0x00]));
assert.deepEqual(types.cstring.size(123), { size: 4 }); assert.deepEqual(types.cstring.size(123), { size: 4 });
for (const value of [NaN, Infinity, -Infinity]) {
assert.ok(types.cstring.size(value).err instanceof Error, String(value));
assert.ok(types.cstring.write(value, Buffer.alloc(9), 0).err instanceof Error, String(value));
assert.ok(types.string.write(value, Buffer.alloc(9), 0).err instanceof Error, String(value));
assert.ok(types.buffer.write(value, Buffer.alloc(9), 0).err instanceof Error, String(value));
assert.ok(types.tlv.string.write(value, Buffer.alloc(9), 0).err instanceof Error, String(value));
assert.ok(types.tlv.cstring.write(value, Buffer.alloc(9), 0).err instanceof Error, String(value));
}
});
test('carries every latin1 octet, and refuses a character past it', () => {
const address = Buffer.from([0x4B, 0x61, 0x66, 0x66, 0x65, 0xE9, 0x00]);
const target = Buffer.alloc(7);
assert.deepEqual(types.cstring.read(address, 0), { bytesRead: 7, value: 'Kaffeé' });
assert.deepEqual(types.cstring.write('Kaffeé', target, 0), {});
assert.deepEqual(target, address);
assert.ok(types.cstring.size('一').err instanceof Error);
assert.ok(types.cstring.write('一', Buffer.alloc(4), 0).err instanceof Error);
});
test('refuses a NULL of its own rather than ending the field early', () => {
assert.ok(types.cstring.size('46701113311\u0000EVIL').err instanceof Error);
assert.ok(types.cstring.write('46701113311\u0000EVIL', Buffer.alloc(17), 0).err instanceof Error);
}); });
test('refuses a string with no terminator rather than running off the end', () => { test('refuses a string with no terminator rather than running off the end', () => {
@@ -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', () => { describe('buffer', () => {
const expected = Buffer.from('abcd1234'); const expected = Buffer.from('abcd1234');
@@ -170,6 +249,20 @@ describe('buffer', () => {
assert.deepEqual(target, expected); assert.deepEqual(target, expected);
}); });
test('takes a string as the latin1 octets it stands for', () => {
const target = Buffer.alloc(3);
assert.deepEqual(types.buffer.size('é\u0080ÿ'), { size: 3 });
assert.deepEqual(types.buffer.write('é\u0080ÿ', target, 0), {});
assert.deepEqual(target, Buffer.from([0xE9, 0x80, 0xFF]));
});
});
describe('paramText()', () => {
test('renders a Buffer parameter as the latin1 text its octets spell', () => {
assert.equal(paramText(Buffer.from([0x4B, 0x61, 0x66, 0x66, 0x65, 0xE9])), 'Kaffeé');
});
}); });
describe('dest_address_array', () => { describe('dest_address_array', () => {
@@ -198,6 +291,8 @@ describe('dest_address_array', () => {
test('sizes every dest_address structure', () => { test('sizes every dest_address structure', () => {
assert.deepEqual(types.dest_address_array.size(expected), { size: 13 }); assert.deepEqual(types.dest_address_array.size(expected), { size: 13 });
assert.ok(types.dest_address_array.size([{ dl_name: 'a\u0000b' }]).err instanceof Error);
assert.ok(types.dest_address_array.size([{ dest_addr_npi: 0, dest_addr_ton: 0, destination_addr: '一' }]).err instanceof Error);
}); });
test('writes every dest_address structure', () => { test('writes every dest_address structure', () => {
@@ -206,6 +301,11 @@ describe('dest_address_array', () => {
types.dest_address_array.write(expected, target, 0); types.dest_address_array.write(expected, target, 0);
assert.deepEqual(target, encoded); assert.deepEqual(target, encoded);
const smuggled: DestAddress[] = [{ dest_addr_npi: 1, dest_addr_ton: 1, destination_addr: '46\u0000EVIL' }];
assert.ok(types.dest_address_array.write(smuggled, Buffer.alloc(16), 0).err instanceof Error);
assert.ok(types.dest_address_array.write([{ dl_name: '一' }], Buffer.alloc(16), 0).err instanceof Error);
}); });
test('refuses a field value the wire cannot hold instead of throwing', () => { test('refuses a field value the wire cannot hold instead of throwing', () => {
@@ -237,6 +337,12 @@ describe('unsuccess_sme_array', () => {
test('sizes every unsuccess_sme structure', () => { test('sizes every unsuccess_sme structure', () => {
assert.deepEqual(types.unsuccess_sme_array.size(expected), { size: 21 }); assert.deepEqual(types.unsuccess_sme_array.size(expected), { size: 21 });
const unwritable: UnsuccessSme[] = [
{ dest_addr_npi: 0, dest_addr_ton: 0, destination_addr: 'a\u0000b', error_status_code: 0 },
];
assert.ok(types.unsuccess_sme_array.size(unwritable).err instanceof Error);
}); });
test('writes every unsuccess_sme structure', () => { test('writes every unsuccess_sme structure', () => {
@@ -245,6 +351,12 @@ describe('unsuccess_sme_array', () => {
types.unsuccess_sme_array.write(expected, target, 0); types.unsuccess_sme_array.write(expected, target, 0);
assert.deepEqual(target, encoded); assert.deepEqual(target, encoded);
const smuggled: UnsuccessSme[] = [
{ dest_addr_npi: 1, dest_addr_ton: 1, destination_addr: 'a\u0000b', error_status_code: 0 },
];
assert.ok(types.unsuccess_sme_array.write(smuggled, Buffer.alloc(16), 0).err instanceof Error);
}); });
test('refuses a field value the wire cannot hold instead of throwing', () => { test('refuses a field value the wire cannot hold instead of throwing', () => {
+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/); 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({ const built = objToPdu({
cmdName: 'data_sm', cmdName: 'data_sm',
params: { data_coding: 0x03, destination_addr: to, source_addr: from }, params: { data_coding: 0x08, destination_addr: to, source_addr: from },
tlvs: { body: { tagId: 0x0424, tagValue: 'あいう' } }, tlvs: { 1060: { tagValue: 'あいう' }, message_payload: { tagValue: 'あいう' } },
}); });
assert.ok(built.err instanceof Error); assert.ok(built.err instanceof Error);
assert.equal(built.buffer, undefined); assert.equal(built.buffer, undefined);
assert.match(built.err.message, /"body"/); assert.match(built.err.message, /"1060"/);
assert.match(built.err.message, /LATIN1/); assert.match(built.err.message, /message_payload/);
assert.match(built.err.message, /U\+3042/);
}); });
test('leaves data_coding to short_message wherever it carries octets, as messageOctets() reads it', () => { 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); 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', () => { test('leaves the alphabet to the body TLV wherever short_message carries no octets', () => {
for (const short of [undefined, '', Buffer.alloc(0)]) { for (const short of [undefined, '', Buffer.alloc(0)]) {
const built = objToPdu({ 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', () => { describe('a time no peer can read', () => {
const invalid = new Date('nope'); const invalid = new Date('nope');
+593 -127
View File
@@ -1,7 +1,7 @@
# todo.md # todo.md
Remaining work for `@larvit/smpp`. Read [AGENTS.md](AGENTS.md) first — the goals and hard rules Remaining work for `@larvit/smpp`. Read [README.md](README.md)'s goals and [AGENTS.md](AGENTS.md)'s
there constrain every item below. 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 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. 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 ## Status
The rewrite is **feature complete and green**: the suite, lint and typecheck are clean on Node 18 The rewrite is **feature complete and green**: the suite, lint and typecheck are clean on Node 18
to 26. 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 ## The agreed API
@@ -73,79 +75,92 @@ Every defect listed in the AGENTS.md table has a regression test naming the beha
## Move the repository to Gitea ## Move the repository to Gitea
`gitea.larvit.se/larvit/smpp-js` is the repository. `github.com/larvit/smpp-js` becomes a push mirror `gitea.larvit.se/larvit/smpp-js` is the repository. `github.com/larvit/smpp-js` mirrors it and is the
of it and the place issues are filed. Maintainer's calls, 2026-09-13 and 2026-09-14. 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` - [x] **Move `main` and `v0.4.0` to `larvit/smpp-js`.** It holds `main`, from `typescript`, and
and the `renovate/*` branches stayed behind. `v0.4.0`, from `master`. `rewrite-base` and the `renovate/*` branches stayed behind.
- [x] Fast-forward is the only merge style. `main` takes no pushes, requires `Test / lint - [x] **Protect `main` with fast-forward merges and required checks.** Fast-forward is the only
(pull_request)` and `Test / test (*) (pull_request)`, blocks an outdated branch, and gives merge style. `main` takes no pushes, requires `Test / lint (pull_request)` and `Test / test
admins no override. Every other branch takes force pushes. (*) (pull_request)`, blocks an outdated branch, and gives admins no override. Every other
- [x] The workflows are in `.gitea/workflows/`. Tests run on pull requests only, the event the gate branch takes force pushes.
reads; Renovate runs as a scheduled workflow, as on adf-codec. - [x] **Run the workflows from `.gitea/workflows/`.** Tests run on pull requests only, the event the
- [x] The release publishes without provenance, which npm generates only on GitHub Actions and gate reads; Renovate runs as a scheduled workflow, as on adf-codec.
GitLab CI/CD. - [x] **Publish the release without provenance.** npm generates it only on GitHub Actions and GitLab
- [x] `package.json` names Gitea, and the GitHub mirror's issues as `bugs`. The README links CI/CD.
- [x] **Name Gitea in `package.json`, and the GitHub mirror's issues as `bugs`.** The README links
absolutely: npmjs.com resolves a relative link against itself when the `repository` is not on absolutely: npmjs.com resolves a relative link against itself when the `repository` is not on
GitHub. Its test badge is gone, since Gitea reports a workflow's status per branch and no GitHub. Its test badge is gone, since Gitea reports a workflow's status per branch and no
workflow runs on `main`. workflow runs on `main`.
- `RENOVATE_GITHUB_TOKEN` exists nowhere, so Renovate queries github.com unauthenticated, as - **Add a github.com token for Renovate only if its lookups hit the rate limit.**
`RENOVATE_GITHUB_TOKEN` exists nowhere, so Renovate queries github.com unauthenticated, as
adf-codec's nightly run already does without a warning. `RENOVATE_TOKEN` is the Gitea token and adf-codec's nightly run already does without a warning. `RENOVATE_TOKEN` is the Gitea token and
cannot stand in for it. Add a github.com token only if lookups hit the rate limit. cannot stand in for it.
## Before publishing 0.5.0 ## Before publishing 0.5.0
- [x] 0.5.0 rather than 1.0.0, while usage is this low. Maintainer's call, 2026-09-14. - [x] **Publish the rewrite as 0.5.0, not 1.0.0, while usage is this low.** Maintainer's call,
- [x] `NPM_TOKEN`, which `.gitea/workflows/release.yaml` needs, is a Gitea organization secret. 2026-09-14.
- [ ] Tag `v0.5.0` on Gitea to publish. The first publish creates `@larvit/smpp` on npm, provided the - [x] **Hold `NPM_TOKEN` as a Gitea organization secret.** `.gitea/workflows/release.yaml` needs it.
token can publish under `@larvit`. - [x] **Tag `v0.5.0` on Gitea to publish.** The first publish creates `@larvit/smpp` on npm,
- [ ] `npm deprecate larvitsmpp` pointing at `@larvit/smpp`. Maintainer's call to run it; not provided the token can publish under `@larvit`.
something CI should do. - [x] **Deprecate `larvitsmpp` on npm, pointing at `@larvit/smpp`.** `npm deprecate larvitsmpp` is
the maintainer's call to run; not something CI should do.
## Retire the GitHub repository ## Retire the GitHub repository
Nothing here starts before 0.5.0 is published. Maintainer's call, 2026-09-14. Then in this order: 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 deleting GitHub's old branches closes every pull request based on them without a reply, and GitHub
`master` without a reply, and GitHub refuses to delete the default branch it syncs over. refuses to delete its default branch.
- [ ] Close the backlog below. - [x] **Close the backlog below.**
- [ ] Close [#71](https://github.com/larvit/larvitsmpp/pull/71), pointing at Gitea. - [x] **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] **Rename `larvit/larvitsmpp` to `larvit/smpp-js`.** GitHub redirects the old URLs, and the
URL in `package.json` resolves from then on. `bugs` URL in `package.json` resolves from then on.
- [ ] Push `main` and make it GitHub's default branch. - [x] **Push `main` and make it GitHub's default branch.**
- [ ] Remove Renovate and CodeRabbit from the GitHub repository. - [x] **Set Renovate to Silent for this repository in the Mend Developer Portal.** It then opens
- [ ] Add it to Gitea as a push mirror, with a GitHub credential that can write to it. 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] **Strip GitHub's repository to a mirror, and give both forges the package's About.** 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 ## Close the GitHub backlog
**Answer and close as fixed by 0.5.0**, the reply naming what fixed it: **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] **Close [#2](https://github.com/larvit/larvitsmpp/issues/2), tests for the README examples.**
`test/readme.test.ts`. Fixed by `test/readme.test.ts`.
- [ ] [#3](https://github.com/larvit/larvitsmpp/issues/3) Tests for flash messages: - [x] **Close [#3](https://github.com/larvit/larvitsmpp/issues/3), tests for flash messages.** Fixed
`test/session.test.ts`. by `test/session.test.ts`.
- [ ] [#4](https://github.com/larvit/larvitsmpp/issues/4) DLR errors with `message_state` missing: - [x] **Close [#4](https://github.com/larvit/larvitsmpp/issues/4), DLR errors with `message_state`
`dlrFromPdu()` parses the `stat:` receipt text when the TLVs are absent. 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] **Close [#13](https://github.com/larvit/larvitsmpp/issues/13), limit a long SMS to fewer
`maxSegments` send option. segments.** Fixed by the `maxSegments` send option.
- [ ] [#16](https://github.com/larvit/larvitsmpp/issues/16) Support all three bind types: bound and - [x] **Close [#16](https://github.com/larvit/larvitsmpp/issues/16), support all three bind types.**
enforced in both directions. Bound and enforced in both directions.
- [ ] [#17](https://github.com/larvit/larvitsmpp/issues/17) `addr_ton`/`addr_npi` should be - [x] **Close [#17](https://github.com/larvit/larvitsmpp/issues/17), `addr_ton`/`addr_npi` should be
settable: `sendSms()` takes all four, documented and tested. settable.** `sendSms()` takes all four, documented and tested.
- [ ] [#20](https://github.com/larvit/larvitsmpp/issues/20) Tests fail on current dependency - [x] **Close [#20](https://github.com/larvit/larvitsmpp/issues/20), tests fail on current
versions: the mocha suite is gone; `node:test` on Node 18 to 26. 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] **Close [#33](https://github.com/larvit/larvitsmpp/issues/33), large inbound text arrives as
`Buffer` segments: `IncomingRequests` reassembles a UDH-carrying `deliver_sm` into one `sms` raw `Buffer` segments.** `IncomingRequests` reassembles a UDH-carrying `deliver_sm` into one
event. `sms` event.
- [ ] [#68](https://github.com/larvit/larvitsmpp/pull/68), a pull request: `message_id` in - [x] **Close [#68](https://github.com/larvit/larvitsmpp/pull/68), a pull request for `message_id`
`submit_sm_resp`, spec DLR codes. All four hold: `sendResp()` always answers a `message_id`, in `submit_sm_resp` and spec DLR codes.** All four hold: `sendResp()` always answers a
per segment; `stat:UNDELIV` is the 7-character code. Credit the reporter — the fork found real `message_id`, per segment; `stat:UNDELIV` is the 7-character code. Credit the reporter — the
defects. fork found real defects.
**Close as superseded**, all against 0.4.0 dependencies the rewrite does not have — `async`, **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`: `coveralls`, `eslint`, `iconv-lite`, `larvitutils`, `mocha`, `mocha-eslint`, `portfinder`, `uuid`:
- [ ] [#40](https://github.com/larvit/larvitsmpp/pull/40), - [x] **Close the twelve dependency pull requests as superseded.**
[#40](https://github.com/larvit/larvitsmpp/pull/40),
[#41](https://github.com/larvit/larvitsmpp/pull/41), [#41](https://github.com/larvit/larvitsmpp/pull/41),
[#42](https://github.com/larvit/larvitsmpp/pull/42), [#42](https://github.com/larvit/larvitsmpp/pull/42),
[#45](https://github.com/larvit/larvitsmpp/pull/45), [#45](https://github.com/larvit/larvitsmpp/pull/45),
@@ -160,102 +175,553 @@ 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 [#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. 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 [#60](https://github.com/larvit/larvitsmpp/issues/60) is Renovate's dashboard and stays open after
Renovate's removal from GitHub. 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 **Close as tracked here**, the reply saying it will be implemented on Gitea:
port on log messages. Only `server - incoming connection` carries them today; putting them on every
session message is a change to every call site. - [x] **Close [#8](https://github.com/larvit/larvitsmpp/issues/8), the socket's remote host and port
on log messages, as tracked here.** It sits 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, `resolveBody`'s `settles` boolean, which
the panel also ranked hardest; it is now a named `CodingSource`.
A four-seat scoring run on 2026-09-27 read #30 at 6, 6, 7 and 6, every seat capped by Locality in
the held-message and shutdown code; #30 merged under the floor on condition that Locality is the
next work ([decision](docs/decisions.md#internals-and-tests)).
### Locality — next, ahead of everything below; 5–6 today, and the gate is 7
A second four-seat run on 2026-09-28, after #35–#40, read 6, 6, 7 and 6 again, Locality 5, 5, 6
and 5. Every seat ranked the session's lifecycle hardest and least wanted to modify it.
- [ ] **Lift Locality to 7, and confirm it with a scoring run.** A run reading 7.0 or above also
retires the #30 decision. The sub-items are what the 2026-09-28 run named, most seats first.
- [ ] **Give the link's liveness one owner.** A third run the same day, after #46, read 6, 6, 7 and
7, Locality 5, 5, 6 and 6; all four seats ranked `drain()`/`end()`/`teardown()`/`retrying()`
in `session.ts` hardest, because whether the link lives is kept in `Session.lifecycle`,
`ReconnectLoop.halted`, `LinkGate.up`/`returning`, `OutgoingRequests.draining` and
`IncomingRequests.linkGeneration`, held in step by statement order and the comment above
`retrying()`. Four seats.
### Correctness
- [ ] **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.
- [ ] **Build every error from a thrown value through `errorFrom()`.** `reconnect-loop.ts` (in
`run()`'s catch and in `bringUp()`) and `client.ts`'s connect still call `String(thrown)`,
which throws on a null-prototype object; in `run()` that lands as an unhandled rejection from
a `void`ed promise, against hard rule 1. From the 2026-09-28 scoring run.
- [ ] **Leave `sms.smsId` alone when `sendResp()` fails.** `sms.ts` assigns `options.smsId` before
the lost-link check and before the write, so a failed answer still renames the message and a
later `sendDlr()` names an id the peer was never given. From the 2026-09-28 scoring run.
- [ ] **Refuse a timeout past 2³¹−1 ms, as `connectTimeout` already is.** `checkLimits()` bounds
`responseTimeout`, `idleTimeout`, `shutdownTimeout` and `reassemblyTimeout` from below only,
and Node fires a larger delay after 1 ms. From the 2026-09-28 scoring run.
- [ ] **Keep a bare ESC out of GSM detection.** `gsmRegex` in `defs/encodings.ts` admits `\x1B`,
so `"\x1B("` is detected as GSM, goes out as 0x1B 0x28 and arrives as `{`. From the
2026-09-28 scoring run.
- [ ] **Count an `sms` listener that throws as one giving up, as a rejection already is.** A
synchronous throw makes `Session.emit` return false, and `HeldMessages.offer()` then releases
the hold at once, so a drain stops waiting on an async listener still answering beside it.
Goal 2. From the stability review of #45.
### Throughput — goal 6, and the default window is where we are slowest
- [ ] **Close the gap to jsmpp at `maxOutstanding: 10`.** Measured 2026-09-20 against the same sink,
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.
### Shape — 6 today, and the gate is 7
- [ ] **Answer "is this a bind command" in one place.** `bindCommands` (read by
`incoming-requests.ts`, `outgoing-requests.ts` and `test/session.test.ts`) and
`bindTypeFromCommand()` (read by `server.ts` and `checkedBind()`) each list the three bind
commands, so a fourth added to one is missed by the other. Derive the list from the function,
or the reverse. From the stability review of #42.
- [ ] **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.
- [ ] **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 three left to inline
as one line each: that `segmentUnits`' three numbers are in two units (septets and octets),
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.
- [ ] **State in README that `SmppServer.close()` reports each session's unfinished drain as
`serverError`.** Only `docs/decisions.md` says so; README's Shutdown section covers the
session's own result alone.
- [ ] **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
- [ ] **Log-cap every interop peer and probe Kannel by protocol, as `interop-tests/AGENTS.md` says
every peer is.** `compose.kannel.yaml` and `compose.smscsim.yaml` carry no `logging:` block,
and Kannel's healthcheck is the bare TCP probe that file warns against. From the prose sweep
of #46.
- [ ] **Cut what the prose sweep of #46 found restated or misplaced.** `docs/decisions.md` entries
of 30–45 lines carrying pre-fix history (133–160, 217–255, 362–402, 404–440, 442–472,
593–624, 626–662); AGENTS.md's 14-line shared-fixtures bullet, whose tolerated-copies
reasoning is a decision; the defect table rows MIGRATION.md already carries; README's
`error`-event reason (hard rule 3 owns it) and the Audience bullets restating goal 8 and
Install; the `'use strict'` clause in both MIGRATION.md and CHANGELOG.md; the node-smpp
cross-check in MIGRATION.md; the planned work in `interop-tests/AGENTS.md` (an expected
malformed count per peer) and `benchmarks/README.md`. README persona 3 names a store nothing
ships yet — the maintainer's call, since it is the audience.
- [ ] **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. The gate decision in `docs/decisions.md` makes the same claim
in its title, and carries the same fix. From the comprehension panel of #25.
- [ ] **Move `checkSessionOptions()`'s doc comment to what it describes.** It explains why a count
below 1 is refused, which is `checkLimits`' job, and says nothing of the function it heads.
From the comprehension panel of #25.
- [ ] **Log why `DlrMerger.expect()` registered no merge.** Ids with no common `<base>-<n>`
numbering return silently, the likeliest cause of a `messageDlr` that never fires and the one
that leaves no trace. From the comprehension panel of #25.
- [ ] **Make "every README example is executed by the suite" true, or stop claiming it.** Goal 10 and
the Done table both promise it; `test/readme.test.ts` transcribes the examples by hand and has
drifted — 15 fenced `javascript` blocks in the README against 10 tests, and the test named "the
documented sending options" passes none of the five options the README's example passes. Read
the fenced blocks at test time and assert each appears verbatim in the executed source, so an
edit to either fails the gate.
- [ ] **Narrow the `src/defs/*` lint exemption to the four table files.** Its stated reason — "the
spec tables are data: their length tracks the specification, not any complexity" — is false for
`defs/types.ts`, which is 595 lines of wire codec with 25 functions and is the file that parses
hostile input from the network. It carries more over-budget methods than any other file in the
repo, under a suppression written for something else.
- [ ] **Run the interop suite before cutting a minor, and date the claim.** README states
"Interoperable. Tested as a client against Jasmin and SMPPSim, and as a server against Kannel,
jsmpp, Cloudhopper, python-smpplib and php-smpp" in the present tense; `interop-tests/README.md`
is honest that the run was 2026-09-08. Nothing runs the peers on a schedule or before a tag, so
the claim rots silently. Goals 1 and 9.
## Worth doing, not blocking ## Worth doing, not blocking
- [ ] **A send the codec will refuse waits for a link and a window slot first.** `refuse()` in - [ ] **Move the decisions out of AGENTS.md's fixtures paragraph and the two tooling READMEs.** The
`outgoing-requests.ts` runs `misuse()` and the abort check before the wait, precisely so a call one dummy SMSC, `smscPeer()` staying separate and which copied helpers are tolerated
that can never go out does not queue for what it will never use; a body `objToPdu()` refuses on (AGENTS.md Conventions), and why Kannel is absent from `benchmarks/README.md`, go to
every attempt is the same case, and #98 made it a common one. On a down link the caller waits `docs/decisions.md` with index lines. Move the planned work written into
`responseTimeout` and is told the link failed rather than that the body could not be built — `interop-tests/AGENTS.md` (an expected count per peer) and `benchmarks/README.md` (the default
goal 2's wrong answer about what happened. The cheap fix builds the PDU twice, so the shape is window gap) to this file. Give the `run.py`-in-background footgun in `interop-tests/AGENTS.md`
the open half. Raised by the architecture review of 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.
- [ ] **Refuse a send the codec cannot build before it waits for a link and a window slot.** Today
it waits 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 every attempt is the same case, and #98 made it a
common one. On a down link the caller waits `responseTimeout` and is told the link failed
rather than that the body could not be built — goal 2's wrong answer about what happened. The
cheap fix builds the PDU twice, so the shape is the open half. Raised by the architecture
review of [#98](https://github.com/larvit/larvitsmpp/pull/98), 2026-09-09.
- [ ] **Split the SMPP time format out of `message.ts` once the file has to move anyway.** It
answers two questions: message coding and the SMPP time format (`smppDate`, `smppTime`) share
the file, which the architecture map in AGENTS.md already spells out as four concerns. Nothing
is wrong today; if the file has to move for another reason, `smpp-time.ts` is the split.
Raised by the architecture review of
[#98](https://github.com/larvit/larvitsmpp/pull/98), 2026-09-09. [#98](https://github.com/larvit/larvitsmpp/pull/98), 2026-09-09.
- [ ] **`message.ts` answers two questions.** Message coding and the SMPP time format (`smppDate`, - [ ] **Add a gate that refuses a floating version anywhere in the repo.** Maintainer's ask on
`smppTime`) share the file, which the architecture map in AGENTS.md already spells out as four [#71](https://github.com/larvit/larvitsmpp/pull/71), 2026-09-06, on the `release.yaml` pinning
concerns. Nothing is wrong today; if the file has to move for another reason, `smpp-time.ts` is thread. Pinning every action and runner by hand is what the ask followed; the gate is what
the split. Raised by the architecture review of keeps them pinned. It has to cover workflow `uses:` and `runs-on:`, compose `image:`, and
[#98](https://github.com/larvit/larvitsmpp/pull/98), 2026-09-09.
- [ ] **A gate that refuses a floating version anywhere in the repo.** Maintainer's ask on
[#71](https://github.com/larvit/larvitsmpp/pull/71), 2026-09-06, on the `release.yaml`
pinning thread. Pinning every action and runner by hand is what the ask followed; the gate is
what keeps them pinned. It has to cover workflow `uses:` and `runs-on:`, compose `image:`, and
Dockerfile `FROM`, and the conventions differ per kind — actions take a semver tag, images the Dockerfile `FROM`, and the conventions differ per kind — actions take a semver tag, images the
full patch version — so one grep for `latest` is not it. full patch version — so one grep for `latest` is not it.
- [ ] **A gate that fails when the test matrix misses the current Node.** Maintainer's ask on - [ ] **Add a gate that catches the test matrix missing the current Node.** Maintainer's ask on
[#71](https://github.com/larvit/larvitsmpp/pull/71), 2026-09-06, on the Node 26 thread. Node [#71](https://github.com/larvit/larvitsmpp/pull/71), 2026-09-06, on the Node 26 thread. Node
26 was added by hand; nothing notices when 27 ships. Needs a source for what Current is — the 26 was added by hand; nothing notices when 27 ships. Needs a source for what Current is — the
Node release schedule is published as JSON — and a decision on whether a new Current fails the Node release schedule is published as JSON — and a decision on whether a new Current fails the
build or opens a PR, which is what Renovate already does for everything else here. build or opens a PR, which is what Renovate already does for everything else here.
- [ ] **CodeRabbit reviews through the GitHub mirror.** CodeRabbit does not support Gitea, so mirror - [ ] **Have CodeRabbit review Gitea pull requests through the GitHub mirror.** CodeRabbit does not
each Gitea pull request to GitHub for it to review there. Maintainer's ask, 2026-09-14; not support Gitea, so mirror each Gitea pull request to GitHub for it to review there.
started until asked. Maintainer's ask, 2026-09-14; not started until asked.
- [ ] **Group the session's collaborators under `src/session/`.** `session.ts` imports - [ ] **Count what is left of a budget one way in `leftOf()` and the link gate.** Today they are one
`dlr-merger`, `incoming-requests`, `link-timers`, `outgoing-requests`, `pdu-transport`, concept counted twice. `idle-waiters.ts` reads what is left of a budget as `Math.max(1,
`reconnect-loop` and `send-sms`, and nothing else does, so the directory would make that deadline - now)`, because 0 means "forever" there; `link-gate.ts` runs the same subtraction
boundary visible. The `OutgoingRequests` extraction this was to be done with landed on and calls `<= 0` expired. Neither is reachable from the other, so nothing can disagree today,
2026-09-01, so it is the remaining half. Raised by review, 2026-09-01. but a reader who learns one and applies it to the other is wrong. A budget type both take
would close it. Raised by review, 2026-09-01.
- [ ] **`leftOf()` and the link gate's own budget are one concept counted twice.** - [ ] **Write a non-zero `err:` on a receipt only for a state that failed.** `receiptText()` now
`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.
Neither is reachable from the other, so nothing can disagree today, but a reader who learns one
and applies it to the other is wrong. A budget type both take would close it. Raised by review,
2026-09-01.
- [ ] **`err:` on a receipt for a state that neither delivered nor failed.** `receiptText()` now
writes `err:000` for `DELIVERED` and for the two transient states, and `err:001` for every writes `err:000` for `DELIVERED` and for the two transient states, and `err:001` for every
other — so `ACCEPTED`, `SKIPPED`, `UNKNOWN` and `DELETED` still announce an error code the SMSC other — so `ACCEPTED`, `SKIPPED`, `UNKNOWN` and `DELETED` still announce an error code the
never had. Which of those are failures is the open half. Raised by review, 2026-09-03; needs a SMSC never had. Which of those are failures is the open half. Raised by review, 2026-09-03;
decision. needs a decision.
- [ ] **`once()` is copied into four test files, and two copies never give up.** - [ ] **Share one `once()` across the test files, one that gives up.** Today it is copied into four
`session-extras.test.ts` and `readme.test.ts` reject after 5000 ms; `session.test.ts` and test files, and two copies never give up. `session-extras.test.ts` and `readme.test.ts` reject
`tls.test.ts` wait forever, so an event that never fires still hangs the run the way an after 5000 ms; `session.test.ts` and `tls.test.ts` wait forever, so an event that never fires
unclosed listener used to. One shared, guarded copy closes the rest of that class. still hangs the run the way an unclosed listener used to. One shared, guarded copy closes the
rest of that class.
- [ ] **A peer whose message ids share one base logs a refused merge on every send.** `smsc01-000123` - [ ] **Carry the peer's address and bind on every session log message.** `remoteAddress` and
and `smsc01-000124` carry the same base, so `DlrMerger` merges the first message and refuses `remotePort` reach only `server - incoming connection`, and `systemId` only the bind messages,
every one after it, one log line per send. Left at `info` — nothing the operator can fix is so with several peers connected one session's lines cannot be told apart, and a reconnect
wrong — but a rate guard or silence may suit it better. Raised by review, 2026-08-30. 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.
- [ ] **`submit_multi` and the broadcast commands** encode and decode, but nothing exercises them - [ ] **Settle how a peer whose message ids share one base logs its refused merges.** Today it logs
end to end. The interop suite is the natural place. one on every send. `smsc01-000123` and `smsc01-000124` carry the same base, so `DlrMerger`
- [ ] **Move to TypeScript 7** once `typescript-eslint` supports it; `renovate.json` pins TypeScript merges the first message and refuses every one after it, one log line per send. Left at `info`
— nothing the operator can fix is wrong — but a rate guard or silence may suit it better.
Raised by review, 2026-08-30.
- [ ] **Exercise `submit_multi` and the broadcast commands end to end.** They encode and decode, but
nothing exercises them 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. below 6.1 for exactly that reason.
- [ ] **Coverage reporting.** `node --test --experimental-test-coverage` works today; nothing - [ ] **Add an `onReceipt` hook.** Receipt text is only loosely specified and operators disagree on
publishes the numbers. 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 dispatch, which owns the response as well. Mirror the `onRequest` seam —
return a `Dlr` to own the receipt, `undefined` to fall through to the built-in parser.
- [ ] **An `onReceipt` hook.** Receipt text is only loosely specified and operators disagree on it, ## Gaps against other SMPP libraries
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 From comparing 0.5.0 with `smpp`, `@semyonf/smpp`, `@leissner/node-red-smpp`, `node-smpp-next`,
dispatch, which owns the response as well. `smpp-js-sdk`, `smppjs`, cloudhopper-smpp, jsmpp, go-smpp, Kannel, Jasmin and php-smpp, 2026-09-14.
Mirror the `onRequest` seam — return a `Dlr` to own the receipt, `undefined` to fall through Each lands under goal 7: an option or a hook, with the call that passes none unchanged.
to the built-in parser.
### Sending
- [ ] **Add 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.
- [ ] **Let `sendSms()` take 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.
- [ ] **Fail over 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
- [ ] **Send `deliver_sm` from `sendSms()` on a `server()` session.** Today it 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`).
- [ ] **Let a response this library builds carry error TLVs.** `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.
- [ ] **Accept the 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.
- [ ] **Handle `outbind`.** It is 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.
- [ ] **Carry 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.
- [ ] **Read and send the 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.
- [ ] **Offer 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.
- [ ] **Give each alphabet in `consts.ENCODING` one name.** Five are spelled 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
- [ ] **Add metrics: outbound PDU events and counts of requests waiting.** 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.
- [ ] **Add a coverage report and a floor to 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.
- [ ] **Add 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.
- [ ] **Build 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 ## Declined
- **Merge state surviving a process restart.** Declined by AGENTS.md goal 7, maintainer's call, - **A check that warns before `MIRROR_GITHUB_TOKEN` expires.** Gitea mails no one about a failed
2026-09-02. A restart loses every incomplete receipt group and a peer has no reason to resend one it scheduled run, since its Actions bot triggers those, so a nightly check would fail unseen. The
already had answered, so the loss is real — but surviving it means handing the application the merge maintainer relies on GitHub's own expiry reminders, and on the mirror's first failed push run after
state to persist, which the scope floor covers as squarely as holding the state here would, and expiry, which mails whoever pushed. Maintainer's call, 2026-09-14.
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.
- **Throughput throttling — a TPS cap, and backing off on `ESME_RTHROTTLED`.** Declined by AGENTS.md - **CommonJS.** ESM only, maintainer's call reaffirmed 2026-09-14, though `node-smpp-next` ships both.
goal 7: an operator's rate limit is scoped to the account, while the widest thing this library owns `require()` of an ES module works unflagged from Node 20.19 and 22.12.
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 ## An optional store
`maxOutstanding` stays — a window slot frees on the peer's next response, which is self-limiting in
a way a rate ceiling is not. - [ ] **Add 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"], "types": ["node"],
"verbatimModuleSyntax": true "verbatimModuleSyntax": true
}, },
"include": ["eslint.config.ts", "interop-tests", "src", "test"] "include": ["benchmarks", "eslint.config.ts", "interop-tests", "src", "test"]
} }