Guides

MLLP over TCP

The Minimal Lower Layer Protocol is three bytes of framing and nothing else. This guide covers what you actually need on top of it: whole messages out of a chopped-up stream, an acknowledgement that names what it answers, and a bound on what a broken peer can allocate.

The whole protocol

A TCP stream is bytes without edges, and an HL7 v2 message carries no length prefix and no self-delimiting syntax — so a receiver reading a socket cannot tell where one message stops and the next begins. MLLP is the answer, and this is all of it:

text
<VT> message <FS><CR>
0x0B          0x1C 0x0D

No length, no checksum, no session, no negotiation, no encryption. Nothing else in the specification.

Framing one message

rust
use hl7_2_mllp as mllp;

let frame = mllp::encode(message.as_bytes());
assert_eq!(frame[0], mllp::START_BLOCK);
assert_eq!(mllp::decode(&frame)?, message.as_bytes());

The payload is copied verbatim — not trimmed, not validated, not normalized. Note that a message's own \r segment terminators are the same byte as the frame's trailer, and they survive untouched.

Streaming: the Framer

This is the one a socket actually needs. Frames arrive split across reads, several to a read, or both, and Framer is the small amount of state that puts them back together.

rust
use hl7_2_mllp::Framer;

let mut framer = Framer::new();
framer.push(b"\x0bMSH|one\x1c\r\x0bMSH|t");   // one and a half messages
framer.push(b"wo\x1c\r");                       // the other half

assert_eq!(framer.next_frame()?.unwrap(), b"MSH|one");
assert_eq!(framer.next_frame()?.unwrap(), b"MSH|two");
assert_eq!(framer.next_frame()?, None);          // nothing more yet

A partial frame is Ok(None), not an error — it means “read more” — and a frame may be split anywhere, including between <FS> and its <CR>.

rust
// The default cap on what a single frame may buffer is 16 MiB.
let framer = Framer::new().with_limit(4 * 1024 * 1024);
assert_eq!(hl7_2_mllp::DEFAULT_LIMIT, 16 * 1024 * 1024);

Transport

rust
use hl7_2_mllp::{IoTransport, Transport};
use std::net::TcpListener;

let listener = TcpListener::bind("127.0.0.1:2575")?;
for stream in listener.incoming() {
    let mut transport = IoTransport::new(stream?);
    while let Some(message) = transport.receive()? {
        // ... one whole HL7 message ...
    }
}

IoTransport works over anything that reads and writes bytes — a TcpStream, a TLS stream, a Unix socket, a Cursor in a test — and the Transport trait is there for carriers it does not know about. send_str is the convenience for messages you hold as text; send bytes when the message is already encoded in the character set MSH-18 names.

Acknowledgement

MLLP has no acknowledgement of its own. The reply HL7 expects is an HL7 message: an ACK whose MSA-2 echoes the control ID of the message being answered.

rust
use hl7_2_mllp::{AckCode, ack};

let frame = ack::acknowledge(&payload, AckCode::Accept, "ACK00001", "20260814080100")?;
transport.send(hl7_2_mllp::decode(&frame)?)?;

That echo is the whole mechanism. MLLP guarantees a message arrived whole; only the echoed control ID says which message arrived — so a sender that does not compare it will eventually take one answer for another's.

When the receiver needs to look before it answers, which is the usual case:

rust
let message = ack::parse(&payload)?;
let mut nack = ack::acknowledge_message(&message, AckCode::Error, "N1", "20260814080100")?;
nack.set("MSA-3", "OBR-4 is required")?;
transport.send(nack.to_er7().as_bytes())?;

Every call takes the acknowledgement's own control ID and timestamp as arguments, because a message that invents them is untestable and untraceable. The clock feature adds acknowledge_now for callers who genuinely just want the current time.

Strictness and tolerance

By default a frame must start with <VT>, end with <FS><CR>, and contain neither block character in between. Real senders are not always strict.

rust
use hl7_2_mllp::{Framer, Tolerance};

// For that one sender whose stack forgets the trailing <CR>.
let framer = Framer::new().with_tolerance(Tolerance::Lenient);

// The default, whatever the crate features say.
let framer = Framer::new().with_tolerance(Tolerance::Strict);

Tolerance::Lenient forgives the two common sins — a missing <CR> after <FS>, and stray bytes between frames — and nothing else.

Prefer setting it per connection, for the one sender that needs it, over enabling the crate-wide noncompliance feature. A receiver that quietly accepts malformed framing is how a truncated message becomes a clinical record, and loosening every connection at once to accommodate one peer is not a trade worth making.

Features

FeatureDefaultEffect
ackonAcknowledgement generation; pulls in hl7-2.
clockoffacknowledge_now; pulls in chrono. Implies ack.
noncomplianceoffThe default tolerance becomes lenient.

--no-default-features gives framing, streaming, and transport with no dependencies at all — useful when the process that terminates the socket is not the process that understands the message.

What this crate does not do

MLLP is a small protocol and this is a small crate. It has:

  • No TLS. Compose it — IoTransport takes any stream.
  • No async runtime.
  • No connection pooling, and no retry or reconnect policy.
  • No opinion on HL7 v2 semantics — that is hl7-2.

What a production listener also needs

The crate ships two runnable examples that talk to each other, and the listener is commented with exactly this list:

sh
cargo run --example tcp_listener     # accepts, reads, acknowledges
cargo run --example tcp_sender       # sends, waits, checks the echo
  • TLS, unless the link is genuinely private end to end.
  • A read timeout, so a half-open connection does not hold a thread forever.
  • A connection bound, so a burst of connections does not exhaust the process.
  • Persistence before acknowledging — see the warning above. This is the one that costs data when it is missed.

For a step-by-step build of a listener that does all four, see An MLLP listener that answers.