Skip to content

Internals Domain Primer

github-actions[bot] edited this page Jul 29, 2026 · 1 revision

Written for the Rust engineer who is new to VoIP. Nearly every subtle bug in this tree is a protocol-semantics bug wearing a Rust costume — the code compiles, the tests pass, and the number on screen is wrong because the protocol does not mean what it looks like it means.

Each concept names the file that encodes it, so this doubles as an index into the source.

The diagrams here use the same mermaid sequenceDiagram form sipnab itself exports: press E in the Call Flow view to copy a diagram to the clipboard, or F2 and Tab through the save formats to Mermaid. Every one of them can be regenerated from a real capture.

SIP

Dialog, transaction, and what the store actually keys on

A transaction is one request plus its responses. A dialog is the longer-lived relationship a successful INVITE establishes — it spans many transactions (INVITE, re-INVITE, BYE) and is what a human means by "a call".

RFC 3261 identifies a dialog by the triple Call-ID + From-tag + To-tag. sipnab's DialogStore keys its map on Call-ID alone and keeps from_tag/to_tag as fields on SipDialog. That is a deliberate simplification for a capture tool: at capture time the To-tag does not exist yet (it arrives in the first response), so keying on the full triple would mean re-keying every dialog mid-flight. The tags are still captured — to_tag is filled the first time a response carries one — and forked calls that share a Call-ID are distinguished downstream rather than by the map key.

CSeq pins the transaction

Every request carries CSeq: <number> <METHOD>. Responses echo it, and that echo is the only reliable way to know which request a response answers.

This is not pedantry. update_timing() records the initial INVITE's CSeq number in invite_cseq precisely so that a re-INVITE's 200 OK cannot overwrite answered_at. Without that pin, a call put on hold twenty minutes in reports a twenty-minute setup time — a plausible-looking number that is simply false.

Auth challenges are pending, not failure

A 401 or 407 is the server saying "try again with credentials". Nearly every real call starts with one. Treating it as a final failure would report most of a healthy carrier's traffic as failed.

SipDialog therefore treats 401/407 as intermediate: the reported outcome is the maximum non-challenge final response, and the challenge only becomes the answer when the call was challenged and never authenticated.

The exchange, with what each hop tells the analyzer.

sequenceDiagram
    autonumber
    participant UAC as caller
    participant Proxy
    participant UAS as callee

    UAC->>Proxy: INVITE (CSeq 1 INVITE, From-tag A)
    Proxy-->>UAC: 407 Proxy Authentication Required
    Note over UAC,Proxy: not a failure — outcome stays pending
    UAC->>Proxy: ACK (CSeq 1 ACK)
    UAC->>Proxy: INVITE (CSeq 2 INVITE, Authorization)
    Proxy->>UAS: INVITE
    UAS-->>Proxy: 100 Trying
    UAS-->>Proxy: 180 Ringing (To-tag B)
    Proxy-->>UAC: 180 Ringing
    Note over UAC,UAS: PDD is measured to the 180, not the 100
    UAS-->>Proxy: 200 OK
    Proxy-->>UAC: 200 OK
    UAC->>UAS: ACK (CSeq 2 ACK)
    Note over UAC,UAS: dialog identity complete — Call-ID + both tags
Loading

The INVITE three-way handshake, and why ACK is special

INVITE alone among SIP methods is confirmed by a separate ACK transaction. Two consequences the code encodes: ACK is recorded but causes no state transition (the 200 already moved the dialog to InCall), and an ACK may carry SDP — see delayed offer below.

Non-INVITE transactions (REGISTER, OPTIONS, MESSAGE) are simple request/response with no ACK at all.

Post-dial delay stops at the ringing

pdd_ms() in timing.rs measures INVITE → first 180/183. A 100 Trying does not count: it means "I got your request", not "the callee is being alerted", and any proxy emits it immediately. Measuring to the 100 would report an excellent PDD for a call the caller experienced as ten seconds of silence.

Offer/answer, and the delayed-offer inversion

Normally the request carries the SDP offer and the response carries the answer. RFC 3261 §13.2.1 allows an offerless INVITE, and then the roles invert: the 200 OK carries the offer and the ACK carries the answer.

determine_offer_answer() encodes exactly that: an ACK is always an answer, and a response bearing SDP with no preceding offer in the dialog is itself the offer. Label these by message type alone and every delayed-offer call in the capture is backwards.

