Files
smpp-js/interop-tests/findings/02-smppsim.md
T

16 KiB

02 smppsim

Date: 2026-09-05. Repo commit: 9c4939f. Host Docker: 29.6.2. Images: larvitsmpp-interop/smppsim:bc29982 (built locally here from kwahome/smpp-sim-docker at commit bc299828af9046ab290da3b4957dfbc472f02bfb, running SMPPSim 2.6.11 on eclipse-temurin:8u452-b09-jre; cloned during the build with debian:13.2-slim, never vendored), nicolaka/netshoot:v0.16 (capture sidecars and tshark), node:24.18.0-bookworm-slim (test runner, from the root compose.yaml).

Setup

interop-tests/peers/smppsim/Dockerfile clones the pinned commit in a build stage and copies just smppsim.jar, lib, www, mo and conf/logging.properties into the runtime stage; each smppsim*.props file under the same directory is copied in and selected via the container command. interop-tests/compose.smppsim.yaml runs nine variants of the one image (smppsim, -textdlr, -transition, -undeliv, -rejected, -accepted, -delayed, -queuefull, -outbind) plus capture (on smppsim) and capture-textdlr, all on one network so ./interop-tests/run.py smppsim covers everything in one run.

Two snags fixed while building the harness:

  • The first healthcheck (bash -c 'echo > /dev/tcp/127.0.0.1/2775', 1s interval) filled the host's disk. SMPPSim reads an empty connection as a malformed PDU and logs a full Java stack trace per attempt; with DECODE_PDUS_IN_LOG=true and a 1s probe interval, nine containers left running for ~20 minutes wrote 24GB+ of container logs between them and took the whole shared host to 0 bytes free (docker run itself started failing with "no space left on device"). Fixed by probing the HTTP admin port instead (curl -sf -o /dev/null http://127.0.0.1:8884/), which SMPPSim answers harmlessly, plus a json-file log cap (max-size: 20m, max-file: "3") on every smppsim* service as a second line of defence. Worth knowing for anyone else pointing a 1s TCP-connect healthcheck at this peer.
  • The capture sidecar's dumpcap needs the same root/DAC-override handling phase 1 documented (interop-tests/captures/ chowned to 1000:1000/0777 by run.py after every run, stale <peer>.pcapng unlinked before the next). Once, a capture attempt failed outright ("Permission denied" opening the pcapng) for a reason not pinned down - not reproduced since; treat as a rare, unexplained flake in this harness rather than a peer issue.
  • Two early runs showed malformed: 2, expert errors: 2. Both traced to this test file, not SMPPSim: C17's raw-UDH test used IEI 0x01 ("Special SMS Message Indication"), a real GSM 03.40 information element with its own defined length (2 bytes), at length 1 - Wireshark's gsm_sms_ud dissector correctly flags that as malformed. Fixed by using IEI 0x70 (reserved-for-future-use, so no dissector validates its length) for what the test only ever needed to be an arbitrary, unparsed UDH element.

Two runs of ./interop-tests/run.py smppsim, back to back, after that fix: both exit 0, 25/25 tests passing, malformed: 0, expert errors: 0 in both.

Scenarios

Id (from PLAN.md) Result Evidence
C2 (smppsim-textdlr) pass smppsim-textdlr - C2 text-only receipts; no TLVs, dlr.smsId matches submit_sm_resp
C3 (smppsim, TLVs on) pass smppsim - C3+C7 …; receipted_message_id/message_state TLVs present and agree with the body on every segment
C4 (smppsim-transition) fail (peer defect, see below) smppsim-transition - C4 …; intermediate report arrives, no final ever does
C5 (single-state variants) pass smppsim single-state variants - C5 …; UNDELIVERABLE/REJECTED/ACCEPTED each map correctly; 2-segment message's shared status confirmed, messageDlr confirmed absent (see Open questions)
C6 (smppsim-delayed) pass smppsim-delayed - C6 …; disconnect+reconnect observed, delayed receipt still reaches dlr on the new link
C7 (loopback, GSM 1/2/3/10-segment) pass same C3+C7 suite; one id per segment, receipt per id, loopback reassembles the exact text including € and [ ]
C7 (loopback, UCS2 2-segment) pass with a documented defect … 2-segment UCS2 …: TLVs stay right, the receipt body is corrupted; TLVs and loopback reassembly both correct, receipt body unreadable - @larvit/smpp defect below
C11 (bind refusal + backoff) pass smppsim - C11 …, three sub-tests, see below for what each shows
C12 (smppsim-queuefull) pass smppsim-queuefull - C12 …; ESME_RMSGQFUL returned, session stays bound, later send succeeds once the one-slot queue drains
C13 (maxOutstanding: 1, 10 parallel) pass smppsim - C13 …; 10 distinct, strictly-increasing ids, none lost
C15 (bind version) pass (peer never declares, see below) smppsim - C15 …, both 0x34 and 0x50
C17 (encodings over loopback) pass smppsim - C17 …, 5 sub-tests: Latin-1, UCS-2, flash (0x10), raw 0xF0 (not flash), raw UDH+8-bit-binary
C18 (smppsim-outbind) pass (record, not judge) smppsim-outbind - C18 …; see below for the wire facts

