25 KiB
Interoperability test plan for 1.0.0
Pre-release working file, like todo.md: it turns into interop-tests/README.md once the harness
below exists, and is deleted with it otherwise.
Purpose
Find where the rewrite disagrees with SMPP as real peers run it. The suite cross-checks one independent implementation, node-smpp, in both directions and over a live session. Everything here adds peers written by other people, in other languages, with other opinions about the spec — and faults the suite's own dummy peers never inject.
Baseline
docker compose run --rm node npm test on typescript at 9ca83ac, 2026-09-05: 303 pass, 0 fail,
lint and typecheck clean.
Targets read from the code
Ranked by how likely a real peer is to trip it. Each is a scenario in the matrix below.
- One unreadable PDU drops the link.
pdu-transport.tsroutes every codec error — an unknown command id, a TLV the codec refuses, a malformed body — to the teardown a framing error takes, althoughcommand_lengthwas honoured and the stream is still in sync. One vendor-specific PDU or odd TLV from an SMSC becomes a reconnect loop. Spec:generic_nackwithESME_RINVCMDIDfor an unknown command, a*_respwithESME_RINVTLVSTREAMfor a bad TLV, and carry on. message_payloadis never read.incoming-requests.tstakes the text fromshort_messageonly. Adeliver_smwithsm_length0 and the body inmessage_payload— how several SMSCs carry a long MO, Melrose Labs and Telia among the documented ones — arrives as an empty message.sar_*segmentation is never read. Reassembly keys on the UDH only; segments carried assar_msg_ref_num/sar_total_segments/sar_segment_seqnumreach the application as fragments. Jasmin's default MT segmentation is SAR.data_smis refused withdata_sm_respESME_RINVCMDID. CM.com documents receipts ondata_smas a configuration; Jasmin's DLR thrower can use it too.- Long messages go out one way only: UDH with an 8-bit reference and
esm_class0x40. Nosar_*, nomessage_payload, no 16-bit reference. LINK Mobility wants exactly this and rejects the other two; Route Mobile and Kaleyra document 0x43 and validate UDH length strictly. Acceptable for 1.0 if documented — but find out which peers refuse it. - Known-but-unhandled commands (
query_sm,cancel_sm,replace_sm,submit_multi,alert_notification,outbind) get a*_respcarryingESME_RINVCMDID. Check strict clients accept that, and thatalert_notificationandoutbind, which have no response PDU, do not produce a bogus one. - Receipt text parsing is a regex per field. Real formats to verify against: LINK writes
sub:000 dlvrd:000and an emptytext:; Vonage writesstat:FAILED, outside the spec's table, and one receipt per segment; tyntec sends a buffered receipt and then a final one for the same id; Infobip writesstat:ENROUTEin an ordinary receipt; Telesign gives only segment 1 amessage_id. Also: field order,id:hex vs decimal, dates with and without seconds, noid:. - Bind version negotiation. A peer that omits
sc_interface_versionis treated as pre-3.4 and sent no TLVs. Right for a client; on the server side an ESME declaring 0x33 (Kannelinterface-version = "33") must get text-only receipts and still correlate them. - Unbind. Most SMSCs close the socket instead of answering; close-after-unbind is read as
clean. Verify no
sessionErrornoise and no reconnect attempt. Telia requires anunbindbefore the TCP close — check whatclose()puts on the wire. - Reconnect against a peer that refuses the bind (
ESME_RBINDFAIL,ESME_RINVPASWD,ESME_RALYBND— Vonage's answer to a third bind — or TCP refused) must back off, never flood. - Window. Telenor's default window is 1; Kannel's
max-pending-submitsis 10. A peer enforcing 1, or closing on overrun, must not lose or duplicate. - Flash is detected on coding group 0x10 only; the 0xF0 GSM message-class group is not read
as flash. Minor, but it is what
sms.flashpromises.
Counterparts
Everything runs in Docker with full patch-version pins, resolved at setup; hosted services are manual runs, never CI. Tags below were current on 2026-09-05.
SMSC side — our client binds to them
| Peer | Why it earns a slot | Run | Knobs that matter |
|---|---|---|---|
| Jasmin 0.11.0 — Python/Twisted, a production gateway, not a toy | Independent codec (smpp.pdu), SAR segmentation by default, UUID message ids, a real DLR pipeline that can use data_sm |
jookies/jasmin:0.11.0 + redis:8.8.2-alpine + rabbitmq:4.3.5-management-alpine (env REDIS_CLIENT_HOST, AMQP_BROKER_HOST; fall back to redis:7.4.x / rabbitmq:3.13.x if 0.11.0 balks). User, group, mtrouter and morouter over jcli on 8990 |
[smpp-server]: enquireLinkTimerSecs 30, inactivityTimerSecs 300, responseTimerSecs 60; submit_throughput for throttling; dlr_level |
| SMPPSim 2.6.x — Java, the classic | Richest fault injection of anything open: per-state receipt percentages, delayed and intermediate receipts, queue-full, LOOPBACK (echoes our submit_sm back as deliver_sm), SMSC-initiated outbind, receipts with or without TLVs, its own decoded PDU log |
Vendor site is down (522); build from kwahome/smpp-sim-docker and tag locally. Port 2775, HTTP 8884, smppclient1/password |
DELIVERY_RECEIPT_OPTIONAL_PARAMS (off = Kannel-style text-only receipts), PERCENTAGE_DELIVERED/UNDELIVERABLE/ACCEPTED/REJECTED, PERCENTAGE_THAT_TRANSITION, MAX_TIME_ENROUTE, DELAY_DELIVERY_RECEIPTS_BY, OUTBOUND_QUEUE_MAX_SIZE, OUTBIND_ENABLED, CAPTURE_*_DECODED_TO_FILE |
| ukarim/smscsim 0.2.0 — Go | Zero setup; MO injection from a web page; the smoke test | ukarim/smscsim:0.2.0, ports 2775 and 12775, no auth |
FAILED_SUBMITS=1 fails even sequence numbers and undelivers odd ones. No PDU validation, so it proves nothing about strictness |
jsmpp SMPPServerSimulator — Java |
Strict PDU validation on the SMSC side, from the best-maintained SMPP codebase found (pushed 2026-06) | Build from opentelecoms-org/jsmpp jsmpp-examples; no image |
Only if the Java clients below leave a gap |
The two hosted simulators, Melrose Labs and smscsim.smpp.org, are dropped, maintainer's call, 2026-09-06: the useful tiers cost money a hobby project does not have, and the peers above already carry the traffic they would have. What goes with them is named under Untested rather than left to be assumed.
Skipped, with the reason: Kannel's opensmppbox is not in any package and last saw a commit in
2014; fakesmsc does not speak SMPP; OsmoMSC and Restcomm need a telecom stack for no extra
coverage.
ESME side — they bind to our server
| Peer | Why it earns a slot | Run | Knobs that matter |
|---|---|---|---|
Kannel 1.4.5 bearerbox + smsbox — C |
The most deployed real ESME there is; parses our receipts with the parser most operators' customers run; declares 3.4 or 3.3 on demand | debian:bookworm-20260824-slim + apt-get install kannel (1.4.5-12). Submit over smsbox HTTP sendsms; MO out to a tiny HTTP receiver through an sms-service get-url |
group = smsc, smsc = smpp: interface-version = "34" or "33", msg-id-type, max-pending-submits (1 and 10), enquire-link-interval (30), wait-ack (60) with wait-ack-expire, transceiver-mode, alt-charset, dlr-mask on sendsms |
| jsmpp — Java, active | Strict, low-level: the caller builds UDH, sar_* or message_payload bytes by hand, so it is the tool for targets 2, 3, 5 and 6. Assumes 3.4 when sc_interface_version is absent; crashes on an unnameable one |
Maven build in a pinned JDK image; no published image | interface_version, raw optional parameters, query_sm/cancel_sm/replace_sm calls |
| Cloudhopper (fizzed fork) — Java/Netty | The windowing client: setWindowSize, setRequestExpiryTimeout, setWindowMonitorInterval; async submits; an SSL demo |
Maven build; make client, make ssl-client |
Window 1, 10, 50 against a slow sms listener; request expiry shorter than our response |
| python-smpplib 2.2.4 — Python | Sends GSM 7-bit unpacked with 0x1B escapes, UDH long messages, reactive enquire_link; the easiest peer to script, so also the driver for the server-side matrix |
python:3.12.14-slim-bookworm + pip install smpplib==2.2.4 |
interface_version kwarg, auto_send_enquire_link, make_parts_encoded |
| php-smpp (alexandr-mironov fork) — PHP | Binds transmitter and receiver separately, which is the direction-enforcement path; three long-message modes from one client: CSMS_16BIT_TAGS, CSMS_PAYLOAD, CSMS_8BIT_UDH. It does bind as a transceiver, contrary to what its inherited README says — phase 6 found that out |
php:8.4.25-cli, cloned at a pinned commit; LGPL-2.0-or-later per its composer.json. Its socket guard uses a check PHP 8 broke, so the peer image patches it |
The three CSMS modes; submit_sm on the RX bind |
Jasmin smppccm — Python/Twisted |
A production gateway as the ESME: its own reconnect, elink_interval, receipt parsing of what we send, dlr_msgid |
Same stack as above; smppccm -a pointing at our server, mtrouter, HTTP /send |
elink_interval, con_loss_retry/con_loss_delay, coding, dlr_msgid, submit_throughput |
| smppload 2.5.3 — Erlang | The only free, scriptable load generator with UDH long messages: -r rps, -T threads, -c count, -D receipts, -C data_coding |
Hand-built OTP image; expect build friction (issue #8) | No real window — in-flight is threads × rps — and no enquire_link at all, which is itself a real-world shape |
vponomarev/libsmpp smpp-dumb-client — Go |
The one load tool with an enforced bounded window (generator.window) |
Go build stage, YAML config | rate, window, count, stayConnected |
fiorix/go-smpp cmd/sms — Go |
One-shot bind + submit + receive from a fourth language; --tls |
Go build stage | — |
Validator — always on
tshark decodes every PDU independently of both ends, including TLVs and UDH/SAR reassembly, and
flags malformed ones. nicolaka/netshoot:v0.16, or debian:13.6-slim + tshark (4.4.18), as a
sidecar on the compose network:
dumpcap -i any -f 'tcp port 2775' -w capture.pcapng
tshark -r capture.pcapng -d tcp.port==2775,smpp -Y smpp -T json > capture.json
Assert: no _ws.malformed, no _ws.expert at error severity, every TLV decoded by name, UDH
totals consistent. Run it under the existing suite too — it is the cheapest independent check of
every byte we emit. gurk4n's browser decoder and SMPPSim's CAPTURE_*_DECODED_TO_FILE are the
manual fallbacks.
Scenario matrix
Every scenario runs with the tshark sidecar. "Fixture" means a raw-socket peer in our own suite,
because no open implementation emits that shape on demand; those scenarios are unit tests derived
from real-world documentation, not interop runs, and belong in test/.
Our client against an SMSC
| # | Scenario | Assert | Peers |
|---|---|---|---|
| C1 | Bind each type, enquire_link both ways, unbind |
Bound; SMSC-initiated enquire_link answered; close after our unbind is clean, no reconnect |
all SMSC peers; Jasmin (30 s) |
| C2 | Text-only receipts, no TLVs | dlr.smsId equals the submit_sm_resp id; statusMsg right; messageDlr merges per segment |
SMPPSim with DELIVERY_RECEIPT_OPTIONAL_PARAMS off |
| C3 | Receipts with receipted_message_id + message_state |
Same, and TLV wins over body when both present | Jasmin, SMPPSim TLVs on |
| C4 | Intermediate then final receipt | First arrives intermediate: true and never counts in messageDlr; final settles |
SMPPSim PERCENTAGE_THAT_TRANSITION 100, MAX_TIME_ENROUTE |
| C5 | Failure states | UNDELIV, REJECTD, EXPIRED, ACCEPTD mapped; messageDlr carries the worst segment |
SMPPSim percentages |
| C6 | Receipt delayed past a link drop | Merge survives the reconnect; late receipt still reaches dlr |
SMPPSim DELAY_DELIVERY_RECEIPTS_BY + kill the TCP link |
| C7 | Long MT: GSM with extension chars, UCS-2 with emoji, 2/3/10 segments | One id per segment; peer reassembles (loopback or MO route shows the whole text); receipt per segment | SMPPSim LOOPBACK, Jasmin MO route |
| C8 | Long MO as UDH 8-bit, UDH 16-bit, sar_*, message_payload |
One sms with the whole text in every spelling (targets 2, 3) |
Jasmin (SAR), jsmpp (message_payload, UDH 16), SMPPSim loopback (UDH 8) |
| C9 | MO or receipt on data_sm |
Not refused; reaches sms/dlr (target 4) |
Jasmin if configurable, else fixture |
| C10 | Unknown command id, unknown or malformed TLV, vendor TLV, zero-length integer TLV from the SMSC | Link stays up; generic_nack/*_resp with the right status; sessionError logged once (target 1) |
fixture |
| C11 | Bind refused: bad password, ESME_RALYBND, TCP refused |
Backoff 1 s → 30 s, one attempt per interval, no flood (target 10) | Jasmin bad creds, SMPPSim SYSTEM_IDS, closed port |
| C12 | ESME_RTHROTTLED and ESME_RMSGQFUL on submit |
sendSms returns err naming the status; session stays bound; next send works |
Jasmin submit_throughput, SMPPSim queue sizes, smscsim FAILED_SUBMITS |
| C13 | Slow SMSC and a full window | maxOutstanding queues, nothing overruns; close() drains; responseTimeout fires as documented (target 11) |
Jasmin responseTimerSecs, SMPPSim delays |
| C14 | TLS bind against a public CA | Handshake, bind, send, receipt | dropped — see Untested |
| C15 | interfaceVersion 0x50 and a peer answering 3.3 or no sc_interface_version |
Bound; no TLVs sent to a pre-3.4 peer; TLVs sent to 3.4+ (target 8) | SMPPSim, fixture; no 5.0 peer — see Untested |
| C16 | Receipt text variants from operator docs | LINK sub:000 dlvrd:000 text:; Vonage stat:FAILED; tyntec two receipts one id; Infobip stat:ENROUTE under 0x04; no id:; hex id in submit_sm_resp, decimal in receipt (target 7) |
fixture |
| C17 | Encodings round trip | Latin-1, UCS-2 big-endian, flash 0x10 and 0xF0, 8-bit binary with UDH — what comes back matches (target 12) | SMPPSim loopback |
| C18 | outbind from the SMSC to our server |
No bogus response PDU; logged (target 6) | SMPPSim OUTBIND_ENABLED |
An ESME against our server
| # | Scenario | Assert | Peers |
|---|---|---|---|
| S1 | Kannel binds at "34" and at "33", submits, gets receipts, receives MO | bearerbox log shows the receipt parsed and matched (DLR); at "33" our receipts carry no TLVs and still match; MO reaches the sms-service URL (target 8) |
Kannel |
| S2 | Long messages in every spelling to our server | UDH 8-bit, UDH 16-bit, sar_*, message_payload: one sms each, or a documented refusal (targets 2, 3, 5) |
jsmpp, php-smpp modes, python-smpplib |
| S3 | Unhandled and unknown commands | query_sm/cancel_sm/replace_sm get *_resp ESME_RINVCMDID and the client accepts it; an unknown id gets generic_nack; alert_notification gets nothing (targets 1, 6) |
jsmpp |
| S4 | Separate TX and RX binds | Receipts and MO go out on RX only; submit_sm on RX gets ESME_RINVBNDSTS; the peer keeps working |
php-smpp |
| S5 | Window pressure | Window 50 against a slow sms listener: every request answered, none twice; peer's request expiry shorter than our answer is reported by the peer, not by a crash on our side |
Cloudhopper |
| S6 | Idle and keepalive | A peer sending no enquire_link is dropped at idleTimeout and reconnects cleanly; one sending every 5 s is kept |
smppload (none), gosmpp (5 s), Jasmin elink_interval |
| S7 | A production gateway as the ESME | Jasmin parses our receipts (HTTP DLR callback fires with the right state); dlr_msgid 0 matches |
Jasmin smppccm |
| S8 | Load: 100 000 submit_sm, rps 500, threads 20, receipts on, long messages on |
All answered, memory flat, receipts correlate, close() drains to zero |
smppload |
| S9 | Bounded window under load | window 2000, rate 1000: no head-of-line stall, no reorder problems |
smpp-dumb-client |
| S10 | TLS from a Java client | Handshake with our cert, bind, send | Cloudhopper make ssl-client |
| S11 | Encodings from other encoders | GSM unpacked with 0x1B extension escapes, UCS-2, Latin-1 decode to the right string; peer decodes our deliver_sm the same way |
python-smpplib, jsmpp, Kannel alt-charset |
Order of work
Each phase ends with findings in Status; a defect gets a regression test in test/ and a
fix before the next phase starts, per AGENTS.md.
| Phase | What | Rough effort |
|---|---|---|
| 0 | Harness: interop-tests/compose.<peer>.yaml per peer, our tests as interop-tests/<peer>.test.ts run in the node service on the same network, tshark sidecar writing to interop-tests/captures/ (gitignored), a Python runner that brings a peer up, runs, tears down |
½ day |
| 1 | smscsim smoke + tshark assertions — proves the harness | 1 h |
| 2 | SMPPSim: C2–C7, C11–C13, C15, C17, C18 | 1 day |
| 3 | Jasmin as SMSC (C1, C3, C7–C9, C11–C13) and as ESME (S7) | 1 day |
| 4 | Kannel (S1) | ½ day |
| 5 | jsmpp + Cloudhopper (S2, S3, S5, S10) | 1 day |
| 6 | python-smpplib + php-smpp (S2, S4, S11) | ½ day |
| 7 | Load: smppload + smpp-dumb-client (S6, S8, S9) | ½ day |
| 8 | — | |
| 9 | Fixtures from the quirk list into test/ (C8 UDH-16, C9, C10, C15, C16) |
1 day |
| 10 | Decide 1.0 scope for targets 2–5: fix, or document as a limitation with the peers that refuse | — |
| 11 | One product-owner pass over everything the fixes changed on the public surface — see below | 2 h |
The product-owner pass, phase 11
Fixing what the peers found moved the published surface seven times in two days, and three separate reviews asked for a product-owner pass on their own change. One pass over all of it beats three narrow ones: the risk days before 1.0.0 is not any single option but the seams between them, and only a review that holds them together at once can see those.
Run it once phases 6 to 10 are done, so nothing lands after it. Read-only, reporting findings; each one that earns a change ships as its own reviewed PR.
Already reviewed, and carried in only as context — the refusal error class and what
sessionError means (PRs #79 and #82), and the multipart answering contract (#83). Both passes'
findings were documentation, fixed in #85. Do not re-litigate them; use them to judge whether the
newer changes stayed consistent with them.
What the pass covers:
| Change | The question a consumer asks |
|---|---|
data_sm and message_payload accepted (#84) |
Traffic that used to be dropped now arrives. Does an application that never expected data_sm behave sensibly when one turns up as an sms? Is messageOctets, newly exported, something a consumer needs or something we leaked? |
The server() onRequest hook (#86) |
A hook that throws answers nothing, so the peer waits for its own timeout. Is that discoverable before it happens in production, and is silence the right failure for the surface that owns refusing a message? |
reconnect: { fromStart: true } (#80) |
client() resolves only once bound, and only the caller's signal ends the wait. Is that obvious enough that nobody ships a process that hangs at startup? |
| A receipt's body read as octets (#81) | dlr.receipt now parses for peers where it used to be garbage. Anything an application could have been relying on in the broken case? |
| The abortable send window (in flight) | What an aborted send returns, and whether it counts as unanswered — the field an application reads to decide whether resending risks a duplicate. |
| The named messaging mode (#92) | A caller may now name the esm_class messaging mode. Two values are refused — the forward mode outright, and a delivery report asked for under datagram mode. Are those refusals discoverable before they happen, and is refusing better than letting the operator refuse? |
An empty entry in smsIds (#93) |
The SMSC took a segment but named no id for it, so the array carries a gap. It is documented now rather than accidental, but is an array with holes the right thing to hand an application reconciling against later receipts? |
| The held-message cap against a peer's window (phase 7) | A peer whose window is wider than the 1000 messages this library will hold unanswered makes it evict, so the peer is never answered for those and re-sends or times out. The cap is deliberate and on a constant an application cannot raise, but it is the peer's window that decides whether it is ever reached. Does an application learn it is happening in time to slow down? |
| All of them together | sessionError now carries refused PDUs, lost reassembly groups and failed hooks. Is one channel carrying too many meanings for an application to act on any of them? |
Delegation
Cheaper model, with these instructions and the tables above, no judgement calls:
- Compose files and Dockerfiles for every peer, pins as listed,
healthcheckon the SMPP port. - Kannel
kannel.conf(core,smsbox,smsc smpp,sendsms-user,sms-service) from the 1.4.5 user guide, one variant per knob value in S1. - Jasmin
jclibootstrap script: group, user,smppccm,mtrouter,morouter. - python-smpplib driver scripts for S2, S4 and S11, one PDU shape per script.
- Running a phase and pasting tshark's malformed/expert counts and the peer's log excerpts into Status.
Stronger model: reading a failure against the spec and the peer's docs, deciding fix versus document, library changes, and the AGENTS.md decision record each one needs.
Untested
Dropping the hosted simulators leaves three things this suite never exercises. None is a known defect; each is a claim resting on the specification and on Node rather than on a peer having agreed.
- A TLS handshake against a certificate a public authority signed. Every TLS test here, our own
and Cloudhopper's, uses a certificate generated for the test, so what is proven is that the
handshake works and that a bad certificate is refused. Verifying a real chain is Node's job and we
pass
tls.ConnectionOptionsthrough untouched, which is why this is a thin risk rather than none. - A peer that genuinely speaks SMPP 5.0.
interfaceVersion: 0x50is tested against peers that answer 3.4 or answer nothing, so what 5.0 declares back is unobserved. - An SMSC written by someone who never sees this code. Every peer here is open source and configured by us. A closed commercial SMSC is the one thing a free suite cannot buy, and the first operator integration is where that gets answered.
Status
| Phase | State | Findings |
|---|---|---|
| 0 | done | 01-smscsim.md |
| 1 | done | 01-smscsim.md |
| 2 | done | 02-smppsim.md |
| 3 | done | 03-jasmin.md |
| 4 | done | 04-kannel.md |
| 5 | done | 05-java-clients.md |
| 6 | done | 06-python-php.md |
| 7 | done | 07-load.md |
| 8 | dropped | Untested |
| 9 | done | 09-operator-fixtures.md |
| 10 | done | Targets 2, 3 and 4 fixed; target 5 answered by the messagingMode send option, maintainer's call 2026-09-06 |
| 11 | done | Surface judged sound. Its documentation findings and the unmergeable-receipt log line shipped in #94; one item, the typing of smsIds, is the maintainer's and is open below |
Phases 1 to 5 were graded before run.py learned to fail an empty capture, so a run whose capture
never started would have scored green on the wire checks while its own assertions carried it. Every
phase has since been re-run under the stricter grading and every one holds: Kannel 80 frames, Jasmin
175, Cloudhopper 88, each with its bind and response decoded and both error counts zero. The zeros
were real.
Open for the maintainer
smsIds is typed as string[] and can hold an empty entry. An SMSC that names an id for the
first segment of a concatenated message only — Telesign documents exactly that — leaves the rest
empty, so the type promises what the value does not keep. Taking the first id, or building a map
from the array, compiles and then misbehaves, and an empty string collides with another message's in
a correlation table. (string | undefined)[] makes the compiler point at the place the mistake
happens; the cost is that every consumer narrows, including the majority whose SMSC always names an
id. Raised by the phase 11 review, 2026-09-08. It is the last thing 1.0.0 locks: after release it
needs a major version.
Research notes behind this plan, 2026-09-05, are in research/: SMSC simulators, ESME clients
and validators, and operator quirks with one source URL per claim. Ask before trusting a claim here
that a peer's own docs would settle.