sequenceDiagram
    autonumber
    participant UAC as caller
    participant UAS as callee

    UAC->>UAS: INVITE (no SDP body)
    Note over UAC,UAS: offerless — nothing to record in the SDP timeline yet
    UAS-->>UAC: 200 OK + SDP
    Note over UAS: no preceding offer exists, so THIS is the offer
    UAC->>UAS: ACK + SDP
    Note over UAC: an ACK is never an offer — this is the answer
Loading

Hold and resume are direction attributes

A re-INVITE with a=sendonly puts the far end on hold; a=sendrecv resumes. sdp.rs parses sendonly/recvonly/inactive into a direction on the media description. The older RFC 2543 convention of holding by setting the connection address to c=0.0.0.0 is not recognized as hold here — such a call reads as media simply stopping. Media stopping mid-call is therefore not automatically a fault — check the SDP timeline before calling it one-way audio.

sequenceDiagram
    autonumber
    participant A as party A
    participant B as party B

    A->>B: re-INVITE + SDP (a=sendonly)
    B-->>A: 200 OK + SDP (a=recvonly)
    A->>B: ACK
    Note over A,B: RTP from B stops — expected, not a fault
    A->>B: re-INVITE + SDP (a=sendrecv)
    B-->>A: 200 OK + SDP (a=sendrecv)
    A->>B: ACK
    Note over A,B: media resumes — the SDP timeline is the evidence
Loading

CANCEL versus 200 OK is a race

CANCEL asks to abandon an INVITE that has not been answered. If the callee's 200 OK crosses it on the wire, both exist in the capture and the naive reading ("last response wins") gives the wrong outcome.

The state machine in dialog.rs resolves this by CSeq method: a CANCEL request moves the dialog to Cancelled, and the 487 that confirms it is the reported outcome — the 200 that merely acknowledged the CANCEL transaction is excluded, because it belongs to a different CSeq.

sequenceDiagram
    autonumber
    participant UAC as caller
    participant UAS as callee

    UAC->>UAS: INVITE (CSeq 1 INVITE)
    UAS-->>UAC: 180 Ringing
    UAC->>UAS: CANCEL (CSeq 1 CANCEL)
    UAS-->>UAC: 200 OK (CSeq 1 CANCEL)
    Note over UAC,UAS: this 200 answers the CANCEL, not the INVITE
    UAS-->>UAC: 487 Request Terminated (CSeq 1 INVITE)
    UAC->>UAS: ACK
    Note over UAC,UAS: outcome is 487 Cancelled — filtering by CSeq method is what gets this right
Loading

Multi-leg correlation

A B2BUA (SBC, PBX) terminates one call and originates another, so one human call is two Call-IDs with no shared identifier by default. dialog_store.rs correlates legs three ways, each with a confidence score: XCallId (a configured header, X-Call-ID by default), ViaBranch (a shared branch parameter), and TimingHeuristic (endpoint overlap plus timing). The header is the reliable one; the heuristic exists because most deployments do not set it.

RTP

Streams exist without dialogs

An RTP stream is identified by its SSRC, a 32-bit random number, not by any SIP field. It has no Call-ID, no From, nothing linking it to signaling except the IP/port pair the SDP advertised.

That is why StreamStore is a first-class store rather than a child of the dialog store (D13), why streams are discovered heuristically when their SDP was never captured, and why the --cores merge needs a re-association pass: the media and the signaling can be sharded to different workers.

Sequence numbers wrap at 65536

The 16-bit sequence number wraps roughly every 20 minutes of voice. Loss detection compares wrapping_add(1) against the received sequence (stream.rs); a plain > comparison reports one enormous loss burst per wrap on every long call.

Timestamps are not wall-clock

The RTP timestamp is a media sample counter at the codec's clock rate, not a time value. Converting it needs clock_rate_from_pt() for static payload types or the a=rtpmap clock rate for dynamic ones.

G.722 is the trap worth knowing: RFC 3551 assigns it a 8000 Hz RTP clock despite 16 kHz audio, so the obvious "clock rate = sample rate" assumption halves or doubles every derived duration.

Jitter is a signed transit delta, not a variance

RFC 3550 §6.4.1 defines interarrival jitter as a smoothed mean of the difference in transit time between consecutive packets: J(i) = J(i-1) + (|D(i-1,i)| - J(i-1)) / 16. stream.rs computes the transit delta as a signed i32 before taking the absolute value — with unsigned arithmetic a single reordered packet underflows and reports a jitter spike of about 4.29 billion.

