# smpp34
[](https://crates.io/crates/smpp34)
[](https://docs.rs/smpp34)
[](https://github.com/Real-Time-Telecom-B-V/smpp34/actions/workflows/ci.yml)
[](LICENSE)
A pure-Rust implementation of the **SMPP 3.4** protocol (Short Message
Peer-to-Peer): a PDU codec plus an asynchronous ([tokio](https://tokio.rs))
**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](#python) below.
- **Client** — bind to an SMSC as an ESME (transmitter, receiver or
transceiver), send `submit_sm` / `data_sm`, and receive `deliver_sm` and
session events through your own `SmppClientListener`.
- **Server** — accept ESME binds and dispatch `submit_sm`, `data_sm`,
`cancel_sm`, … to your `SmppServerListener`; push MT traffic back out with
`deliver_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
```sh
cargo add smpp34
```
## 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`.
```rust,no_run
use std::sync::Arc;
use std::net::{IpAddr, Ipv4Addr};
use async_trait::async_trait;
use smpp34::{
SmppServer, SmppServerListener, SmppConnectionInformation, SmppError,
bind_transceiver, bind_transceiver_resp, submit_sm, submit_sm_resp,
};
struct MyEsmeHandler;
#[async_trait]
impl SmppServerListener for MyEsmeHandler {
async fn on_bind_transceiver(
&self, req: bind_transceiver, _c: &SmppConnectionInformation, _s: &String,
) -> bind_transceiver_resp {
if req.system_id == "esme1" && req.password == "secret" {
req.accept("MySMSC".to_string(), Some(0x34)) // 0x34 = SMPP v3.4
} else {
req.reject(SmppError::ESME_RINVPASWD)
}
}
async fn on_submit_sm(
&self, req: submit_sm, _c: &SmppConnectionInformation, _s: &String,
) -> submit_sm_resp {
// ... route the message ...
req.accept("message-id-1".to_string())
}
// Every other method has a sensible default (binds reject, on_data_sm /
// on_cancel_sm reject, on_unbind acks, notifications no-op), so you override
// only what you need.
}
#[tokio::main]
async fn main() {
let listener = Arc::new(MyEsmeHandler);
let mut server = SmppServer::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2775, listener);
server.start().await; // spawns the accept loop
tokio::signal::ctrl_c().await.unwrap();
server.stop().await;
}
```
## 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).
```rust,no_run
use std::sync::Arc;
use smpp34::client::{SmppClient, BIND_TYPE};
// `listener` implements SmppClientListener; its on_smsc_bound callback hands
// you the bound `SMSC` handle to submit through.
let listener = Arc::new(MyClientHandler::new());
let mut client = SmppClient::new(
"smsc.example.net".to_string(), 2775, /* tls = */ false,
BIND_TYPE::TRX,
"esme1".to_string(), "secret".to_string(), "GATEWAY".to_string(),
/* addr_ton */ 0, /* addr_npi */ 0, /* address_range */ String::new(),
listener.clone(),
/* window_size */ 20,
);
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.
```sh
pip install smpp34
```
**Client** — `connect()` binds and returns an `Smsc`; `await` sends and inbound
events:
```python
import asyncio, smpp34
async def main():
client = smpp34.Client(host="smsc.example.net", port=2775,
system_id="esme1", password="secret", bind_type="TRX")
smsc = await client.connect()
resp = await smsc.submit_sm(destination_addr="31600000000",
source_addr="12345", short_message=b"hello")
print(resp.message_id, resp.is_success)
# pull inbound deliver_sm (MO / DLR) — one GIL crossing per message
ev = await smsc.next() # DeliverSmEvent | Disconnected
if isinstance(ev, smpp34.DeliverSmEvent):
print(ev.source_addr, ev.short_message)
await smsc.unbind()
asyncio.run(main())
```
**Server** — `await next()` yields an `Esme` when a client binds, a
`SubmitSmEvent` per inbound message (answer with `accept`/`reject`), or `Unbound`:
```python
import asyncio, smpp34
async def main():
server = smpp34.Server(host="0.0.0.0", port=2775, system_id="MySMSC")
await server.start()
sessions = {}
while True:
ev = await server.next()
if isinstance(ev, smpp34.Esme):
sessions[ev.session_id] = ev # stash the MT send handle
elif isinstance(ev, smpp34.SubmitSmEvent):
ev.accept(message_id="abc") # or ev.reject(smpp34.ESME_RMSGQFUL)
# push MT back to that ESME (here, a delivery receipt):
await sessions[ev.session_id].deliver_sm(
source_addr=ev.destination_addr, destination_addr=ev.source_addr,
esm_class=0x04, short_message=b"id:abc stat:DELIVRD")
asyncio.run(main())
```
**Codec** — encode/decode PDUs without any I/O:
```python
pdu = smpp34.SubmitSm(source_addr="12345", destination_addr="31600000000",
short_message=b"hello")
wire = pdu.encode()
msg = smpp34.decode(wire) # -> typed SubmitSm / DeliverSm / RawPdu
```
Runnable samples: [`python/examples/`](python/examples) (and the Rust
equivalents in [`examples/`](examples) — `client.rs` / `server.rs`). A throughput
and leak harness lives in [`python/perf/`](python/perf), mirroring the Rust
`examples/{perf_loopback,leak_check}.rs`.
## Optional parameters (TLVs)
Inbound TLVs are on `pdu.tlvs` in wire order, with typed accessors from the
`TlvList` trait. Outbound, the builders take them directly:
```rust,ignore
use smpp34::{Tlv, TlvTag, TlvList};
// ESME -> SMSC
smsc.submit_sm()
.source_addr("12345")
.destination_addr("31600000000")
.tlv(TlvTag::MessagePayload, "a body past the 254-byte short_message limit")
.tlv(TlvTag::UserMessageReference, 42u16.to_be_bytes())
.tlv_raw(0x1403, [0xAA, 0xBB]) // vendor-specific tag
.send().await?;
// SMSC -> ESME: a delivery receipt is TLVs, nothing else
esme.deliver_sm()
.source_addr("31600000000")
.destination_addr("12345")
.esm_class(0x04)
.tlvs([
Tlv::from_c_string(TlvTag::ReceiptedMessageId, "abc"),
Tlv::from_u8(TlvTag::MessageStateTlv, 2), // DELIVERED
])
.send().await?;
// reading, on either side
let dlr_id = request.tlvs.receipted_message_id();
```
For a PDU built up front (relaying a decoded one, or `data_sm`, whose message
body *only* exists as the `message_payload` TLV) use the `*_pdu` send methods —
the session assigns the sequence number:
```rust,ignore
let pdu = data_sm::new(0, String::new(), 1, 1, "12345".into(), 1, 1, "31600000000".into(), 0, 0, 8)
.with_tlvs([Tlv::from_tag(TlvTag::MessagePayload, body)]);
smsc.send_data_sm_pdu(pdu).await?;
```
In Python, `tlvs=` takes a list of `Tlv(tag, value)` and the tag constants are on
the module:
```python
await smsc.submit_sm("31600000000", b"hi", source_addr="12345", tlvs=[
smpp34.Tlv(smpp34.TLV_USER_MESSAGE_REFERENCE, (42).to_bytes(2, "big")),
smpp34.Tlv(smpp34.TLV_MESSAGE_PAYLOAD, b"the real body"),
])
ev = await smsc.next()
print([(hex(t.tag), t.value) for t in ev.tlvs])
```
All 44 spec-defined tags are in `TlvTag` / `smpp34.TLV_*`; unknown and
vendor-specific tags (0x1400-0x3FFF) pass through byte for byte.
## TLS
Pass `tls = true` to `SmppClient::new` to bind over TLS. The TLS transport is
[`rustls`](https://crates.io/crates/rustls) (via `tokio-rustls`, `ring`
backend), so no system TLS library is needed to build or run.
- TLS 1.2 and 1.3, ECDHE key exchange with AEAD ciphers only. A peer limited to
RSA key exchange or CBC suites cannot be reached.
- The server certificate is verified against the operating system's trust store
(`SSL_CERT_FILE` / `SSL_CERT_DIR` are honoured), and against the address you
passed, hostname or IP. It must carry a matching subjectAltName; a
certificate that only names the host in its CN is rejected.
- No client certificate (mTLS) and no per-client CA yet.
## 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`):
| `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`):
| 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**.
| 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 develop` *debug* build is ~3× slower; published
wheels are `--release`. The ~25k/s figure is a release build.
- **Tune the tokio pool** with `SMPP34_WORKER_THREADS` (default `2`). 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`](python/perf/perf_loopback.py)
(`COUNT=200000 SESSIONS=4 WINDOW=64`), the thread-scaling demo
[`python/perf/perf_threads.py`](python/perf/perf_threads.py), and the leak check
[`python/perf/leak_check.py`](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](CHANGELOG.md).)
## SMPP 3.4 compliance
The PDU / TLV / session support matrix is in
[`docs/COMPLIANCE.md`](https://github.com/Real-Time-Telecom-B-V/smpp34/blob/main/docs/COMPLIANCE.md);
[`docs/COMPARISON.md`](https://github.com/Real-Time-Telecom-B-V/smpp34/blob/main/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](VERSIONING.md): 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](LICENSE) © Real Time Telecom B.V.
Maintained by [Real Time Telecom B.V.](https://realtime-telecom.nl) — carrier-grade
telecom infrastructure in Rust.