smpp34
A pure-Rust implementation of the SMPP 3.4 protocol (Short Message Peer-to-Peer): a PDU codec plus an asynchronous (tokio) client and server built on a listener-trait model.
The same engine ships two ways from one source tree, one version: a Rust
crate (cargo add smpp34, zero Python dependencies) and a Rust-backed Python
wheel (pip install smpp34) that exposes the identical async client/server to
asyncio. The whole SMPP machinery — framing, windowing, timers, codec — runs in
the Rust/tokio core; Python crosses the GIL only once per message, and the
extension is free-threaded ("no-GIL") ready. See Python below.
- Client — bind to an SMSC as an ESME (transmitter, receiver or
transceiver), send
submit_sm/data_sm, and receivedeliver_smand session events through your ownSmppClientListener. - Server — accept ESME binds and dispatch
submit_sm,data_sm,cancel_sm, … to yourSmppServerListener; push MT traffic back out withdeliver_sm. - Codec — encode/decode every SMPP 3.4 PDU, including optional TLV parameters.
Sessions handle the SMPP timers (session_init, enquire_link, inactivity,
response), sequence-number windowing, and optional TLS.
Supported PDUs
bind_transmitter · bind_receiver · bind_transceiver (+ resps) · outbind
· unbind · enquire_link · submit_sm · submit_sm_multi · deliver_sm ·
data_sm · query_sm · cancel_sm · replace_sm · alert_notification ·
generic_nack, plus arbitrary TLV (optional parameter) encode/decode.
Install
Quick start — server
Implement SmppServerListener and hand it to an SmppServer. Every trait
method has a default, so you override only the PDUs you care about — typically
on_bind_transceiver and on_submit_sm.
use Arc;
use ;
use async_trait;
use ;
;
async
Quick start — client
Bind to an SMSC, then submit once the bind completes (the on_smsc_bound
callback hands you an SMSC you can submit through).
use Arc;
use ;
// `listener` implements SmppClientListener; its on_smsc_bound callback hands
// you the bound `SMSC` handle to submit through.
let listener = new;
let mut client = new;
client.start.await;
// inside on_smsc_bound(smsc, _): use the fluent builder (only set what you need)
// smsc.submit_sm()
// .source_addr("12345")
// .destination_addr("31600000000")
// .short_message(b"hello")
// .send().await?;
Python
pip install smpp34 gives a Rust-backed wheel exposing the same async
client/server to asyncio. The hot path stays in the Rust/tokio core; the Python
layer is an event-pull bridge that crosses the GIL once per message. abi3 wheels
cover CPython 3.9+, plus free-threaded ("no-GIL") builds.
Client — connect() binds and returns an Smsc; await sends and inbound
events:
=
= await
= await
# pull inbound deliver_sm (MO / DLR) — one GIL crossing per message
= await # DeliverSmEvent | Disconnected
await
Server — await next() yields an Esme when a client binds, a
SubmitSmEvent per inbound message (answer with accept/reject), or Unbound:
=
await
=
= await
= # stash the MT send handle
# or ev.reject(smpp34.ESME_RMSGQFUL)
# push MT back to that ESME (here, a delivery receipt):
await
Codec — encode/decode PDUs without any I/O:
=
=
= # -> typed SubmitSm / DeliverSm / RawPdu
Runnable samples: python/examples/ (and the Rust
equivalents in examples/ — client.rs / server.rs). A throughput
and leak harness lives in python/perf/, mirroring the Rust
examples/{perf_loopback,leak_check}.rs.
TLS
Pass tls = true to SmppClient::new to bind over TLS. The TLS transport is
backed by tokio-native-tls
(system OpenSSL).
Timers & windowing
SmppServer::new / SmppClient::new use sensible default timers. Use the
*_with_default_timers constructors to set session_init, enquire_link,
inactivity and response timers (milliseconds) and the buffer / window size
explicitly.
Performance
Measured on a 24-core AMD Ryzen AI 9 HX 370 (single box — the SMSC and ESME are
co-located, so the load generator competes for cores; separate hosts scale
further). Reproduce with cargo bench and perf/docker-compose.yml.
Codec — single core, criterion (cargo bench):
| Operation | Throughput |
|---|---|
submit_sm decode |
~6.6 M PDU/s |
submit_sm encode |
~12.3 M PDU/s |
deliver_sm decode / encode |
~6.8 M / ~12.6 M PDU/s |
| TLV decode / encode | ~20 M / ~14 M /s |
End-to-end — real TCP, full submit_sm round-trip (send → submit_sm_resp):
| Load | Throughput | Peak RSS |
|---|---|---|
| 1 ESME, window 64 | ~180k submit_sm/s | 6 MB |
| 4 ESMEs, window 64 | ~635k submit_sm/s | 7 MB |
The SMSC server sustains 625k+ submit_sm/s even pinned to half the cores; past a few sessions the single-box benchmark is bound by the co-located load generator, not by smpp34.
Rust vs Python (and free-threading)
Both languages drive the same Rust/tokio engine, so the protocol work — framing, windowing, timers, codec — runs at full Rust speed either way. The difference is the Python boundary: the event-pull bridge crosses the GIL once per message.
| Path | Throughput | Notes |
|---|---|---|
| Rust — 4 ESMEs, window 64 | ~635k submit_sm/s | full multi-threaded tokio core |
Python — single asyncio loop |
~25k submit_sm/s | one GIL crossing per message (release wheel) |
| Codec decode — GIL CPython | ~4.5M PDU/s (flat vs threads) | the GIL serialises the pyo3 call |
| Codec decode — free-threaded, 4 threads | ~12M PDU/s | no GIL → real parallelism |
A few things worth knowing:
- Build matters. A
maturin developdebug build is ~3× slower; published wheels are--release. The ~25k/s figure is a release build. - Tune the tokio pool with
SMPP34_WORKER_THREADS(default2). On a many-core box, a large pool thrashes the GIL on every future completion and throughput drops (24 threads ≈ 17k/s vs 1 thread ≈ 28k/s); on a free-threaded build, raise it. - Free-threaded ("no-GIL") is correctness-clean — the full test suite passes
with the GIL off (
sys._is_gil_enabled() == False) — and lets CPU-bound Python work (the codec, your per-message handlers) run in parallel, as the codec row shows. Single-process async I/O is bounded by the shared tokio runtime, so scale that across processes. - ~25k submit_sm/s on one event loop is already well above the per-bind rate limits carriers typically impose.
Reproduce: python/perf/perf_loopback.py
(COUNT=200000 SESSIONS=4 WINDOW=64), the thread-scaling demo
python/perf/perf_threads.py, and the leak check
python/perf/leak_check.py — all after a release
build (maturin develop --release).
Profiling
A CPU flamegraph of the loopback under load (scripts/flamegraph.sh) puts
~99.98% of samples in the kernel (the socket/syscall path) — the user-space
codec barely appears. smpp34 is syscall-bound, not CPU-bound: at these rates
the cost is the ~600k+ read/write syscalls per second plus the runtime's
wakeups, not parsing (the codec runs at millions of PDUs/sec). The remaining
headroom is therefore I/O batching — coalescing writes and cutting wakeups —
not faster code.
Memory
scripts/mem_leak_test.sh installs a counting global allocator and asserts
live bytes stay flat (exact — unlike noisy RSS) across both codec round-trips
and bind/unbind churn. RSS holds at single-digit MB while pushing millions of
PDUs. (The bind/unbind check found and now guards a real session-teardown leak —
see the changelog.)
SMPP 3.4 compliance
The PDU / TLV / session support matrix is in
docs/COMPLIANCE.md;
docs/COMPARISON.md
maps smpp34 against other SMPP stacks.
MSRV
Rust 1.80.
Stability
The SMPP 3.4 codec and the client/server session handling are used in
production, and the crate follows Semantic Versioning: the
public Rust API is the contract. The 17-argument send_submit_sm /
send_deliver_sm remain available, but the submit_sm() / deliver_sm()
builders are the recommended way to send.
License
MIT © Real Time Telecom B.V.
Maintained by Real Time Telecom B.V. — carrier-grade telecom infrastructure in Rust.