RTCP receiver reports carry a jitter field too, but in RTP timestamp units. stream_store.rs converts with the stream's clock rate before storing it, so the RTCP-reported and locally-measured numbers are comparable and MOS is fed a millisecond value either way.

The report block is where two of this codebase's historical bugs lived, and both are visible in the same six fields.

sequenceDiagram
    autonumber
    participant S as sender
    participant R as receiver
    participant Cap as sipnab

    S->>R: RTCP SR (NTP + RTP timestamp, packet/octet counts)
    R->>S: RTCP RR report block
    Note over R,S: fraction_lost, cumulative_lost, highest_seq, jitter, last_sr, delay_since_sr
    R->>Cap: same RR observed on the wire
    Cap->>Cap: sign-extend cumulative_lost from 24 bits (negative = net duplicates)
    Cap->>Cap: jitter x 1000 / clock_rate to reach milliseconds
    Note over Cap: skip either step and the numbers are wrong, not missing
Loading

MOS is an estimate, not a measurement

estimate_mos() is an E-model computation from jitter, loss and codec — a model output on the 1.0–4.5 scale, not an opinion score from a listener. Say "estimated MOS" in anything user-facing; the distinction is the difference between a tool an engineer trusts and one they re-derive.

Bursty loss and diffuse loss are not the same impairment

Ten percent loss in one clump is a dropped word; ten percent scattered is a faint crackle a codec's concealment mostly hides. analyze_burst_gap() classifies which it is, and the loss map view renders it in sequence space.

DTMF is carried out of band, and depends on the clock

RFC 4733 telephone-events carry digits as their own payload type, negotiated in SDP. extract_dtmf_with_clock() needs both the negotiated PT and its rtpmap clock rate — event duration is expressed in clock ticks, so decoding a 16 kHz telephone-event with an assumed 8 kHz clock reports every digit as twice its real length.

Symmetric RTP, NAT, and one-way audio

Endpoints normally send from the same port they receive on ("symmetric RTP"). Behind NAT the address in the SDP is the private one and the media actually arrives from a translated address — so the naive match of SDP address against observed source fails, and the stream looks unassociated.

diagnose_media() is where this is untangled: one-way audio is inferred from the directed-endpoint set, nat_mismatch from SDP-versus-observed address disagreement, and the two combine into the diagnosis an operator reads. It also checks whether comfort-noise frames explain an asymmetry before flagging it — silence suppression is not a fault.

sequenceDiagram
    autonumber
    participant A as caller (behind NAT)
    participant NAT
    participant B as callee

    A->>B: SDP offer c=192.168.1.10 port 40000
    Note over B: private address — unroutable from here
    B->>NAT: RTP to the advertised address
    A->>NAT: RTP from 192.168.1.10:40000
    NAT->>B: RTP from 203.0.113.7:52344
    Note over B: source does not match the SDP — nat_mismatch
    Note over A,B: media flows one way — diagnose_media reports both signals together
Loading

What this model prevents

Every one of these was a real defect in this codebase, found and fixed. They are worth reading as a set, because none of them is a Rust mistake — each is a place where correct-looking code encoded a wrong protocol assumption.

The wrong assumption What it produced Where it is now handled
A 200 OK answers the INVITE A re-INVITE's 200 overwrote answered_at; held calls reported absurd setup times invite_cseq pinning in timing.rs
RTCP jitter is milliseconds Reported jitter off by the clock-rate factor, and MOS fed the wrong units Clock-rate conversion in stream_store.rs
cumulative_lost is unsigned A 24-bit signed field zero-extended: net-duplicate streams reported ~16.7M lost packets Sign extension in rtcp.rs, clamped at zero
Transit deltas can be unsigned One reordered packet underflowed to a ~4.29e9 jitter spike Signed i32 delta in stream.rs
Sequence numbers only increase A "loss burst" of 65,000 packets once per wrap wrapping_add comparison in stream.rs
SDP role follows message type Delayed-offer calls labeled backwards determine_offer_answer()
401/407 is a failure Most of a healthy carrier's calls reported as failed Non-challenge maximum in dialog.rs

The lesson generalizes: when a number looks wrong, the bug is usually not in the arithmetic. It is in what the field was assumed to mean.

Clone this wiki locally