Defects in @larvit/smpp

A delivery receipt's data_coding is trusted to decode its body, even though the spec makes the receipt a fixed text format

What happened. A message sent with encoding: 'UCS2' gets data_coding: 8 on its submit_sm. SMPPSim's delivery receipt for it (confirmed against source, DeliveryReceipt extends DeliverSM by copy-constructing the original SubmitSM) inherits that same data_coding: 8, but its short_message is always plain ASCII text (id:… sub:… dlvrd:… stat:…). pdu.ts's parser decodes any non-UDH PDU's short_message using its own data_coding unconditionally (params.short_message = decodeMessage(message, paramNumber(params.data_coding, 0)).message), so the ASCII receipt bytes are read back as UCS2 (byte pairs swapped) - id:001 sub:… becomes unrecoverable CJK-range glyphs, and dlr.receipt (hence .stat, .id, every body field) is garbage or undefined for every receipt whose triggering message used a non-ASCII encoding.

What the spec says. SMPP 3.4 5.2.25 defines short_message/message_payload generically, but Appendix B's receipt format is specified as fixed ASCII fields; data_coding on the receipt PDU describes nothing about how to read that text, since the MC is reporting on a message rather than carrying one. Trusting data_coding to decode a receipt body is reading a field the spec never attaches that meaning to. The receipted_message_id/message_state TLVs are unaffected (typed fields, not text), which is why dlr.statusMsg and dlr.smsId stay correct here - only the body-derived dlr.receipt (and anything relying on it, e.g. a peer without TLVs) is lost.

Reproducer. session.sendSms({ encoding: 'UCS2', dlr: true, message: 'x', from, to }) against smppsim, then inspect the dlr event's second argument (the raw PduObject): pduObj.params.data_coding === 8 and dlr.receipt === undefined, while pduObj.tlvs.receipted_message_id/message_state are present and correct. Confirmed both against SMPPSim and by constructing the PDU directly: a deliver_sm with data_coding=8, esm_class=4 and an ASCII id:0 sub:001 dlvrd:001 … body decodes to garbage text via pduToObj.

Severity. Medium: harmless when TLVs are present (as here), since statusMsg/smsId still resolve correctly - but total for a peer that answers DELIVERY_RECEIPT_OPTIONAL_PARAMS=false (text-only, smppsim-textdlr's own style) and accepts non-ASCII submits, where nothing would be left to fall back on. Not reproduced against smppsim-textdlr here (that variant's own C2 test only sends ASCII), so this is inferred from the mechanism, not independently confirmed there - see Open questions.

reconnect never retries the very first connect or bind attempt

What happened. client() performs the socket connect and the initial bind directly, not through the ReconnectLoop; on either failing (ESME_RINVPASWD, ESME_RBINDFAIL, connection refused, …) it calls session.close() and returns { err } with no session at all - the reconnect option is never consulted. Confirmed with a wrong password and with a closed port against smppsim (smppsim - C11 …, both "one attempt, no retry" sub-tests): each is exactly one bind attempt, logged once, no matter how long the test then waits. ReconnectLoop.schedule() only runs from onClose()/a failed comeBackUp() after a session has been up at least once - confirmed separately by binding once, then mutating the same options object's password before dropping the live link: the loop does retry with proper backoff then (smppsim - C11 …, "a rebind refused after a live link drops": 2-6 attempts over 12s, each gap ≥150ms).

What the spec/README say. Neither documents this boundary explicitly; README.md's reconnect entry reads as covering any drop uniformly. Given hard rule/goal 4 ("no bind flooding"), never retrying a cold failure is arguably the safer default - a bad password retried forever would flood exactly as much as this avoids it entirely - but it means a client started before its peer's listener is up gets one shot and gives up for good, which a caller relying on reconnect for that race would not expect.

Reproducer. client({ host, password: 'wrong', reconnect: { minDelay: 1000, maxDelay: 4000 } }) against smppsim (valid system_id, wrong password): resolves { err } once, no session; a log.info capture shows exactly one 'client - bind refused' line even after a further 2s wait.

Severity. Low / informational - plausibly intentional, not previously written down anywhere git grep-able in this repo.

Peer quirks

  • registered_delivery 0x11 (final + intermediate together) never produces a final receipt. SMPPSim's own user guide (v2.5 release notes) says "Set to 0x11 for both intermediate notification and final delivery receipts." LifeCycleManager.setState() tests registered_delivery_flag == 1 or == 2 by exact integer equality rather than a bitmask (InboundQueue.addMessageState()'s own intermediate-notification check correctly uses (rd & 0x10) == 0x10), so 17 (0x11) matches neither branch: the intermediate report (esm_class 0x20, ENROUTE) always arrives, a final one never does, however long you wait. Confirmed by reading LifeCycleManager.java, MessageState.java and OutboundQueue.java, and empirically with a 16s wait against smppsim-transition.
  • Two submit_sm's on the same connection without a response in between - exactly how this library always sends a multi-segment message's segments (Promise.all, never one after the previous one's response, a documented, deliberate design choice, not something to change here) - sometimes make SMPPSim lose one of the resulting receipts or loopback echoes entirely. Never seen as a malformed/corrupted PDU on the wire (both kept runs, and every other run once the harness's own UDH bug above was fixed, show zero); the PDU that should have arrived just never does. Confirmed by direct A/B: a script sending two segments concurrently (this library's own shape) got only one of two receipts about half the time across several tries; the same script sending two independent submit_sm's sequentially, awaiting each response before the next, never lost one in the same number of tries. interop-tests/smppsim.test.ts's own multi-segment tests work around it by resending a fresh message (up to 10 times) until every segment's receipt and, where relevant, the loopback reassembly are all present in one attempt - see sendUntilAllDlrsArrive/sendUntilComplete/dlrLooksIntact there. The mechanism is unconfirmed (see Open questions).
  • bind_resp never carries sc_interface_version, whatever the ESME declared. No BindTransceiverResp/BindReceiverResp/BindTransmitterResp class sets that TLV (confirmed against source). @larvit/smpp's client therefore always records peerInterfaceVersion === 0x00 (undeclared) and acceptsOptionalParams() === false against this peer, whether the client declared 0x34 or 0x50 - yet smppsim (with DELIVERY_RECEIPT_OPTIONAL_PARAMS=true) still sends receipted_message_id/message_state TLVs on every receipt regardless, because that gate reads the client's own declared version from the bind PDU it received, not anything it echoes back. Confirmed for both 0x34 and 0x50 (smppsim - C15 …).
  • DelayedDrQueue's own poll loop is hardcoded to 5000ms (private static final int period = 5000;, not a props knob - DELAYED_INBOUND_QUEUE_PROCESSING_PERIOD is a different queue, for redelivery after ESME_RMSGQFUL). DELAY_DELIVERY_RECEIPTS_BY is a floor, not the delay: a receipt configured for an 8000ms delay can take up to ~13000ms in practice. Confirmed by direct measurement (an 11s wait saw nothing; 16s did).
  • Message ids are decimal, a per-process global counter (message_id++, not per-connection), starting at 0 unless START_MESSAGE_ID_AT/MESSAGE_ID_PREFIX are set (neither is here) - matches the research notes; submit_sm_resp and every receipt spelling (TLV and body id:) agree, so no smsIdFormat was needed anywhere in this suite.
  • The receipt body's echoed-message field is Text: (capitalised, not text:) and carries up to the first 20 bytes of the original message when it is non-empty - never the empty text: some other peers (documented: LINK) send. @larvit/smpp's parsing is already case-insensitive, so this needed no special handling.
  • outbind() closes the socket immediately after writing, without reading anything back. Confirmed against source (Smsc.outbind(): out.write(...); out.flush(); out.close(); s.close(); with no read in between) and on the wire (smppsim-outbind - C18 …): our server's incomingPduObj sees the outbind (system_id: smppclient1), onRequest tries to answer ESME_RINVBNDSTS and fails before writing anything (outbind_resp is not a defined command, so pduReturn errors first) - sessionError fires with "outbind" has no response command, no PDU reaches the wire, matching target 6. Whether our socket sees SMPPSim's own close land as a clean close was observed but not asserted against, per the task ("record, do not judge").
  • outbind fires the moment SMPPSim has an MO to deliver and no receiver is bound - not on a timer. InboundQueue.processQueue()'s wait/notify wakes on iq.addMessage(...), and moves straight to PENDING_QUEUE + outbind() when getReceiverBoundCount() == 0; the MO Injection endpoint (GET /inject_mo?source_addr=…&destination_addr=…&short_message=…, not /inject_mo.htm as the docs might suggest - that path is the form, /inject_mo is the handler) works with zero ESMEs ever bound, which is what let this test trigger outbind deterministically instead of racing DELIVERY_MESSAGES_PER_MINUTE against container startup.

Open questions

  • Whether the data_coding-decodes-the-receipt-body defect also loses receipts against a peer answering DELIVERY_RECEIPT_OPTIONAL_PARAMS=false (no TLV fallback) - smppsim-textdlr's own scenario here only exercises an ASCII message, so the compounding case (non-ASCII + no TLVs) is untested.
  • The exact mechanism behind the occasional lost receipt/loopback echo under concurrent submit_sm's. A plausible read of SMPPSim's threading (the connection-handler thread writing submit_sm_resps and loopback echoes, a separate OutboundQueue/InboundQueue thread writing receipts, both against the same socket with no synchronisation found in StandardConnectionHandler) would predict corruption, not a clean loss - but no malformed PDU was ever observed for a genuine SMPPSim-authored frame in this suite, so that read is unconfirmed and the actual mechanism (dropped at the SMPPSim side before it ever reaches the wire, versus something on our side discarding a well-formed PDU) is still open.
  • Phase 2's own instructions call for smppsim-accepted/-rejected as "similar single-state variants... if cheap"; both were cheap and are included, alongside -undeliv.