Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

sipr

sipr sipr

A SIP testing tool and traffic generator written in Rust, compatible with SIPp scenarios.

sipr plays SIP call flows described in SIPp’s XML scenario format, as caller (UAC) or callee (UAS), at a controlled call rate, with a live terminal dashboard. It takes the same scenario files and the same command-line flags as SIPp, so sipr -sn uac -r 50 does what sipp -sn uac -r 50 does.

sipr is an independent implementation, not a fork. SIPp remains the reference tool for this format; its documentation and behavior are what sipr is tested against, and every release runs an interoperability suite with a real SIPp on the other end of the call. sipr exists for people who want that workflow in a single static binary with no system libraries. It is not intended to replace SIPp.

Source, issues and releases live at github.com/tareqmy/sipr.

Install

Prebuilt binaries for macOS, Linux (static) and Windows come with every release; the full list of methods is on the Installation page.

brew tap tareqmy/tap && brew install sipr                                   # Homebrew (macOS, Linux)
curl -fsSL https://raw.githubusercontent.com/tareqmy/sipr/master/scripts/install.sh | sh   # shell script
cargo install sipr                                                          # from crates.io
nix run github:tareqmy/sipr                                                 # Nix flake
irm https://raw.githubusercontent.com/tareqmy/sipr/master/scripts/install.ps1 | iex   # Windows

Quick start

# Terminal 1 — answer calls (UAS) on port 5060:
sipr -sn uas -p 5060

# Terminal 2 — place 1000 calls at 50 cps (UAC):
sipr -sn uac -r 50 -m 1000 127.0.0.1:5060

The UAC opens a live dashboard when run in a terminal: +/-/*// change the call rate, p pauses, s cycles the screens (main, per-step, repartitions), q drains and quits, Q aborts.

Run your own scenario, lint it first, or dump a built-in one:

sipr -sf my_scenario.xml -r 10 sip.example.com
sipr -sf my_scenario.xml --check      # compile + print the IR, exit non-zero on any issue
sipr -sd uac                          # print an embedded scenario to stdout

Headless (CI) mode with traces and a statistics CSV:

sipr -sn uac -m 10000 -bg -trace_msg -trace_err -trace_stat sip.example.com

Flags use SIPp’s single-dash names (-sf, -r, -l, -m, -d, -trace_msg, -au/-ap, -aa, -nr, …); sipr -h prints the full list.

Where to look

  • SIPp compatibility surface: every scenario element, action, keyword and flag sipr implements, the deliberate gaps, and the behavior notes learned from SIPp’s source.
  • Runtime control: SIPp’s UDP control socket and sipr’s HTTP/JSON API for driving a run from outside.
  • Glossary: transactions, dialogs, calls, RTDs, repartitions, 3PCC.
  • Architecture, Testing and Contributing if you want to change sipr.
  • Changelog for what each release changed.

License

sipr is licensed under the MIT License. SIPp is GPL-licensed, and no SIPp C++ source is copied into this project: its code was read to learn the behavior, which was then implemented separately. The scenario format, keywords and command-line flags are reproduced as an interface so existing SIPp scenarios keep working.

Installation

Every release ships prebuilt binaries for macOS (Intel and Apple Silicon), Linux (x86_64 and arm64, fully static musl builds) and Windows (x86_64), so nothing needs to be compiled. Pick whichever method suits your machine.

Homebrew (macOS, Linux)

brew tap tareqmy/tap
brew install sipr

The formula lives in the tareqmy/homebrew-tap repository and is updated by CI on every release. If Homebrew reports “No available formula”, run brew tap tareqmy/tap first.

Shell script (macOS, Linux)

curl -fsSL https://raw.githubusercontent.com/tareqmy/sipr/master/scripts/install.sh | sh

The script detects the platform, downloads the matching release archive, and installs sipr into /usr/local/bin when that is writable, otherwise into ~/.local/bin. Set VERSION=v0.27.1 in the environment to pin a release. Read the script before piping it to a shell; its checksum is in scripts/install.sh.sha256.

Uninstall the same way:

curl -fsSL https://raw.githubusercontent.com/tareqmy/sipr/master/scripts/uninstall.sh | sh

PowerShell (Windows)

irm https://raw.githubusercontent.com/tareqmy/sipr/master/scripts/install.ps1 | iex

Installs sipr.exe into %USERPROFILE%\.sipr\bin and adds that directory to the user PATH; no administrator rights are needed. scripts/uninstall.ps1 reverses it.

Chocolatey (Windows)

Not published yet. The package definition is in dist/chocolatey/ and the release workflow pushes it once a CHOCO_API_KEY secret is set; until then, use the PowerShell installer above.

Cargo

cargo install sipr

Builds from the crates.io sources with a stock Rust toolchain (1.85 or newer). No system libraries are needed: TLS is pure-Rust rustls, SRTP and AKA are in-tree, and pcap files are read without libpcap.

Nix (flake)

nix run github:tareqmy/sipr -- -sn uac -r 10 -m 100 127.0.0.1:5060
nix profile install github:tareqmy/sipr

In a flake-based NixOS or home-manager configuration:

inputs.sipr.url = "github:tareqmy/sipr";
# ...
environment.systemPackages = [ inputs.sipr.packages.${pkgs.system}.default ];

nix develop gives a shell with the Rust toolchain and cargo-deny.

Release archives

Every release on the releases page carries one archive per target, named sipr-vX.Y.Z-<target>.tar.gz (.zip on Windows), holding the single sipr binary. Download, extract, and put it on your PATH.

From source

git clone https://github.com/tareqmy/sipr && cd sipr
cargo build --release        # binary at target/release/sipr
cargo install --path .       # or install it onto your PATH

SCTP transport (-t s1|sn) is a Linux-only cargo feature and is not in the prebuilt binaries: cargo build --release --features sctp.

Verifying

sipr --version
sipr -sn uas -p 5060                          # answer calls
sipr -sn uac -r 10 -m 100 127.0.0.1:5060      # place 100 calls at 10 cps

SIPp compatibility surface

What “SIPp-compatible” means, precisely. Source of truth for the grammar: ../../cprojects/sipp/sipp.dtd; for behavior: the SIPp docs (https://sipp.readthedocs.io) and, where those are ambiguous, the C++ source (../../cprojects/sipp/src/, mainly scenario.cpp and call.cpp).

Rule zero: anything we do not implement must fail loudly (warning with file:line at load; hard error under --check). No silent skips, ever.

1. Scenario elements and attributes

v1 tier (M1–M6)

ElementAttributes (v1)Notes
scenarioname
sendcommon⁺, retrans, lost, crlf, start_txn, ack_txnCDATA body = message template; the _txn attrs name a transaction (M36, §6)
recvcommon⁺, response, request, optional, timeout, ontimeout, rrs, auth, lost, regexp_match, response_txn
pausecommon⁺, milliseconds, variable, distribution + its parameters, sanity_checkall ten SIPp distributions, SIPp’s attribute names and old-style min/max (M38, §6)
nopcommon⁺, displaycarries actions
labelidjump target; validated at compile
timewaitmillisecondsend-of-call linger
Referencevariablessuppress unused-var warnings
Globalvariablescomma list of run-wide variables (M35, §6)
Uservariablescomma list of per-user-id variables (M35, §6)
ResponseTimeRepartitionvaluems bucket list
CallLengthRepartitionvaluems bucket list

⁺ common attrs: start_rtd, rtd, repeat_rtd, crlf, next, test, chance, condexec, condexec_inverse, counter.

v1 actions (inside <action> on recv/nop)

ereg (with assign_to, check_it, header, regexp, search_in = msg|hdr|body|var, variable, start_line), log, warning, error, assign, assignstr, strcmp, verifyauth (with assign_to, username, password), pauserestore (value/variable), closecon, test, add, subtract, multiply, divide, todouble, jump, trim, gettimeofday, urlencode, urldecode, sample (assign_to, distribution

  • its parameters; M38, §6), jump (value/variable; the _unexp.main label, _unexp.retaddr and _unexp.pausedaddr recipe), exec with int_cmd (stop_now, stop_gracefully, stop_call) or command= (an external shell command, M37), setdest (host, port, protocol; M37, §6).

v1.x tier (fast follow)

Manual transactions (start_txn/ack_txn/response_txn) shipped in M36, exec command= and setdest in M37, statistical pauses and sample in M38 — the tier is complete. index as a standalone action stays out — sipr builds the index from -infindex at load, not from a scenario action. Extended 3PCC (-master/-slave/-slave_cfg with dest=/src= peer routing) shipped in M43; classic -3pcc in M10 (both in §6).

-inf injection + [fieldN] and lookup/insert/replace shipped in M7, classic 3PCC (sendCmd/recvCmd) in M10 (see §6).

Media (M14–M15)

Shipped: exec play_pcap_audio|video|image= and <recv ignoresdp> (M14), exec rtp_stream= (file/pattern/pause/resume) and exec play_dtmf= (M15) — -rtp_echo + rtpcheck (M18), SRTP (M23), exec rtp_echo= (M25) — see §6.

2. Keywords (v1)

[service] [remote_ip] [remote_port] [server_ip] (the IP this call sends from; -t ui) [local_ip] [local_ip_type] [local_port] [transport] [call_id] [call_number] [userid] [users] [cseq] [branch] [msg_index] [pid] [routes] [next_url] [peer_tag_param] [last_*] (verbatim copy of header(s) from last received message, e.g. [last_Via:], [last_From:]) [$var] [authentication] (+ username=/ password= params) [len] (Content-Length auto-compute) [field0..N] (v1.x, with injection files). M39 keywords (§6): [clock_tick] [timestamp] [date] [sipp_version] [dynamic_id] [remote_host] [tdmmap] [last_message] [last_cseq_number] (with +N/-N) [fill variable= text=] [file name=] and the -key KEYWORD VALUE generic keywords [KEYWORD].

Media keywords (M14): [media_ip] (-mi, default the local IP), [media_ip_type], [media_port] (-mp, default 6000, the same value for every call — as in SIPp), [auto_media_port] (per-call 4-port block: base + 4*(call_number-1) % 10000, SIPp’s undocumented keyword), and the +N offset forms [media_port+1] / [auto_media_port+2] (RTCP, video). [authentication] params (M16/M19): username= password= aka_K= aka_OP= aka_AMF= (SIPp), aka_OPc= aka_sqn= aka_resync= (sipr additions); 0x-prefixed hex or raw bytes. SRTP/SDES (M23): [cryptotag{1,2}{audio,video}], [cryptosuite<suite>{1,2}{audio,video}], [cryptokeyparams{1,2}{audio,video}] (-N offset = reuse the key), [ue<suite>{1,2}{audio,video}] (UNENCRYPTED_SRTP), <suite>aescm128sha180 aescm128sha132 nullsha180 nullsha132. [rtpstream_audio_port] / [rtpstream_video_port] (M15): a port allocated to the call from -mp..-max_rtp_port in steps of two the first time it renders; +N forms never allocate (a=rtcp:[rtpstream_audio_port+1]).

Keyword parameters use SIPp syntax [keyword param=value]. Unknown keywords: loud warning + left verbatim in the message (match SIPp behavior — verify in C++ and record below).

3. CLI flags (v1 set, SIPp names)

Scenario/mode: -sf <file> -sn uac|uas|ooc_default|ooc_dummy -sd (dump embedded) -oocsf <file> / -oocsn ooc_default|ooc_dummy (out-of-call scenario, client mode only, M33) -rxsf <file> / -rxsn uas|… (mixed mode: a server-mode receive scenario next to the client-mode main one, M34) -rxinf <file> (injection files loaded after the -inf ones, for [fieldN file=NAME] in either scenario) --check (sipr addition: lint scenario — and the ooc/rx one — and exit). Traffic: -r <rate> -rp <ms> -l <max concurrent> -m <total calls> -d <pause ms default> -users (v1.x closed loop) -set <variable> <value> (seed a <Global> variable, M35) -rate_increase <n> -rate_max <n> -rate_interval <time> -no_rate_quit -rate_scale <n> (M20 ramps). Network: -p <local port> -i <local ip> -t u1|un|ui|t1|tn|l1|ln (UDP / TCP / TLS, one socket, one socket per call, or one UDP socket per injected IP; s1|sn = SCTP, only in a build with the sctp cargo feature on a host with an SCTP stack) -ip_field <n> (the -inf column holding that IP) -max_socket <n> (per-call modes share sockets past n) -rsa <host[:port]> (remote sending address) -max_reconnect <n> -reconnect_close <bool> -reconnect_sleep <ms> (TCP/TLS reconnection) -s <service> (called number) -bind_local -buff_size <bytes> -sendbuffer_warn <bool> -bind_to_device <name> (M44, §6) -tls_cert/-tls_key/-tls_ca/-tls_crl/-tls_version (TLS material, SIPp defaults cacert.pem/cakey.pem). Media: -mi <ip> (media address; default local IP) -mp <port> (base media port, default 6000; -min_rtp_port is SIPp’s alias — note SIPp’s -mp is that alias too, not a fixed port) -max_rtp_port -rtp_payload <pt> (default 8) -random_base_ssrc -rtp_echo -mb <bytes> -audiotolerance -videotolerance (M18). Auth: -au/-ap (username/password defaults for [authentication]) -auth_uri (digest uri= after SIPp’s sip: prefix; default remote_ip:remote_port, M21). Control (M17): -cp <port> -ci <ip> (SIPp’s UDP control socket; -cp 0 disables — sipr addition) and sipr’s --sipr-http [HOST:]PORT / --sipr-http-token (docs/CONTROL_API.md). Tracing/output: -trace_msg -trace_err -trace_stat -stf <file> -fd <interval s> (default 60, the (P) period) -f <interval s> (screen and -bg line refresh, default 1) -trace_rtt -rtt_freq <n> -trace_counts -trace_error_codes -trace_screen -screen_file <file> -stat_delimiter <s> -periodic_rtd (M40, §6) -trace_logs -log_file -trace_shortmsg -shortmessage_file -trace_calldebug -calldebug_file -error_file -message_file -<kind>_overwrite <bool> (message, error, log, shortmessage, calldebug, screen) -ringbuffer_files -ringbuffer_size -max_log_size -deadcall_wait <ms> -trace_timeout (accepted; a no-op in SIPp 3.7 too) (M41, §6) -nd (no defaults) -timeout <s> -bg (headless). Behavior toggles: -aa (auto-answer OPTIONS/INFO/UPDATE/NOTIFY in-dialog), -base_cseq, -cid_str (Call-ID format), -max_retrans, -nr (no retrans), -max_invite_retrans, -max_non_invite_retrans, -recv_timeout, -timeout_error, -lost, -pause_msg_ign, -default_behaviors, -callid_slash_ign, -sleep, -nostdin (M42, §6); -send_timeout and -timer_resol are accepted with a warning (no send queue, exact timers). Keywords (M39): -key <keyword> <value> (repeatable), -tdmmap <map>, -dynamicStart/-dynamicMax/-dynamicStep (the [dynamic_id] counter), -rfc3339 ([timestamp] form). Accepted with a “no effect in sipr” warning (M44, §6): -watchdog_interval -watchdog_reset -watchdog_minor_threshold -watchdog_major_threshold -watchdog_minor_maxtriggers -watchdog_major_maxtriggers -max_recv_loops -max_sched_loops -rtp_threadtasks -skip_rlimit -plugin and the SCTP socket options -multihome -heartbeat -assocmaxret -pathmaxret -pmtu -gracefulclose.

Where sipr needs a flag SIPp lacks, prefix long-form --sipr-* to keep the two namespaces distinct.

hide="true" and display="…" on any message command (M22): the scenario screen skips hidden rows while set hide true (default) holds, and shows display text instead of the derived label.

3.1 Flags accepted but without effect

These steer machinery sipr does not have: SIPp’s event-loop scheduler and watchdog task (-watchdog_*, -max_recv_loops, -max_sched_loops), its RTP playback thread pool (-rtp_threadtasks), its file-descriptor rlimit tuning (-skip_rlimit), its dlopen plugins (-plugin), and the SCTP socket options only libsctp can set (-multihome, -heartbeat, -assocmaxret, -pathmaxret, -pmtu, -gracefulclose). sipr parses each, prints one sipr: warning: -<flag> has no effect in sipr: <why> line, and carries on, so a CI wrapper written for sipp keeps working instead of dying at argument parsing. This is the only sanctioned exception to “an unknown flag is an error”: a flag not in this list and not implemented is still a usage error. -send_timeout and -timer_resol (M42) warn the same way for the same reason.

4. Runtime key bindings (TUI)

+/- rate ±1 (*,/ ±10), p pause traffic, s..screens cycle, q soft quit (drain), Q hard quit. Match SIPp muscle memory exactly. SIPp’s screen digits 1 (scenario) 2 (statistics) 3 (repartition) also work, at the keyboard and over the control socket (M22).

5. Exit codes

0 = all calls successful; 1 = at least one call failed; 97 = exit on internal command / user abort; 99 = aborted, no calls processed; -1/255 = fatal error; -3/253 = an RTP echo check failed (EXIT_RTPCHECK_FAILED, M18; wins over the call-failure code, as in sipp_exit). sipr adds 2 = usage error.

6. Behavior notes (folklore learned from docs/C++ — append as discovered)

  • Recv matching — VERIFIED in call.cpp (process_incoming, the two scan loops around line 5360, and matches_scenario); implemented in sipr-engine/src/engine.rs::scan_for_match:
    • Forward scan from the current index: unmatched optional recvs are skipped; the scan stops at the first mandatory recv (inclusive) or any non-recv step. A match may land on any step in that window; execution resumes after the matched step (skipped optionals are passed for good).
    • Backward scan when forward fails: only the contiguous optional block immediately behind the window may re-match (out-of-order provisionals); contig is broken by ANY non-optional message including sends — a late 180 arriving after the ACK is unexpected and kills the call, exactly as in SIPp. (optional="global" would bypass contig; sipr rejects that value until implemented.)
    • CSeq-method guard: beyond index 0, a response only matches a recv if its CSeq method occurs in the list of all request methods sent so far (recv_response_for_cseq_method_list, built by concatenating each send’s method in scenario.cpp and tested with strstr) — so after INVITE and PRACK both 200s match the following recvs, while a response to a method never sent cannot. (Until M23 sipr kept only the nearest preceding method, which rejected the INVITE’s 200 after a PRACK.)
  • regexp_match="true" (verified in call.cpp matches_scenario ~l.4540-4575): the request expectation runs as an unanchored POSIX extended regex (REG_NOSUB) over the method, the response one over the decimal status code (snprintf("%u")), and the CSeq-method guard above still applies afterwards. So request=".*" takes any request and response="18[0-9]" any 18x. Until M33 sipr compiled the regex but then matched literally — fixed with M33 (recv_matches).
  • Out-of-call scenarios (M33; verified in sipp.cpp ~l.1792-1800 (parse), ~l.2113-2116 (the ooc_default fallback is commented out), ~l.2147-2149 (server-mode fatal), socket.cpp ~l.1160-1240 (process_message dispatch), call.cpp ~l.6641 (-inf fatal), scenario.cpp ~l.1933 (embedded names), reporttask.cpp ~l.94 (only the main stats are ever dumped)): -oocsf <file> / -oocsn <name> load a second, independently compiled scenario with its own variable table, per-step stats and repartitions. In client mode a request whose Call-ID matches no live call spawns a call on it — keyed by that Call-ID, remote = the packet’s source (or -rsa), no user id and no injection line ([userid] renders 0; any [fieldN] in the ooc scenario is fatal at startup: “Automatic calls (created by -aa, -oocsn or -oocsf) cannot use input files!”) — logs “Received out-of-call METHOD message, using the out-of-call scenario”, counts an incoming call on the ooc stats plus the global auto-answered counter, and feeds it the request at step 0 (ooc_dummy then fails it as unexpected, on the ooc stats). An unmapped response is only counted (E_OUT_OF_CALL_MSGS = sipr’s unexpected) and never spawns anything, ooc scenario or not. Without -oocs* a UAC keeps discarding unmapped requests the same way — SIPp’s default since the fallback was commented out. Server mode is fatal (“SIPp cannot use out-of-call scenarios when running in server mode”); -oocsf and -oocsn are mutually exclusive. SIPp’s open_calls counts main-scenario calls only, so ooc calls never count toward -l, -users or -m, and the run ends when the main calls are done — lingering ooc calls (the default’s 4 s timewait) are dropped. set display ooc|main swaps every screen — the main counters, the statistics and repartition screens and the scenario page — to that scenario, as SIPp’s screen.cpp reads display_scenario->stats throughout (v0.22.0 had only the scenario page follow; corrected with M34); -trace_stat never writes an ooc CSV (SIPp’s stattask::report dumps main_scenario->stats only) and the exit code always reflects the main scenario. Two SIPp behaviours seen in the interop runs and not reproduced: on exit SIPp aborts its lingering ooc calls with a BYE (its generic established-call abort, sipp_exit); and a SIPp UAS spawns a main-scenario call for any unmapped message, responses included — the 200 answering its own out-of-call OPTIONS fails a call and eats its -m budget — where a sipr UAS keeps discarding unmapped responses. Mixed mode (-rxsf) is the next note.
  • Mixed mode -rxsf <file> / -rxsn <name> + -rxinf (M34; verified in sipp.cpp ~l.174-197, 1778-1790, 1584-1605, 2140-2148, 556-561, 1182, socket.cpp ~l.1184-1195, screen.cpp ~l.83-90, 242-245, 294, 710, 796): a second, server-mode scenario terminates the calls the peer originates towards a client-mode main scenario. SIPp quirks worth knowing: (1) in SIPp 3.7 only -rxsf works — the option table spells the embedded variant rxrn while the parser expects rxsn, so -rxsn is an unknown option and -rxrn an “Internal error” (the help text’s -snrx/-sfrx exist nowhere); sipr accepts -rxsn as the parser intends and -rxrn not at all. (2) -rxinf registers the CSV in the shared file map under its basename, but the rx_default_file it sets is never read: a bare [fieldN] in the rx scenario means the first -inf file (“No injection file was specified!” without one) and [fieldN file=name.csv] reaches a -rxinf file by name — sipr does the same, loading -rxinf files after the -inf ones into one table. (3) SIPp enforces none of its help text’s “rx MUST be server-mode, main MUST be client-mode”; sipr does, at startup, and also refuses <sendCmd>/<recvCmd> in the rx scenario and -rxs* together with -oocs* (process_message takes the MODE_MIXED arm first, so an ooc scenario never fires in mixed mode). (4) Dispatch: SIPp spawns an rx call for any unmapped message, responses included and even while quitting (that check is commented out), logging nothing; sipr spawns for unmapped requests only — no user id, injection lines drawn like a UAS call’s, counted as an incoming call on the rx stats, a sipr-only line in the error trace — and keeps discarding unmapped responses as its UAS does. (5) Rx calls never count toward -l/-users/-m (call_generation_task.cpp and the main loop look at main_scenario), so the run ends with the main calls and lingering rx calls are dropped: a timewait at the end of the main scenario is how a mixed-mode side stays up for the peer’s last call (the interop tests do this). (6) set display rx|main switches every screen, see the ooc note; SIPp’s header reads “Sipp Mixed Mode - main|rx”. (7) Not reproduced: SIPp’s exit code comes from whichever scenario is displayed at exit (sipp.cpp ~l.1182) — sipr’s always reflects the main scenario — and SIPp’s exit abort BYEs lingering rx calls. (8) <init>: SIPp never runs the rx scenario’s; sipr has no <init> support at all (an unknown element is a hard error), so there was nothing to decide. -trace_stat stays main-only.
  • A matched recv cancels the pending retransmission of the last send (next_retrans = 0) — including a matched provisional. SIPp’s own code carries a TODO admitting this can erroneously stop retransmission (e.g. 180 received, 200 lost → the call stalls until a timeout). sipr reproduces the behavior faithfully; scenarios can mitigate with timeout/ontimeout on the mandatory recv.
  • Pacing: SIPp smooths call starts within the rate period rather than bursting -r calls at once; sipr ticks every ≤20 ms and accumulates fractional starts.
  • UAS behaviors (M4): an inbound retransmission (same branch/CSeq/start line) is answered by re-sending our last message; during timewait the call absorbs traffic without failing (SIPp deadcall). -aa answers in-dialog OPTIONS/INFO/UPDATE/NOTIFY with a 200 mirroring Via/From/To/Call-ID/CSeq. UAS calls reply to the request’s source address — as SIPp does: it keeps the source as call_peer and never reads Via received/rport (checked in M42; no divergence).
  • -trace_stat CSV (M4, at parity since M40 — see the M40 note): SIPp’s columns, names, order, (P)/(C) naming and ; delimiter.
  • -l cap: calls above the concurrent cap are not queued — the pacer simply does not start them; effective rate drops.
  • [branch] must be unique per transaction and RFC 3261 magic-cookie prefixed (z9hG4bK); SIPp derives it from call number + msg index — mirror the shape.
  • Retransmission: applies to UDP sends awaiting a matching recv; recv with timeout + ontimeout jump is the scenario-level timeout mechanism.
  • auth="true" on a recv of 401/407 stores the challenge; the next send’s [authentication] keyword consumes it. Stale nonce handling: re-auth once.
  • Default headers: SIPp does NOT auto-add headers to templates (what you write is what is sent), except Content-Length when [len] present or body exists (verify), and CRLF normalization of line endings. -nd is -default_behaviors none (M42 note).
  • Diagnostics policy as implemented (M1): unknown elements and actions are hard errors (skipping a step silently would change call flow); unknown attributes warn and are ignored; unknown keywords warn and pass through verbatim (IPv6 literals like [2001:db8::1] in URIs depend on this). --check treats any diagnostic, warnings included, as failure.
  • Template CDATA normalization (M1, template::normalize_cdata): every line left-trimmed, line endings → CRLF, leading/trailing blank lines dropped, single trailing CRLF appended; internal blank line (header/body separator) preserved. TO VERIFY against scenario.cpp message construction at M3 interop — especially whether SIPp appends CRLFCRLF or CRLF.
  • <pause sanity_check> (default true) is SIPp’s 99th-percentile guard on a distributed pause; implemented as of M38 (see the M38 note below).
  • The DTD spells the recv SDP attribute ignosesdp (sic); SIPp docs use ignoresdp. sipr recognizes both spellings (and rejects them until media).
  • Regex engine (M6, sipr-scenario/src/regex.rs): ereg and regexp_match recv patterns use an in-tree POSIX-ERE matcher — literals, . (not newline), classes incl. [[:alpha:]]-style POSIX classes, anchors, alternation, * + ? {m,n}, and capture groups. DIVERGENCE: it is a leftmost-first greedy backtracker (PCRE-style), NOT POSIX leftmost-longest. Identical on the patterns SIPp scenarios use (the SIPp default regexp scenario’s IP/SDP-origin captures are covered by tests); a pattern that relies on POSIX longest-match semantics could differ. A backtracking step budget bounds pathological patterns — an over-budget match fails rather than hanging. ereg assign_to="1,2,3": index 0 (first listed var) gets the whole match, the rest get capture groups in order.
  • Digest auth (M6, sipr-auth): MD5 and SHA-256, qop=auth with cnonce/nc, opaque echo, 401 (Authorization) and 407 (Proxy-Authorization). The [authentication] keyword computes the value from the last recv auth="true" challenge, using -au/-ap or the keyword’s own username=/password= params. The digest URI is currently the sip:[service]@remote shape; a proxy keying strictly on the request-URI may need that widened (tracked for post-v1). Stale-nonce: the challenge exposes stale; scenarios re-auth by looping back to the send.
  • Action executor (M6): variables are loosely typed (string/num/bool) with SIPp-style coercion; strcmp yields 0 on equality (C semantics); test/condexec truthiness = set and not zero/false/empty; divide by zero leaves the value unchanged. exec int_cmd maps to fail-call / graceful-stop / immediate-stop.
  • Injection files -inf (M7, verified in infile.cpp / call.cpp getFieldFromInputFile): line 1 is the mode, matched by SUBSTRING — SEQUENTIAL, RANDOM, or USER, optionally with PRINTF= (below). Data lines follow; a line beginning # is a comment, trailing \r is stripped, a blank line ends the file. Field separator is ;, fields are 0-indexed ([field0] = first). Each call is assigned ONE line per file at creation (nextLine): SEQUENTIAL = a shared per-file counter mod line-count, RANDOM = uniform pick, USER = userId-1 (M11 — supported under -users; without -users the fields render empty and sipr warns at load). [fieldN] uses the default (first) file. file= selects another file by its SIPp key — the BASENAME of the -inf path (sipp.cpp SIPP_OPTION_INPUT_FILE strips the directory); sipr also accepts a 0-based -inf index there as an extension. line= overrides the per-call line and, per SIPp (message.cpp builds it as a SendingMessage, resolved in getFieldFromInputFile), is rendered at send time — so line=[$var] works and a value past the end / negative renders empty (SIPp sets line = -1).
  • Indexed injection, lookup/insert/replace (M7, verified in infile.cpp index/lookup/insert/replace/reIndex/deIndex and call.cpp action execution): -infindex FILE FIELD builds a key→line map over one field; on duplicate keys the LAST line wins (reIndex erases then inserts). <lookup assign_to="v" file="F" key="K"/> stores the matched line number in v, or -1 on a miss (looking up a file with no -infindex is an error). <insert file="F" value="…"/> appends a ;-split row; <replace file="F" line="N" value="…"/> swaps a row; both re-index around the change. file, key, value, line are all rendered templates. The typical chain is lookup → [fieldN line=[$v]]. Files are wrapped so reads ([fieldN]) and mutations (insert/replace) share them on the single engine thread. The standalone <index> action is not supported — use -infindex.
  • PRINTF= injection files (M44, verified in infile.cpp — the header parse, getField’s printf branch, numLines, insert/replace): a header PRINTF=<n> (plus optional PRINTFOFFSET=<o>, default 0, and PRINTFMULTIPLE=<m>, default 1) makes the data lines templates. The file then has n virtual lines; virtual line l reads real line l % rows and every %d conversion in the field is filled with o + l * m, %% being a literal %. So one row, SEQUENTIAL,PRINTF=10000\nuser%05d;[...], is ten thousand users. Only %[0-9.-]*d is a legal conversion; insert/replace on such a file are refused, as in SIPp. Two deliberate divergences: sipr splits the header into ,/whitespace tokens, so PRINTFOFFSET= may precede PRINTF= (SIPp finds each with strstr, and that order makes its PRINTF match land inside PRINTFOFFSET — a parse error); and sipr checks every field’s conversions at load, where SIPp errors at render time, the first time a call reads a bad field.
  • TCP transport -t t1 (M8): SIP over TCP is a byte stream, so message boundaries come from Content-Length, not packet edges (RFC 3261 §7.5). A framer reads headers up to the first \r\n\r\n, then exactly Content-Length body bytes; leading \r\n runs (keep-alive pings, RFC 5626) are skipped. sipr keeps one connection per peer — the client (UAC) dials the target once at start-up and the server (UAS) accepts, framing each; responses go back on the connection the request arrived on (keyed by peer address, like SIPp routes by the socket the message came in on). Reliable transports carry NO SIP retransmissions (RFC 3261 §18.2), so retrans=/-max_retrans are ignored under t1. Per-call connections (tn) are M28 below, reconnection after a drop M30, one socket per injected IP (-t ui) M31.
  • Message framing fix surfaced by TCP: every SIP message must end with the header/body separator (\r\n\r\n) even with no body (RFC 3261 §7). sipr’s CDATA normalization trimmed the trailing blank line for body-less messages (180, ACK, empty 200); UDP datagrams hid it, but TCP framing and real SIPp need it, so normalization now restores the separator when a message has no body.
  • Classic 3PCC -3pcc HOST:PORT (M10, verified in scenario.cpp role detection, call.cpp sendCmdMessage/sendCmdBuffer, sipp.cpp SIPP_OPTION_3PCC): two instances coordinate over a separate TCP “twin” socket, exchanging command messages each terminated by a single ESC byte (0x1B — SIPp’s delimitor[0]=27). The role comes from the scenario’s first twin command: sendCmd-first dials the peer (controller A, started last), recvCmd-first listens (controller B); both take the same -3pcc address. <sendCmd> renders its CDATA (keywords/variables) and writes it plus ESC; <recvCmd> blocks the call until a command arrives, then runs its <action>s with ereg searching the raw command text (SIPp strips a trailing CRLF and matches against the blob). Commands are opaque text used to pass SDP/tags between the two controllers, e.g. <sendCmd> a captured offer then <recvCmd> the answer. Extended master/slave 3PCC, the optional-recvCmd fall-through, command routing by Call-ID and the twin-closed rule came in M43 (next note); SIPp has no twin reconnection to mirror.
  • Extended 3PCC -master NAME/-slave NAME/-slave_cfg FILE with sendCmd dest= and recvCmd src= (M43, verified in sipp.cpp SIPP_OPTION_3PCC_EXTENDED/SIPP_OPTION_SLAVE_CFG, scenario.cpp parse_slave_cfg/computeSippMode/the sendCmd/recvCmd parse, socket.cpp open_connections/connect_to_all_peers/pollset_process/ read_error/process_message, call.cpp sendCmdMessage/ process_twinSippCom/check_peer_src/checkInternalCmd, docs/3PCC_extended.rst):
    • The table is one name;host:port per line — the first two ;-fields, anything after them ignored, a line without ; skipped (sipr warns). -slave_cfg needs -master or -slave, which exclude each other and -3pcc; the own name and every dest= must be in the table (“get_peer_addr: Peer X not found”). The scenario’s role must match the flag (“Inconsistency between command line and scenario: master scenario but -master option not set” / “slave scenario but -slave option not set”): a master scenario reaches sendCmd before any recvCmd, a slave the other way round. In extended mode every sendCmd needs dest= and every recvCmd src= (“You must specify a ‘dest’ for sendCmd with extended 3pcc mode!”).
    • Wiring: each instance listens on its own table address. The master dials every dest= peer at start-up — so it is launched last — while a slave dials its own dest= peers only when the first peer connects to it (connect_to_all_peers from the accept path); a slave that never sendCmds dials nobody. A pair is joined by two one-way TCP connections and a command leaves on the sender’s dialed link to that peer. There is no reconnection: any control connection closing ends the run at once — WARNING “One of the twin instances has ended -> exiting”, then quitting += 20, which is past the main loop’s >= 11 hard-exit bar, so the calls still open are aborted (abort_all_tasks, they count as failed) and the process exits. Classic controller B does the same (“3PCC controller A has ended -> exiting”); controller A only sets quitting = 1 and drains. Hence the docs’ rule that slaves run without -m and the master is launched last: the master’s normal end is what stops the slaves, and it comes after their calls are done.
    • Routing: a twin command is keyed by its own Call-ID: line exactly like a SIP message (get_trimmed_call_id, /// marker included; a command without one is discarded). An unknown Call-ID opens a new outgoing call with that id on the 3PCC “server” sides — controller B and every slave, whose first send/recv/sendCmd/recvCmd is a recvCmd (computeSippModeMODE_SERVER creation) — so [call_id] on a slave is the master’s and the pacer plays no part there (-m still caps them); a master or controller A discards it (“Discarding message which can’t be mapped to a known SIPp call”). src= is checked against the first token of the command’s own From: line, never against the socket it came in on: the sender writes its name into the command (From: m, as the SIPp docs show). A mismatch is WARNING “Unexpected sender for the received peer message” and the call is rejected.
    • Matching (process_twinSippCom): from the current step forward, optional steps and nops are skipped and the first recvCmd takes the command (trailing CRLFs stripped before its actions run); a mandatory step of any other kind is “Unexpected control message received” and the call is rejected (rejectCall: a failed call, no abort messages). The same skip rule in process_incoming is the optional-recvCmd fall-through: a SIP message for the recv behind an optional recvCmd passes over it, so sipr keeps that recv window open while it waits. internal-cmd: abort_call (SIPp’s 3pcc_abort default message, call-id: [call_id]) fails the named call; a controller sends it to its twin when it aborts a call past its first step on an unexpected message or BYE/CANCEL — classic mode only, extended mode has no single twin socket and sends nothing.
    • sipr before M43 handed a twin command to whichever call was blocked on recvCmd and queued early ones, so a peer’s reply did not need the Call-ID; it must carry it now, as with SIPp. Controller B used to pace its calls with -r; they now open on the commands that name them. Still open: -trace_msg does not log twin commands (SIPp logs them tagged “control”).
  • -users N closed loop (M11, verified in call_generation_task.cpp run/free_user/set_users, call.cpp init line assignment and [userid]/[users] keywords, sipp.cpp SIPP_OPTION_USERS): instead of open-loop rate pacing, keep N concurrent calls, each holding a 1-based user id drawn from a free pool (1..N). A finished call returns its id and a replacement opens immediately (calls_to_open = users - current_calls), so the population stays constant until -m total is reached. -users and -l are mutually exclusive. USER-mode -inf files resolve line = userId-1 (SIPp nextLine(userId)); [userid] renders the id, [users] the count. The count changes at runtime through set users N (control socket, HTTP /control) and the + - * / keys (M17); see the M35 note below for the id bookkeeping and the per-user variables.
  • IPv6 (M12, verified in call.cpp E_Message_Local_IP/E_Message_Remote_IPlocal_ip_w_brackets/remote_ip_w_brackets vs E_Message_Media_IP → raw media_ip): [local_ip]/[remote_ip] render the address bracketed when it is IPv6 ([2001:db8::1]), so URIs and Via lines are well-formed, while [media_ip] stays raw for SDP c=/o= lines (SIPp brackets [local_ip] even in the SDP o= line — sipr matches that verbatim). Targets accept bracketed ([::1], [2001:db8::1]:5060) and bare-literal (::1) IPv6; a v6 target with no -i auto-binds the :: family. [local_ip_type]/ [media_ip_type] render 6 for a colon-bearing address. -i takes a v6 local address directly. Not exercised in the build sandbox (no v6 loopback); the e2e self-skips there and runs where ::1 binds.
  • TLS -t l1 (M13, verified in sslsocket.cpp TLS_init_context/ SSL_new_client/SSL_new_server, socket.cpp handshake/read/write paths, sipp.cpp option table): TLS is exactly the TCP path with a TLS layer — same Content-Length framing, same connection-per-peer model (ln collapses onto it like tn), no SIP retransmissions, default port stays 5060 (SIPp has no 5061 constant), no sips: scheme anywhere, [transport] renders TLS. Cert/key default to cacert.pem/cakey.pem in the CWD and are required to start (SIPp loads them into both client and server contexts, so the client always presents its cert when asked). Peer verification is OFF unless -tls_ca or -tls_crl is given; when on, the client validates the chain but never the hostname (no X509_check_host in SIPp), and the server demands + verifies a client cert (SSL_VERIFY_PEER | FAIL_IF_NO_PEER_CERT — mutual TLS is a side effect of -tls_ca). SNI is sent only for named (non-IP) targets; sipr resolves targets before dialing, so like SIPp with an IP target it sends none. Deliberate divergences: (1) a failed inbound handshake drops that connection with a warning — SIPp kills the whole process on SSL_accept failure; (2) -tls_version 1.0/1.1 are rejected (rustls starts at 1.2; SIPp’s floor is 1.0); (3) encrypted keys are rejected — SIPp silently decrypts with the hardcoded passphrase ksgr (sslsocket.cpp passwd_call_back_routine); (4) setdest to TLS is fatal in SIPp and unsupported here too. Also noted: sipp’s client stream bind (TCP and TLS) reuses its own listening port, which fails with EADDRINUSE on macOS — the reverse interop test self-skips there.
  • pcap replay exec play_pcap_* (M14; verified in prepare_pcap.c prepare_pkts, send_packets.c send_packets/do_sleep, call.cpp get_remote_media_addr (~l.349), the media_port/auto_media_port keyword handler (~l.2789), E_AT_PLAY_PCAP_* execution (~l.6196), sipp.cpp setup_media_sockets): SIPp parses the file once at scenario load (missing/truncated = fatal; “recapture with -s0”), keeps the UDP header + payload of every UDP packet with no RTP filtering, and replays on a raw socket rewriting only the UDP ports (port_diff = packet’s destination port minus the lowest destination port in the file, added to the SDP-learned remote port and the advertised local port) — the RTP header is sent verbatim, so every call replaying one file emits the same SSRC/seq/timestamps. Timing tracks the capture’s absolute timeline (didsleep vs elapsed), out-of-order timestamps get no delay. The action is non-blocking (a <pause> must cover the file’s duration) and one media thread per call means audio cancels video and vice versa. The remote endpoint is the first c=IN IP4/IP6 + m=audio|video|image of any response with a body or any INVITE/ACK/PRACK request, unless the recv has ignoresdp; streams absent from a later SDP keep their old address. [media_port] is min_rtp_port (6000) for every call unless -rtp_echo bumps it at startup; [auto_media_port] = + 4*(call-1) % 10000; the local port used by a replay is whatever [media_port] rendered on the SDP line containing “audio”/“video”/“image”. sipr matches all of that with these deliberate divergences: (1) ordinary UDP sockets bound to the media port — no raw socket, no root; the sockets are not connected so a silent peer’s ICMP errors never abort a replay; (2) non-UDP/non-IP packets in a capture are skipped with a count, not fatal (SIPp aborts on an unknown EtherType); (3) audio/video/image streams of one call are independent — playing one does not cancel another; (4) a port-0 (held) m= line is skipped in favour of a later live one (SIPp’s rtpstream path does this, its pcap path does not); (5) 802.11 captures are rejected (unsupported link type) — recapture on the wired side; (6) pcapng captures are read too (M44), which SIPp’s pcap_open_offline refuses — its -s0 advice covers only the classic format. An in-tree block reader (sipr-media::pcapng, no crate) handles Section Header, Interface Description (if_tsresol, decimal and binary), Enhanced Packet, Simple Packet and the obsolete Packet block, in either byte order and across sections; other block types are skipped by their length. The resulting stream is identical to the classic reader’s, so everything above applies unchanged. play_pcap= (in the DTD, never implemented by SIPp) is an error pointing at play_pcap_audio=. -key shipped in M39 (§6).
  • exec rtp_stream= / exec play_dtmf= (M15; verified in rtpstream.cpp rtpstream_playrtptask (~l.603), rtpstream_get_localport (~l.1789), rtpstream_cache_file / get_wav_header_size (~l.1619/2240), actions.cpp setRTPStreamActInfo (~l.677), prepare_pcap.c prepare_dtmf (~l.556), call.cpp E_Message_RTPStream_Audio_Port (~l.2827)): the value is name,loops|pattern_id,payload_type, payload_name; files are raw codec bytes with only a RIFF/WAVE header skipped (“Doesn’t actually parse/convert anything!”), cached once at parse; the payload table is fixed (0/8/9 → 160 B per 20 ms, 18 → 20 B, 13 → 1 B per 150 ms, dynamic H264/90000 → 1280 B per 160 ms video, iLBC/8000 → 50 B per 30 ms) and a missing name is fatal except for 0/8/9/18; a mismatched name is a fatal “unknown payload type”. Packets: V=2, marker never set, seq from 0, timestamp = wall-clock ms × ticks-per-ms advancing by ticks-per-packet, SSRC 0xCA110000 + 2 per call (-random_base_ssrc randomizes the base), payload spliced across the file end when looping, -1 loops forever. pause does NOT stop the clock — the timestamp is fast-forwarded so the stream “appears up to date” on resume. [rtpstream_audio_port] allocates a port from min_rtp_port in steps of two (wrapping at max_rtp_port) with a trial bind; +N never allocates. SIPp streams from that allocated port even when the SDP advertised [media_port] (its own pfca_uac.xml does this), and its RTCP socket is always destroyed by an inverted bind test. play_dtmf="digits[,tone]": 20 warm-up packets (PT 97, 4 zero bytes, 20 ms apart) then per digit start packets every 20 ms (marker on the first, duration = elapsed*8) at 400 + (k+1)*2*tone ms and three end packets 1 ms apart; PT hard-coded 96 (the bundled scenario advertises 101); per-call sequence from 1200; a fresh SSRC per burst; digits outside 0-9*#A-D skipped; tone outside 50..=2000 → 200. sipr matches all of that with these divergences: (1) a stream sends from the port the SDP advertised — the allocated [rtpstream_*_port] when used, else the [media_port] form on that m= line; (2) DTMF sequence numbers are consecutive (SIPp’s warm-up increments two counters and skips every other number); (3) the SIPp sender’s post-send recv+memcmp (“RTP check”) and the -audiotolerance verdict/exit −3 are not implemented; (4) the packet grid is per stream (start + n*interval), not SIPp’s global wall-clock grid that fires every stream in the same millisecond; (5) -rtp_threadtasks is not needed (one scheduler thread) and not accepted.
  • IMS AKA AKAv1-MD5 (M16; verified in auth.cpp createAuthHeader (~l.158) / createAuthHeaderAKAv1MD5 (~l.600), milenage.c, message.cpp parseAuthenticationKeyword (~l.547) / getHexStringParam (~l.498), docs/scenarios/sipauth.rst): SIPp matches algorithm= by case-insensitive prefix (MD5-sess → MD5; AKAv2-MD5 is rejected: “must use MD5, AKAv1-MD5 or SHA-256”), decodes the nonce as base64(RAND(16) ‖ SQN⊕AK(6) ‖ AMF(2) ‖ MAC-A(8)) — extra server bytes ignored, unpadded base64 rejected, and an off-by-one that accepts 31 decoded bytes — computes f2345 then SQN = (SQN⊕AK)⊕AK, then XMAC = f1 with the configured aka_AMF (AUTN’s AMF is read and discarded), and on MAC ≠ XMAC calls ERROR(), which aborts the whole process. RES (8 raw bytes, never hex) is the digest password with the length passed explicitly so NUL bytes survive; CK/IK are computed and discarded; algorithm=AKAv1-MD5 is echoed. OP only, OPc derived as E_K(OP)⊕OP on every call; no OPc input. AUTS/resync is dead code (if (1/*…*/)) — SIPp never emits auts=. Keyword params: aka_K, aka_OP, aka_AMF as 0x hex (nibble pairs, no length validation, not NUL-terminated) or quoted/bare strings; aka_K absent → the first 16 bytes of the password (documented), aka_OP/aka_AMF absent → reads past a 1-byte buffer. No AKA CLI flags; no AKA test vectors in the tree. sipr matches the wire behavior (same nonce layout, RES-as-password, header shape, prefix matching, configured-AMF precedence) with these divergences: (1) a MAC mismatch, a malformed nonce, or missing keys fails the call with the reason in the error trace — the process continues; (2) hex values must be exactly 32/32/4 digits; (3) aka_OPc= is accepted directly; (4) when aka_AMF is absent, AUTN’s AMF is used (SIPp would read garbage); (5) the password-as-K fallback requires a 16+ byte password; (6) unpadded base64 is accepted. aka_* values are taken literally (SIPp renders them, so [field0] works there) — a follow-up.
  • Remote control (M17; verified in socket.cpp setup_ctrl_socket (~l.497), handle_ctrl_socket (~l.472), process_command/process_set/ process_trace/process_dump/process_reset (~l.134–330), process_key (~l.367), docs/controlling.rst): the control socket is UDP, created unconditionally (no flag disables it), bound to -cp once (failure fatal) or probing 8888..8947 (failure = warning, no socket) on every interface, and the chosen port is never printed. One datagram = one command: byte 0 is a hot key (1-9 screens, + - * / rate or — in -users mode — user count, stepped by rate-scale; p pause; q adds 10 to quitting, Q 20; ≥1 drains, ≥11 aborts, so q q = Q) and the rest is discarded, unless byte 0 is c: then the rest is a command line split on the first space only (tabs do not separate), verbs set|trace|dump|reset, numbers via strtol(…, 0) (hex/octal accepted) with strict trailing-garbage rejection, booleans true|false for set hide but on|off|true|false for trace. No reply is ever sent (recv() without a peer); errors go to the error log with the wordings reproduced in sipr-control::command. set rate/set limit are refused in users mode and set users in rate mode; set limit latches the cap so later set rate stops auto-sizing it. reset stats, set display rx, dump variables exist but are undocumented; the s key is dead code (screenf is never set). No HTTP anything. sipr matches the protocol, grammar, wordings, refusals and quit ladder, with these divergences: (1) default bind is loopback, -ci opts into more; (2) -cp 0 disables the socket; (3) the bound address is printed; (4) screen digits are ignored (sipr’s TUI cycles with s); (5) set display rx and dump variables warn that they are unsupported instead of silently succeeding (set display ooc|main works as SIPp’s since M33; trace logs|shortmessages on|off work since M41); (6) set limit in sipr simply sets -l (sipr never auto-sizes the cap from the rate). The HTTP API is a sipr addition with no SIPp counterpart.
  • RTP echo and the RTP check (M18; verified in sipp.cpp rtp_echo_thread (~l.650), setup_media_sockets (~l.1292), sipp_exit (~l.1146), rtpstream.cpp the post-send select/recv/compare block (~l.754) and the exit verdict (~l.1300), call.cpp E_AT_RTP_ECHO (~l.6253)): -rtp_echo binds global sockets on media_port and media_port+2 (probing in steps of two only when -rtp_echo is on — otherwise media_port never moves), each thread recvfroms with a 100 ms timeout and sendtos the bytes back unless the process-wide rtp_echo_state (default true, toggled by the <rtp_echo> action from any call) is false; counters rtp_pckts/rtp_bytes (1st stream) and rtp2_* (2nd). The RTP check lives inside the rtp_stream sender: after every successful send it selects + recvs on the same socket and memcmps the payload of what arrived with the payload just sent; a mismatch or nothing received counts as a failure; at thread exit each task with packets sent is judged failed/sent >= tolerance (-audiotolerance/-videotolerance, default 1.0) and a failure sets a bit in rtpresult, which makes sipp_exit return EXIT_RTPCHECK_FAILED (-3, shell 253) ahead of the call-failure code. Consequence: with the defaults, an rtp_stream run against a peer that does not echo exits -3. exec rtp_echo=startaudio|… is a different feature (per-call SRTP echo threads with process-global state). sipr matches the echo sockets, probing, counters, toggle action, compare semantics, and exit code, with these divergences: (1) a stream is judged only when -audiotolerance/-videotolerance was given; (2) <rtp_echo variable="v"/> (M44) reads v, where SIPp parses the attribute through handle_rhs and then calls getDoubleValue() rather than get_rhs() — its literal slot, which variable= never fills — so in SIPp that form always switches echoing off. Every other rhs action (jump, pauserestore, add, …) reads the variable; sipr makes this one consistent instead of copying the slip. exec rtp_echo= (the per-call SRTP echo) is M25 below.
  • Socket options and the local address (M44; verified in socket.cpp open_connections ~l.2372-2560 — bind_specific, the connect-probe, the bind_local || peripsocket re-resolve — sipp_customize_socket ~l.1735-1815, SIPpSocket::bind_to_device ~l.1645, call.cpp sendBuffer ~l.1627): SIPp keeps two addresses apart. The advertised one is -i; without -i it is gethostname() resolved when there is no remote host, else the source address a UDP socket connected to the remote reports (no packet is sent). The bound one is INADDR_ANY unless -i was given (which sets bind_specific), or -bind_local/-t ui asks for the advertised address. sipr now matches that split — before M44 it bound -i and rendered [local_ip] as 0.0.0.0 when -i was absent — with one divergence: for the no-remote case sipr runs the same connect-probe against the RFC 5737/3849 documentation prefixes (naming the default route’s address) rather than resolving gethostname(), which SIPp’s own comment calls “actually buggy”. -bind_local is therefore a no-op alongside -i, exactly as in SIPp. -buff_size sets SO_SNDBUF and SO_RCVBUF on every SIP socket (socket2, since std exposes neither and unsafe is forbidden) — but only when given: SIPp always applies its own default of 65536, which is below Linux’s default receive buffer and costs throughput at high rate. -bind_to_device is SO_BINDTODEVICE, which exists on Linux alone and needs CAP_NET_RAW; SIPp compiles the call out elsewhere and binds nothing silently, where sipr refuses the flag at argument parsing. -sendbuffer_warn governs a failed send of a default (non-scenario) message: despite its help text (“Produce warnings instead of errors”), SIPp’s code reads if (sendbuffer_warn) ERROR_NO(…) else WARNING_NO(…), so the flag makes the failure fatal and its default is the warning. sipr matches the code — the run ends with the flag, warns without it — and no longer ignores the failure outright.
  • AKA resynchronisation (M19): SIPp’s auth.cpp has an AUTS branch guarded by if (1/*sqn[5] > sqn_he[5]*/) (~l.676) whose real condition is commented out, so the always-taken branch stores one SQN byte into a write-only global and SIPp never emits auts=; had it run, it would have used the configured AMF instead of AMF* = 0 and an uninitialised SQN_MS. sipr implements the standard flow (RFC 3310 §3.2, TS 33.102 §6.3.3): with aka_sqn= the challenge’s SQN must be greater than SQN_MS, otherwise (or with aka_resync=1) the response carries auts="base64((SQN_MS ⊕ f5*(RAND)) ‖ f1*(K, RAND, SQN_MS, 0x0000))" and a digest over the empty password; the scenario then expects the server’s fresh 401 (<recv response="401" auth="true"/> again). Pure addition — no SIPp behavior to match.
  • Rate ramps (M20; verified in ratetask.cpp and the option table in sipp.cpp ~l.347-356): the ramp task is created only when -rate_increase is non-zero; it wakes every rate_increase_freq (-rate_interval, a SIPP_OPTION_TIME_SEC value; when 0 it takes -fd’s value, whose SIPp default is 60 s), does rate += rate_increase, and if rate_max is set and the new rate exceeds it, clamps to rate_max and — with rate_quit (default true; -no_rate_quit clears it) — quitting += 10 (drain). The task deletes itself once quitting >= 10. It calls set_rate, which users mode ignores. sipr matches this; the only difference is the default interval, since sipr’s -fd defaults to 1 s (recorded in M4).
  • Digest uri= and rendered auth parameters (M21; verified in call.cpp ~l.4149-4170 and message.cpp ~l.547-585): SIPp’s digest URI is literally "sip:" + (auth_uri ? auth_uri : remote_ip ":" remote_port) — no user part — so -auth_uri sip:x produces uri="sip:sip:x" (its own gtest expects that). Each [authentication] parameter is stored as a SendingMessage and rendered at send time, so keywords work inside them. sipr matched the wire form from M21 on (it previously signed sip:service@ip:port, which servers accepted since they verify against the header’s own uri=, but which differed on the wire) and renders the parameters the same way; the sip:sip: quirk is kept, with a startup warning.
  • hide / display (M22; verified in scenario.cpp ~l.1852 and screen.cpp ~l.282/493): hide is a boolean on every message command (xp_get_bool("hide", …)), display a free-text attribute read for every command even though sipp.dtd declares it only on nop; the scenario screen skips a hidden row only while the global do_hide (default true, set hide true|false) holds. sipr matches this. Screen keys: sipr maps 1/2/3 like SIPp and ignores 4..9 (no variables/TDM screens; secondary repartitions are not drawn separately).
  • SRTP (M23; verified in jlsrtp.cpppseudorandomFunction ~l.66, computePacketIV ~l.416, issueAuthenticationTag ~l.639, processOutgoingPacket ~l.2055 / processIncomingPacket ~l.2158, encodeMasterKeySalt ~l.2518; call.cpp keyword handlers ~l.2860-3300, extract_srtp_remote_info ~l.564; rtpstream.cpp echo ~l.2519): JLSRTP is AES-CM-128 or NULL cipher × HMAC-SHA1 80/32, master key 16 + salt 14 always, kdr 0 (key ids label || 0), no MKI, no replay list, no SRTCP, a fixed 12-byte header and a configured payload length. [cryptokeyparams…] generates a fresh RAND_bytes key on every render (negative offset = reuse); [cryptosuite…] selects the local suite; [ue…] renders UNENCRYPTED_SRTP and switches the local cipher to NULL while still advertising the AES suite. Received SDP: the first a=crypto: in the media section is PRIMARY, the second SECONDARY (at most two, sscanf-parsed); only the primary attribute is ever active — selectActiveCrypto is never called — and swapCrypto swaps the two when the answer’s primary suite is the offer’s secondary. The sender’s echo check decrypts the echo under the peer’s key and compares payloads; the per-call echo re-encrypts under its own key with the caller’s SSRC and sequence numbers. Bug: the auth tag is computed with the stale _ROC (updated after the tag is issued), so after sequence 65535 SIPp’s packets are rejected by conforming stacks (two SIPps still agree). sipr matches the suites, sizes, KDF, SDES encoding, keyword names and side effects, two-line parse, swap rule, and check semantics, with these divergences: (1) the tag uses the packet’s own estimated ROC (RFC 3711 §4.2) — interop with SIPp only diverges after a rollover; (2) master keys come from sipr’s seeded RNG (reproducible across runs with the same seed) rather than RAND_bytes; (3) an unsupported peer suite or undecodable key logs and falls back to plain RTP instead of rejectCall(); (4) payload length is taken from the datagram, not configured. Interop verified with sipp’s own -srtpcheck_debug log: it authenticates and decrypts sipr’s packets (processIncomingPacket() rc == 0). Also found: sipp’s per-call SRTP echo does sendto() with an explicit address on a socket it has connect()ed, which macOS rejects with EISCONN (errno 56) — on macOS a sipp SRTP echo server never answers (Linux allows it). Same family as the stream-client bind limitation.
  • [authentication] placement and injection (M24; verified in call.cpp ~l.4022-4045 E_Message_Injection and ~l.4149-4155): the keyword renders the entire header line including its name — Authorization: after a 401, Proxy-Authorization: after a 407 — which is why SIPp’s scenarios put [authentication …] alone on a line; and an injected field whose text contains [authentication is re-parsed as the keyword at send time (a temporary NUL at the first ]), which is the documented way to give each call its own credentials from a CSV. Only one [authentication] per message is allowed (fatal). sipr renders the full line like SIPp, re-parses injected fields the same way, and additionally accepts Authorization: [authentication …] (its pre-M24 spelling) by emitting only the value when the header name is already on the line; the one-per-message check is not enforced.
  • exec rtp_echo= (M25; verified in actions.cpp setRTPEchoActInfo, scenario.cpp ~l.1729, rtpstream.cpp ~l.2519-2665): the value is <verb>,<payload_type>,<payload_name>; verbs are matched by prefix (startaudio, updateaudio, stopaudio, startvideo, updatevideo, stopvideo), the payload type defaults to -rtp_payload and the name to SIPp’s table for 0/8/9/18 — an unknown codec is a parse-time error. The echo thread recvfroms on the call’s [rtpstream_*_port], and when the answer carried a=crypto it processIncomingPackets under the peer’s key, rebuilds the packet, setSSRCs the incoming SSRC, re-protects it under the local key with the incoming sequence number, and sendtos the packet’s source; an authentication failure is only logged and the bytes go out anyway. Both threads are process singletons — a second call’s startaudio re-keys the same thread. sipr matches the grammar, the defaults and validation, the port, the re-keying with the caller’s SSRC and sequence numbers, and the counters, with these divergences: (1) one echo per (call, kind), stopped with the call, instead of a shared singleton; (2) a packet failing authentication is dropped, not echoed; (3) update restarts the echo with the current negotiation (the port is released synchronously so nothing is lost but the packets in flight) rather than swapping keys in place. Verified against real sipp: its pfca_uac_apattern_crypto_simple.xml passes its own RTP check (exit 0) against sipr playing pfca_uas_audio_crypto_simple.xml unchanged.
  • ereg search_in="hdr" (M25; verified in call.cpp extractSubMessage): the haystack is the text after the first occurrence of the header string as a plain substring (header="CSeq:" gives 1 INVITE, leading space included; header="CSeq" gives : 1 INVITE) up to the end of that line; start_line="true" anchors the match to a line start; case_indep selects case-insensitive matching; and an absent header under check_it fails the call (E_AR_HDR_NOT_FOUND) regardless of the regexp. sipr matches this, matching the header string case-insensitively always (tolerance on the inbound side only).
  • <verifyauth> (M26; verified in scenario.cpp ~l.1572, call.cpp ~l.5946 E_AT_VERIFY_AUTH, auth.cpp verifyAuthHeader): username and password are message templates rendered at execution (keywords and [$var] allowed — SIPp’s documented recipe pulls them from a <lookup> line); the method is the received start line’s first token (a start line without a space verifies false — and a response’s “method” is SIP/2.0, so it never verifies); the credential is the first Authorization: header only (Proxy-Authorization: is never consulted); every digest parameter — realm, uri, nonce, cnonce, nc, qop, algorithm (default MD5; matched by prefix, so MD5-sess computes as plain MD5), response — is read from the client’s header, so only the shared secret is checked, never the server’s own nonce or realm; qop=auth-int hashes the request body; the RFC 2617 form with nc:cnonce:qop is selected by cnonce being present, not by qop; -auth_uri replaces the header’s uri= in the verifier’s HA2 too; a non-Digest scheme or an algorithm other than MD5/SHA-256 WARNINGs and yields false. The verdict is a boolean variable (test= branches on it). sipr matches all of this, with one tolerance: the response hex is compared case-insensitively (SIPp’s strcmp rejects uppercase hex). Verified both ways against real sipp: sipr’s <verifyauth> accepts and rejects sipp’s [authentication] header, and sipp’s accepts and rejects sipr’s.
  • _unexp.main, <jump variable=>, <pauserestore> (M27; verified in scenario.cpp ~l.1065 and call.cpp ~l.5449, ~l.1975, ~l.2315, ~l.6003): when a scenario has <label id="_unexp.main"/>, an unexpected in-call message does not fail the call — SIPp stores the current message index in _unexp.retaddr and the running pause’s absolute deadline (paused_until, a ms clock tick; 0 when not pausing) in _unexp.pausedaddr (each only if the scenario mentions the variable), cancels the pause, jumps to the label and re-queues the message for the handler’s <recv>. It does not count as unexpected in the stats. The jump is refused (normal unexpected handling) while _unexp.retaddr is non-zero — “already in a jump” — and nothing ever resets that variable, so one interruption per call unless the scenario zeroes it. The handler ends with <pauserestore variable="_unexp.pausedaddr"/> and <jump variable="_unexp.retaddr"/>: pauserestore sets paused_until to the operand ((int), absolute), and run() serves a pending paused_until before executing the current message and then next()s past it — so jumping back to an interrupted <pause> waits out the original deadline and skips the pause; jumping back to a <recv> (pausedaddr 0) simply re-arms it. <jump> itself is handle_rhs (value= or variable=, msg_index = (int)operand - 1); an out-of-range target is a fatal ERROR. sipr matches all of this (deadlines are ms since the run started, like SIPp’s clock tick), with two divergences: an out-of-range jump fails the call rather than the run, and the _unexp.main jump is tried before -aa auto-answering. Verified both ways against real sipp with an INFO during a 3 s pause: the BYE after the pause lands ~2.5 s after the INFO, not ~3 s.
  • <closecon/> (M27; verified in call.cpp ~l.5836 E_AT_CLOSE_CON, socket.cpp SIPpSocket::close ~l.1045, ~l.1155-1168, call.cpp ~l.1089, ~l.1481): it is call_socket->close(); call_socket = nullptr, and close() only decrements a reference count, freeing the socket at zero. Every call holds one reference on the socket it uses and the process holds another on the shared ones (main_socket, tcp_multiplex, each accepted server connection), so in the mono-socket modes (u1, t1, l1 — everything sipr offers) closecon never closes anything: it drops the call’s reference, after which a further <send> on that call has no socket (send_raw asserts unless -rsa). Only the per-call socket modes (un, tn, ln) actually close a connection. sipr accepts the action as a no-op — the same observable behavior — and the per-call socket modes remain unimplemented.
  • Per-call sockets -t un|tn|ln (M28; verified in sipp.cpp ~l.1660 (multisocket), call.cpp connect_socket_if_needed ~l.1419 and its call site at the top of createSendingMessage ~l.1737, E_Message_Local_Port ~l.2753, socket.cpp new_sipp_call_socket ~l.1340 and the call-creation branches ~l.1148-1185): multisocket only changes the client side. A call opens its own socket at its first send — “socket port must be known before string substitution” — bound to the local IP on a system-chosen port for UDP, or dialed to the target for TCP/TLS; [local_port] then renders that socket’s port (call_port) instead of -p, but only for clients (sendMode != MODE_SERVER). A server call keeps the socket the message arrived on: the main UDP socket under un, the accepted connection under tn/ln — so a per-call server is the mono server. Past -max_socket (default 50000) open call sockets, a new call is handed an existing one round-robin (next_socket), and a socket closes when the last call holding it ends (the reference count closecon decrements). A per-call TCP/TLS connect failure fails that call (E_FAILED_TCP_CONNECT) when reconnects are allowed, else the run. sipr matches all of this — pool sharing, [local_port], server-side behavior, closing with the last holder, and <closecon/> now really closing a per-call socket with the next send opening a fresh one — with these divergences: a connect failure always fails only the call (the -max_reconnect/-reconnect_* family, -rsa and -t ui landed later, in M29–M31 — see their notes below); each per-call socket has its own receive thread rather than SIPp’s single poll loop, so very large -max_socket values cost threads.
  • -rsa host[:port] (M29; verified in sipp.cpp ~l.1827 (parse, default port 5060), call_generation_task.cpp ~l.152 and socket.cpp ~l.1146-1230 (the call’s call_peer), socket.cpp ~l.2588 and call.cpp ~l.1489 (TCP dials it), call.cpp send_raw ~l.1570-1600 (call_remote_socket), E_Message_Remote_IP/Port ~l.2741): the remote sending address replaces where messages go, never what keywords say. A UAC’s calls send to it instead of the target (mono TCP/TLS dials it; per-call sockets connect to it) while [remote_ip]/[remote_port] — and so the digest uri= — keep the command-line target. A UAS’s calls send to it instead of the request’s source, and do so from a socket of their own (new_sipp_socket, connected for TCP/TLS, plain for UDP: responses leave from an ephemeral port, not -p), one shared main_remote_socket unless the transport is per-call. sipr matches all of this (the UAS-side socket is a call socket shared with cap 1 in mono modes), with one divergence: [remote_ip] on a UAS still renders the request’s source, where SIPp renders its remote_ip global (the command-line remote host, if any). Verified against real sipp in both roles, including sipr accepting the responses a -rsa sipp UAS sends from its extra socket.
  • TCP/TLS reconnection -max_reconnect/-reconnect_close/-reconnect_sleep (M30; verified in socket.cpp reconnect_allowed ~l.2257, reset_connection ~l.2265, the recv/send error paths ~l.1866-1880 and ~l.1940-1970, write_primitive ~l.2098, sipp.cpp ~l.551/~l.635 and docs/transport.rst): reset_number (default 0: no reconnection; -1 unlimited) is a process-wide budget. A clean close (read returns 0) only invalidate()s the socket and, with reset_close (default true), close_calls() — every call on it fails with E_FAILED_TCP_CLOSED (“Closing calls, because of TCP reset or close!”); nothing is re-dialed until a send needs the socket: writing to an invalid socket is an EPIPE, which queues a reset_connection — if no budget is left that is a fatal ERROR("Max number of reconnections reached") (exit -1), else the budget is spent, calls are closed again under reset_close, the main loop sleeps reset_sleep (default 1000 ms, blocking everything, usleep) and re-dials the same destination (“Socket required a reconnection.”); a failed re-dial closes the calls and leaves the socket invalid for the next attempt. An error close (EPIPE on send, a recv error) queues the reset immediately. The order matters: send_raw deletes the call whose write failed (E_FAILED_CANNOT_SEND_MSG) before the main loop resets the socket, so the call that discovers the dead connection always dies; -reconnect_close false only decides whether the other calls on the socket live on — and they do send again once someone has re-dialed it (the “resurrect the socket” comment). A write on a half-closed socket (FIN received, no RST yet) still succeeds, so an ACK queued behind the 200 that preceded the FIN goes out. sipr matches all of this for the mono client connection (t1/l1 as UAC) — the reader only reports the end of a connection and the engine forgets it when it processes that event, keeping the same ordering — including the synchronous sleep, the fatal exit 255, the counters failed_cannot_send / failed_tcp_closed / failed_tcp_connect, and the log lines, with these divergences: a server whose client resets the connection closes that client’s calls (under -reconnect_close) but never re-dials and never exits — SIPp’s UAS dies on a client’s RST with the default budget; a dropped per-call connection (tn/ln) fails its call under -reconnect_close or, without it, simply re-dials at the call’s next send outside the budget; and a connection failure at start-up stays a start-up error (SIPp decrements the budget and carries on without a socket). Verified against real sipp both ways by restarting the UAS between two calls.
  • Pacing start (verified in call_generation_task.cpp set_rate ~l.228, run ~l.90-110, wake ~l.60): SIPp anchors last_rate_change_time at start-up and opens elapsed × rate / rate_period − calls_since calls per run, so with -r 1 -rp 1000 the first call comes at t ≈ 1 s, not at t = 0 (-r 10 → 100 ms, -r 1 -rp 2000 → 2 s); each rate change (+/-, the control socket) re-anchors the clock and the count. sipr’s pacer credits rate × elapsed / rate_period per tick from the wall clock (a tick that arrives late credits the interval it covers, so a loaded host never silently runs below the requested rate) and produces the same first-call time and the same steady-state spacing; it does not re-anchor on a rate change (the fractional carry survives), a sub-interval difference.
  • -t ui / -ip_field / [server_ip] (M31; verified in sipp.cpp ~l.316 and ~l.1996 (peripfield default 0; -inf required; UDP only), ~l.1572 (ip_file = the first -inf), socket.cpp open_connections ~l.2466-2560 and call.cpp connect_socket_if_needed ~l.1430-1475, E_Message_Server_IP ~l.2768, docs/transport.rst): the main socket is bound to the IP in line 0’s -ip_field column (“on some machines it fails to bind to the self computed local IP”), and map_perip_fd maps IP → socket. A client call, at its first send, looks up the IP in its injection line and uses the mapped socket, creating one bound to ip:local_port if absent — persistent for the run, never closed (an unbindable IP is a fatal “Unable to bind UDP socket”). A server binds one extra socket per distinct listed IP at start-up and answers each request from the socket it arrived on. [server_ip] is getsockname on the call’s socket — the IP the call sends from — which is how a ui scenario writes correct Via/Contact lines ([local_ip] stays the -i / auto-detected address). sipr matches all of this — the per-IP sockets share the main socket’s port, calls attach to the receiving socket on the server, [server_ip] renders the socket IP, the errors are fatal at the same points — with one divergence: IPs must be literal (SIPp resolves host names in the column).
  • SCTP -t s1|sn (M32; verified in sipp.cpp ~l.209-243, socket.cpp ~l.806-850, ~l.888-905, ~l.1575-1590, ~l.1694-1775, ~l.2076): SIPp uses one-to-one SOCK_STREAM SCTP sockets, receives with sctp_recvmsgone SCTP message is one SIP message, no Content-Length framing — holds sends until SCTP_COMM_UP arrives as an SCTP_EVENTS notification, sets SCTP_NODELAY, and applies -heartbeat, -pathmaxret, -pmtu, -assocmaxret per peer address (SCTP_PEER_ADDR_PARAMS), -multihome via sctp_bindx, -gracefulclose as SHUTDOWN vs ABORT. A SIPp built without USE_SCTP errors “SCTP support is not enabled!”. sipr (cargo feature sctp, off by default; socket2) matches the socket type, the message-per-message model, s1/sn, the association-up gating (a blocking connect), reliability (no retransmissions), reconnection and the clear error without support, with these divergences: no SCTP_NODELAY, no notifications (a peer’s SHUTDOWN is seen as end-of-stream), and the six SCTP option flags are rejected rather than applied — socket2 cannot set SCTP-level socket options. Only Linux with the sctp module has a stack; macOS and Windows report “SCTP is not supported on this host”. Verified in Linux CI against a sipp built with USE_SCTP; the development host cannot run it.
  • Variable scopes and dynamic users (M35; verified in variables.cpp ~l.187-210, ~l.284-330, ~l.342-351, scenario.cpp ~l.718, ~l.756-790, sipp.cpp ~l.1449-1450, ~l.1738-1744, ~l.2123-2126, call.cpp ~l.1100-1115, ~l.1296, call_generation_task.cpp ~l.144-145, ~l.252-293, socket.cpp ~l.289-290): SIPp keeps three chained variable tables — the call’s own, userVarMap[userId] (one per user id, created at start-up for 1..N and by set_users growth, never freed) and one globalVariables — and <User variables="a,b"/> / <Global variables="c"/> allocate the names at the user / global level. Both levels are process-wide: every scenario (-sf, -oocsf, -rxsf) hangs its allocVars off the same userVariables, so one name is one slot across scenarios. A call with a user id parents its table on the user’s; a call without one (UAS, ooc, rx, plain rate mode) gets a fresh private table, so “user” variables are per call there. -set VAR VALUE seeds a global (fatal “Can not set the global variable VAR, because it does not exist.” when no scenario declared it — and, in SIPp, when it comes before -sf on the command line, since the scenario loads as its flag is parsed). dump variables prints the displayed scenario’s names per level (0 global, 1 user, 2 call) as WARNINGs. User ids: the free pool is filled 1..N and served from the back, so the first call is user N’s; a finished call’s id goes to the front of the pool (behind the still-free ones) — or, when more calls are live than set users now allows, to retiredUsers; the next growth takes retired ids back first (oldest first, with their variables), then creates fresh ones; a shrink touches no pool. sipr matches all of it — scopes resolved at compile time into a per-scenario layout over one user table per id and one global table, the private user layer for id-less calls, -set (checked after all scenarios load, so flag order does not matter), dump variables into the error trace with SIPp’s line format, and the exact pool order — with these deliberate divergences: (1) a name used before its <User>/<Global> declaration is already call-scoped in SIPp (AllocVariableTable::find checks the scenario’s own map first) and the declaration silently creates a second, differently scoped variable of the same name; sipr scopes every use as declared and warns (so --check fails) naming the earlier use. (2) A name one scenario declares <User> and another <Global> is a start-up error in sipr (SIPp: whichever level allocated first wins, silently). (3) Each scenario must declare its own scopes — a bare use of g in the rx scenario does not inherit the main scenario’s <Global> declaration (SIPp resolves it through the shared parent tables; sipr compiles each file on its own). (4) On a growth that needs fresh ids SIPp uses users + 1 counting from the current target, which after a shrink collides with ids still live (e.g. 3 → 1 → 3 while the calls of 2 and 3 are up hands id 2 out twice and replaces user 2’s table); sipr creates never-used ids instead (4, 5, …), so an id is live at most once and no table is lost. (5) A <Global> read but never set in a scenario is no diagnostic (its value may come from -set or the other scenario); a <User> one still is the usual error, since only the main scenario’s own actions could set it. Found on the way: (a) variable value semantics, fixed right after M35 (v0.24.0) — see the next note; (b) SIPp’s scheduler runs one message step per call per turn (call::run returns after a <nop>’s next()), sipr runs a call until its first blocking step — so two calls started in the same tick interleave their action steps differently (both <nop>s before either <send> in SIPp), which only shows through shared (global) variables. Settled as a permanent divergence in M44 below; the M35 interop test normalises it.
  • Variable value semantics (v0.24.0; verified in variables.cpp ~l.33-46 CCallVariable::isSet, call.cpp ~l.3968-3978 E_Message_Variable, ~l.1933 call::next, ~l.2241 condexec): a variable “is set” when it is a string or regexp capture (even empty), a non-zero double, or a true bool. [$var] writes nothing for an unset variable, a double as %lf (3.000000, -2.000000), a true bool as true; so a zero counter and a false <test> result render empty. test="var" on a message and condexec ask the same isSet. sipr now matches all of it (it used to print 3, false and 0, and treated a "0"/"false" string as not set). Not matched on purpose: SIPp’s getString() of a double is "" (the source calls it a bug), so strcmp/trim/ urlencode on a numeric variable see nothing there; sipr gives them the %lf text.
  • Manual transactions (M36; verified in scenario.cpp ~l.343-400 get_txn, ~l.878-931, ~l.588-602 validate_txn_usage; call.cpp ~l.1128, ~l.2110-2116, ~l.4431-4450 extract_transaction, ~l.4581-4587 matches_scenario, ~l.5395-5430, ~l.5502-5504; docs/scenarios/ownscenarios.rst): start_txn="n" on a sent request stores the top Via branch of the message as sent (up to ;, , or whitespace) under n; ack_txn="n" on a sent ACK records that ACK’s step; response_txn="n" on a recv response= matches only a response whose top Via branch equals the stored one — that check replaces both the first-step rule and the CSeq-method guard, and a request that names a transaction is left out of the CSeq-method list the other recvs use. Placement is strict, with these fatal texts: “An ACK message can not start a transaction!”, “The ack_txn attribute is valid only for ACK messages!”, “Responses can not start a transaction”, “Responses can not ACK a transaction”, “response_txn can only be used for received messages.” (on a send), “… for received responses.” (on recv request=); names obey the variable-name rules (“Variable names may not be empty / contain $ or , for start transaction | ack transaction | transaction response”); after parsing, validate_txn_usage: “Transaction n is never started!”, “… has no responses defined!”, “… is an INVITE transaction without an ACK!”, “… is a non-INVITE transaction with an ACK!”. A response for a named transaction that arrives once the call has moved past its recv (an “old transaction” reply, found by branch anywhere behind the window): a 1xx is ignored (“Ignoring provisional <transport> message for transaction n”, a trace line), a final one for an INVITE transaction gets the recorded ack_txn ACK re-rendered and sent again, and a repeat of the final response already taken (same message hash) is ignored with a WARNING (“Ignoring final <transport> message for transaction n (hash …)”); anything else is unexpected as usual. The accepted response’s hash is stored per transaction. [branch] itself knows nothing of transactions (z9hG4bK-pid-number-index): an ack_txn ACK carries its own branch. sipr matches all of it (the trace/WARNING lines go to the error trace; the hash is over the datagram bytes) with one addition: a <send> carrying both start_txn and ack_txn is an error (SIPp silently takes start_txn). Verified against real sipp both ways.
  • exec command= and <setdest> (M37; verified in scenario.cpp ~l.265-270 xp_get_string (“%s is missing the required ‘%s’ parameter.”), ~l.1596-1600, ~l.1637-1640; call.cpp ~l.6144-6178, ~l.5841-5935, ~l.2741; socket.cpp ~l.2588; docs/scenarios/actions.rst): exec command="…" renders the text like a message (keywords, [$var]) and runs it through system() in a double-forked grandchild — SIPp never waits for it and never sees its status, stdio is inherited (the >> file idiom; output lands on the curses screen too) and a system() failure is the grandchild’s WARNING “system call error for %s”. sipr matches the contract from one runner thread that spawns sh -c (cmd /C on Windows) with stdin closed and reaps its children as they exit — the engine thread never forks or blocks, no zombie accumulates under load — and prints the same warning to stderr on a spawn failure; commands still running at exit are left to finish, as SIPp’s grandchildren are. <setdest host= port= protocol=/>: all three required, all three rendered at run time; the port must be numeric (“Invalid port for setdest: %s”), the protocol udp|tcp|tls|sctp in either case (“Unknown transport for setdest: ‘%s’”) and the run’s own (“Can not switch protocols during setdest.”); TLS is refused (“Changing destinations is not supported for TLS.”), TCP/SCTP need the per-call modes -t tn|sn (“Changing destinations for TCP or SCTP requires multisocket mode.”) and a connection no other call shares (“Can not change destinations for a TCP/SCTP socket that has more than one user.”); the host goes through a blocking getaddrinfo (“Unknown host ‘%s’ for setdest”); UDP then retargets the call’s peer, TCP/SCTP close the call’s connection and dial the new peer, a failure logging “Unable to connect a TCP/SCTP/TLS socket” and spending one -max_reconnect credit (“Max number of reconnections reached” when none is left). [remote_ip]/[remote_port] keep rendering the global remote — setdest moves the traffic, not the keywords — and it overrides -rsa for that call (SIPp copies the sending address into remote_sockaddr at start-up and setdest overwrites the peer). sipr matches every check and its wording, with two deliberate differences: (1) each setdest error fails the call, not the run (the same choice as for “Jump statement out of range”), logged as call … failed: setdest: <SIPp text>; (2) an IP literal costs no I/O, and the first host name resolved logs a note that the lookup blocks the engine thread. IPv6 literals go bare, as SIPp documents (brackets read as a keyword). A call that has not sent yet (per-call modes) is simply retargeted; its first send dials the new peer. Verified against real sipp both ways. Found on the way: (a) ereg search_in="body" and search_in="var" variable="…" (scenario.cpp ~l.1396-1401, call.cpp ~l.5739-5760: the body, or the variable’s text, is the haystack) were missing and are now supported — SIPp’s setdest idiom needs var; case_indep, occurrence and check_it_inverse on ereg are still not. (b) [next_url] (call.cpp ~l.5570-5580): SIPp copies the Contact into next_req_url only for a recv with rrs="true"; otherwise the keyword falls back to the last received request’s URI, which a UAC never has — so the documented setdest example silently depends on rrs="true" on the recv response="200". sipr renders the last received Contact regardless of rrs (settled as permanent in M44 below: it is what the example intends). (c) [last_*] inside the actions of the recv that just matched (call.cpp ~l.5517 executeAction before ~l.5641 last_recv_msg = …): SIPp still names the previous received message — empty on a call’s first recv — so SIPp’s own <exec command="echo [last_From] >> from_list.log"/> example writes blank lines; sipr’s [last_*] name the message just received (settled as permanent in M44 below; the interop test accepts both). (d) The example’s unquoted From also breaks under any shell (<, > and ; are redirections and a command separator) — quote it.
  • The three divergences M37 and M35 left open, settled (M44). Each was “left as is” with no decision recorded; each is now permanent, with no --sipr-strict-sipp flag, and the first two are pinned by next_url_and_last_headers_follow_siprs_reading_not_sipps in tests/e2e.rs. (1) [next_url] without rrs (call.cpp ~l.5570-5580): SIPp fills next_req_url only on a recv carrying rrs="true" and otherwise falls back to the last received request’s URI, which a UAC never has — so its own documented setdest example depends on an rrs nobody writes, and the keyword renders empty without it. sipr renders the last received Contact either way. A scenario written SIPp’s way behaves identically in both; matching SIPp could only turn a working scenario into one that sends to an empty URI, which is no one’s test. (2) [last_*] inside the matching recv’s own actions (call.cpp ~l.5517 executeAction runs before ~l.5641 last_recv_msg = …): SIPp’s keywords still name the previous received message, empty on a call’s first recv — which is why SIPp’s own echo [last_From] example logs blank lines. Matching it would mean rendering [last_*] from the previous message while ereg in the same action list still searches the new one: two different “current messages” in one <action> block, for a behavior no scenario depends on deliberately. (3) Action-step interleaving (call.cpp call::run returns after a <nop>’s next()): SIPp’s scheduler runs one message step per call per turn, sipr runs a call until its first blocking step, so two calls started in the same tick interleave their <nop>s differently. Matching it means SIPp’s one-step-per-turn scheduler, the opposite of the runtime model in ARCHITECTURE §3, for a difference observable only through a <Global> variable two such calls both write.
  • sipp frees the socket it is still sending on when a TCP peer resets (found M44 while closing the interop gate; socket.cpp ~l.2151, the default: arm of write_primitive). After the far end closes a -t t1 connection, sipp 3.7.7 reaches a SIPpSocket whose ss_transport reads back as garbage — the same run logs “Unable to send UDP message” for a TCP run, and then dies on the fatal “Internal error, unknown transport type 1024” instead of reconnecting. Reproducible on macOS; the mirror direction (sipr’s UAC reconnecting to a sipp UAS) is unaffected, so this is sipp’s bookkeeping, not a protocol difference. Nothing sipr can do about it — closing the connection is what the test is for — so real_sipp_tcp_uac_reconnects_to_sipr skips visibly when sipp’s error log shows it, alongside the pre-existing “Unable to bind TCP socket” guard.
  • (append new findings above this line, with a pointer to where in the C++ you verified them)
  • Statistical pauses and <sample> (M38; verified in scenario.cpp ~l.1112 parse_distribution, ~l.965-985 the <pause> branch and its sanity_check, ~l.1522 sample; stat.cpp ~l.1530-1880 the CSample classes; call.cpp ~l.1956 the pause branch of call::run, ~l.6125 E_AT_ASSIGN_FROM_SAMPLE): the distribution is distribution="<kind>" and its parameters are separate attributes with SIPp’s names — fixed value; uniform min/max; normal and lognormal mean/stdev (a lognormal’s are the log-space parameters, GSL’s zeta/sigma); exponential mean; weibull lambda (scale) /k (shape); pareto k (shape) /x_m (minimum); gpareto shape/scale/location; gamma k (shape) /theta (scale); negbin p/n. There is no poisson. A missing parameter is SIPp’s “<Kind> distribution is missing the required ‘<name>’ parameter.”, an unknown kind “Unknown distribution: <kind>”. Old-style <pause> spellings are accepted too: min/max alone mean uniform, and a bare normal="…"/exponential="…"/lognormal/weibull/pareto/gamma flag names that kind. sipr’s earlier positional shorthand, distribution="uniform(200,3000)", still parses (values in SIPp’s attribute order) but is a sipr extension. sanity_check (default true) refuses a distribution whose 99th percentile exceeds INT_MAX ms, as SIPp does; a negative binomial has no percentile in SIPp and is not checked. Sampling (engine sample.rs, one draw per pause or action from the seeded generator): SIPp uses GSL and is built with these only under USE_GSL — a GSL-less sipp errors “The distribution ‘…’ is only available with GSL” for everything but fixed and uniform; sipr always has them. A pause sample below 1 (the negative tail of a normal) is no pause, as SIPp’s if (actualpause < 1) pause = 0. <sample> stores a double. Two deliberate divergences: (1) SIPp passes negbin’s n and p to GSL swapped (gsl_ran_negative_binomial(rng, n, p) against GSL’s (rng, p, n)), so with its own documented p="0.1" n="2" GSL gets a “probability” of 2 and the pauses are garbage — sipr draws the documented meaning, failures before n successes at probability p; (2) the generalized Pareto’s shape="0" divides by zero in SIPp — sipr uses the shape → 0 limit, location + Exp(scale). Poisson draws past a mean of 30 (inside negbin) use the normal approximation. The screen/--check label is SIPp’s textDescr: N(mean,stdev), LN(…), Exp(mean), Wb(lambda,k), P(k,x_m), P(shape,scale,location), G(k,theta), NB(p,n), min/max, or the fixed value.
  • Keyword parity, -key and -tdmmap (M39; verified in message.cpp ~l.50-120 the keyword table and ~l.236-372 SendingMessage’s dispatch, call.cpp createSendingMessage the E_Message_* arms, sipp.cpp SIPP_OPTION_KEY/SIPP_OPTION_TDMMAP, call.cpp ~l.113 the dynamic-id defaults and ~l.281 get_tdm_map_number, stat.cpp CStat::formatTime, time.cpp getmicroseconds): SIPp looks a bracketed name up in its keyword table before the last_<Header> copy, so [last_message] (the whole last received message, empty without one) and [last_cseq_number] (the CSeq number of the last received message, sscanf("%d"), 0 without one, plus a +N/-N suffix) are keywords, not header copies. [clock_tick] is milliseconds since the process started (SIPp’s clock_tick, a steady clock). [date] is gmtime in RFC 1123 form, Mon, 25 Oct 2021 07:20:55 GMT. [timestamp] is the log time: YYYY-MM-DD<TAB>HH:MM:SS.uuuuuu<TAB>ssssssssss.uuuuuu or, with -rfc3339, YYYY-MM-DDTHH:MM:SS.uuuuuu<offset>; SIPp renders it in local time — sipr in UTC (offset Z), the one deliberate divergence, so it needs no timezone dependency. [sipp_version] is the bare version number (SIPp drops its v; sipr renders its own, e.g. 0.28.0). [dynamic_id] is one counter for the run, starting at -dynamicStart (10000), stepping by -dynamicStep (4) at every render, wrapping back to the start once past -dynamicMax (18000). [remote_host] is the target host as typed on the command line, unresolved, port and IPv6 brackets stripped. [fill variable=N text="…"] repeats text (default X) to the variable’s numeric value in characters (negative or unset = nothing); [file name=…] inserts a file’s contents, the name itself a template ([$var], [fieldN]) — sipr reads each name once per run and caches it, and a missing file fails the call where SIPp aborts the process. -key KEYWORD VALUE defines [KEYWORD] as the literal VALUE (SIPp’s generic map, no keyword expansion inside the value); the compiler is told the names so they do not draw the unknown-keyword warning. -tdmmap {x-x'}{h}{y-y'}{z-z'} builds (x'-x+1)·(y'-y+1)·(z'-z+1) circuits; each outgoing call takes a free one at creation and [tdmmap] renders it as X.h.Y/Z (SIPp’s formula, Z cycling fastest); no free circuit is SIPp’s warning “Can’t create new outgoing call: all tdm_map circuits busy” and a failed call. [tdmmap] without -tdmmap is SIPp’s “[tdmmap] keyword without -tdmmap parameter on command line”, raised at start-up rather than at the first render. Divergences from SIPp’s circuit bookkeeping, deliberate: SIPp marks circuit n-1 busy and frees circuit n (an off-by-one that leaks one circuit per call) and picks a random start — sipr hands out the lowest free circuit and frees the same one. The screen/--check dump names each keyword; [file] shows as [file name=…].
  • Statistics files at parity (M40; verified in stat.cpp CStat::dumpData ~l.1230-1400 (the header and row), sRepartitionHeader/sRepartitionInfo, msToHHMMSS/msToHHMMSSus, computeRtt/dumpDataRtt, findRtd (RTD numbering by first mention), initRtt/setFileName (file names); logger.cpp print_count_file, print_error_codes_file, print_screens; reporttask.cpp (-fd stattask dumps the CSV, the counts and the error codes then resets the PL counters; -f screentask refreshes the screen and resets the PD counters); sipp.hpp the defaults): -trace_stat writes <scenario>_<pid>_.csv (or -stf) with SIPp’s header — StartTime, LastResetTime, CurrentTime (the formatTime form, -rfc3339 aware), ElapsedTime(P|C) as hh:mm:ss, TargetRate (the -users count in users mode), CallRate(P|C) with three decimals, the fixed counter pairs through WatchdogMinor, then ResponseTime<rtd>(P|C) and …StDev(P|C) per RTD as hh:mm:ss:uuuuuu, CallLength(P|C) and …StDev, then a repartition block per RTD and for the call length: a name column (empty in rows) plus Name_<b per bound and Name_>=last. Every field ends with the delimiter, so the header ends with one. (P) is since the last dump (SIPp resets its PL counters after each dump; sipr diffs against a per-dump baseline), (C) since the start. RTDs are numbered by first mention in the scenario and named as written (rtd="1"ResponseTime1, rtd="setup"ResponseTimesetup). Counters sipr has no source for are always 0: FailedCallRejected, FailedCmdNotSent, FailedRegexp*, FailedOutboundCongestion, FailedTimeoutOnSend, FailedTest*, FailedStrcmp*, Warnings, FatalErrors, Watchdog*; OutOfCallMsgs counts messages for no call, DeadCallMsgs those absorbed in timewait. SIPp’s generic counter= columns are not written (sipr’s counters are per call, M44). -fd defaults to 60 s as SIPp’s (it was 1 s) and the final row is written at exit regardless; -f (default 1 s) paces the screen snapshot and the -bg line. -trace_rtt writes <scenario>_<pid>_rtt.csv: Date_ms;response_time_ms;rtd_no, then per rtd= close the stop time and the response time — both in seconds despite the names, as SIPp divides by 1000 — and the RTD name, in C++ ostream default number form (six significant digits), buffered -rtt_freq (200) rows between flushes. -trace_counts writes <scenario>_<pid>_counts.csv: CurrentTime;ElapsedTime (the latter hh:mm:ss:uuuuuu) then per visible step <index>_<name>_Sent, _Retrans and, for a send with retrans=, _Timeout; for a recv _Recv, _Retrans, _Timeout, _Unexp; for a pause or timewait <index>_Pause_Sessions (times entered) and _Pause_Unexp; for a 3PCC sendCmd <index>_SendCmd, for a recvCmd <index>_RecvCmd and _RecvCmd_Timeout; nothing for a nop or label — <name> the method or status code, <index> the step’s position counting every step (SIPp’s message index counts pauses and nops too). SIPp’s _Lost columns appear only with -lost (M42). -trace_error_codes writes <scenario>_<pid>_error_codes.csv: per dump the time, the elapsed time and the status codes of the responses that failed a call as unexpected since the last dump, comma-terminated, newest first (SIPp pops them off the back). -trace_screen (or -screen_file) writes the scenario, statistics and repartition screens as text at exit, SIPp’s print_screens order; sipr’s screens are its own layout, not a copy of SIPp’s curses text. -periodic_rtd zeroes every repartition table (per RTD and call length) at each dump. -stat_delimiter applies to all four CSV files. Found on the way: an rtd= with no matching start_rtd= measures from the call’s creation — SIPp initialises every RTD’s start time in call::init, and its own default UAC has only rtd="true" on the 200 — where sipr used to record nothing (so its ResponseTime1 stayed 0 for the embedded UAC); repeat_rtd then restarts that clock at the recording step.
  • Message and error logs at parity (M41; verified in logger.cpp _trace/rotatef/_screen_error/LOG_MSG, socket.cpp the TRACE_MSG/TRACE_SHORTMSG calls in process_message (receive) and write_primitive (send), call.cpp callDebug/_callDebug, abort (the dump) and terminate (new deadcall), deadcall.cpp, sipp.cpp the SIPP_OPTION_LFNAME/LFOVERWRITE cases and the startup rotate_*f calls, sipp.hpp DEFAULT_DEADCALL_WAIT): every log is <scenario>_<pid>_<kind>.logmessages, errors, logs, shortmessages, calldebug, screens — or the -<kind>_file name; -<kind>_overwrite false appends instead of truncating (SIPp also sets fixedname there, which empties the name when no -<kind>_file was given — a SIPp bug sipr does not copy). -trace_msg frames are SIPp’s: a 47-dash rule and the time (always the RFC 3339 form there), then <TRANSPORT> message sent|received [<bytes>] bytes:, a blank line and the message — no peer address (sipr used to print one). -trace_err starts with The following events occurred: and each line is <time>: <text> (-rfc3339 aware); <warning> actions land there. <log> actions go to -trace_logs (LOG_MSG, one line each, keyword-expanded) and nowhere without it. -trace_shortmsg writes per message <time>\tS|R\t<Call-ID>\tCSeq:<value>\t<start line>; SIPp’s receive side always uses the default (tab-separated) time form while its send side honours -rfc3339, so a line has seven tab-separated columns except an RFC 3339 send line’s five — matched, quirk included. -trace_calldebug buffers per call SIPp’s callDebug entries (<time> <text>): Starting call, Sending <TRANSPORT> message for call <id> (index <n>, hash <h>) with the message, Processing <n> byte incoming message for call-ID <id> (hash <h>) with the message, Unexpected … message received, and on abort Aborting call <id> (index <n>).; an aborted call’s buffer is written under Call debugging information for call <id>: and its rule — a successful call writes nothing, as SIPp’s abort is the only dumper. The hash is sipr’s, not SIPp’s. Rotation (_trace): -ringbuffer_size bytes written rotates the file — with -ringbuffer_files N the current file is renamed <scenario>_<pid>_<kind>_<start seconds>.log (.<n>.log when the same second repeats) and the oldest beyond N is deleted; without it the file is truncated in place — and -max_log_size closes the file for good. Rotation applies to the messages, errors, logs, shortmessages and calldebug files, not the statistics CSVs, as in SIPp. -deadcall_wait (default 33 s; 0 disables): a finished call’s Call-ID is remembered with its reason — successful, or aborted at index <n> — and a late message for it is not out-of-call: SIPp’s deadcall counts DeadCallMsgs, warns Dead call <id> (<reason>), received '<msg>', writes Dead call <id> received a <TRANSPORT> message: to the message trace and refreshes the expiry; sipr does the same and sweeps expired entries once a second (a message absorbed by a call in <timewait> also counts as DeadCallMsgs, M40). -trace_timeout is accepted and does nothing: SIPp 3.7’s implementation is commented out.
  • Timer and behavior knobs (M42; verified in call.cpp ~l.2252-2320 the retransmission block, ~l.2160-2205 the receive timeout, ~l.2445-2520 process_unexpected, ~l.2534-2600 abortCall, ~l.6665-6830 checkAutomaticResponseMode/automaticResponseMode, ~l.1242 matches_cseq, ~l.1527 lost, ~l.4628 the -pause_msg_ign check, default_message_strings ~l.2335; sipp.cpp SIPP_OPTION_DEFAULTS, timeout_alarm, the sleeptime/nostdin setup; socket.cpp get_trimmed_call_id; call.hpp/sipp.hpp the defaults): SIPp retransmits an INVITE up to -max_invite_retrans (5) times and any other message up to -max_non_invite_retrans (9), -max_retrans being a ceiling on both; the interval doubles from the send’s retrans= and is capped at T2 (4 s) only for non-INVITE transactions — an INVITE keeps doubling (500, 1000, 2000, 4000, 8000 ms). sipr used one cap of 5 and capped everything at T2; both now match. -recv_timeout (default unit ms) is the timeout of every recv without its own timeout=; the timeout fires the same way (ontimeout label or a failed call). -timeout_error makes reaching -timeout an error — SIPp’s <scenario> timed out after '<s>' seconds, exit 255. -lost <percent> is the loss of every send and every recv whose own lost= is absent; a received message that “loses” is dropped after matching, with a message lost (recv) call-debug entry. -pause_msg_ign drops whatever arrives while the call is in a pause before anything is counted. -default_behaviors is SIPp’s list (all, none, bye, abortunexp, pingreply, cseq; -x removes, +x/x adds, left to right from none; -nd = none): abortunexp off counts an unexpected message and continues the call (SIPp’s “Continuing call on unexpected message”); bye on ends an aborted client-side call the way SIPp’s abortCall does — an unestablished INVITE answered 4xx or worse gets an ACK, one answered 200 gets ACK then BYE, one answered provisionally gets a CANCEL, one never answered gets nothing, any other call that received something gets a BYE — using SIPp’s own built-in templates (compiled with sipr’s template engine, hence the new [last_Request_URI] keyword: the URI in <…> of the last received To); bye also answers an unexpected BYE or CANCEL with a 200 before aborting; pingreply answers an unexpected PING request with a 200 and drops the call, neither successful nor failed, as SIPp does; cseq makes an ACK match only when its CSeq number is the last received INVITE’s (SIPp matches_cseq). A server-side or secondary call never sends abort messages (SIPp creationMode != MODE_SERVER). Call-IDs: SIPp keys a call by the text after the first /// (its 3PCC twin marker) unless -callid_slash_ign; sipr used to keep the whole value and now trims it the same way. -sleep <s> waits before the run, -nostdin disables the keyboard watcher. -send_timeout and -timer_resol are accepted with a warning: sipr has no send queue that could time out and its timers are exact, not polled.

Runtime control: the control socket and the HTTP API

sipr can be steered while it runs, two ways. Both drive the same commands inside the engine’s event loop; neither touches call state from outside it.

1. The control socket (-cp, -ci) — SIPp’s protocol

SIPp’s remote control is a UDP port that takes one command per datagram (socket.cpp handle_ctrl_socket). sipr implements it as is, so existing scripts keep working:

echo -n 'p' | nc -u -w0 127.0.0.1 8888          # hot key: pause/resume
echo -n 'cset rate 50' | nc -u -w0 127.0.0.1 8888  # command: rate to 50 cps
  • Byte 0 decides. Anything but c is a hot key and the rest of the datagram is ignored: + - * / step the rate (or the user count in -users mode) by rate-scale, p toggles pause, q drains and a second q aborts, Q aborts, 1/2/3 switch the TUI to the scenario / statistics / repartition screen (4..9 are ignored).

  • c + a command line, split on the first space (tabs do not separate), exactly SIPp’s grammar and warning texts:

    CommandEffect
    set rate Ncall rate (rate mode only)
    set rate-scale Nstep multiplier for the rate keys (default 1)
    set users Nuser count (-users mode only)
    set limit Nconcurrent-call cap (rate mode only)
    set display main|ooc|rxevery screen (counters, statistics, repartitions, scenario page) shows the main / out-of-call / receive scenario (ooc needs -oocsf/-oocsn, rx needs -rxsf/-rxsn; SIPp’s display_scenario)
    set hide true|falseskip hide="true" steps on the scenario screen (default true)
    trace messages|error on|offopen/close the trace file at runtime (SIPp’s file naming)
    trace logs|shortmessages on|offnot supported (warning)
    dump tasksone line per active call into the error trace
    reset statszero the cumulative counters and histograms
  • Fire-and-forget, like SIPp: there is never a reply. Malformed or refused commands print SIPp’s warning on stderr and into the error trace.

  • Binding: -cp PORT is tried once and failure is fatal; without it, ports 8888..8947 are probed and running without a socket is only a warning (SIPp’s rules). -cp 0 disables the socket (sipr addition). The chosen address is printed at startup — SIPp never says which port it got.

  • Deliberate divergence: the default bind address is 127.0.0.1, not every interface. The socket can stop the run with no authentication; -ci 0.0.0.0 opts into SIPp’s behavior.

2. The HTTP API (--sipr-http) — sipr’s addition

sipr -sn uac -r 10 -m 100000 --sipr-http 8080 127.0.0.1:5060
curl -s localhost:8080/stats | jq .created
curl -s -XPOST localhost:8080/control -d '{"rate": 25, "paused": false}'
curl -s -XPOST localhost:8080/quit -d '{"force": false}'

--sipr-http PORT binds loopback; --sipr-http HOST:PORT binds elsewhere and then requires --sipr-http-token TOKEN, presented as Authorization: Bearer TOKEN or ?token=TOKEN. HTTP/1.1, one request per connection, JSON in and out. Every error is {"error":"..."} with a 4xx/5xx status; control errors carry SIPp’s own warning text.

MethodPathBodyResponse
GET/health{"status":"ok","version":"0.5.0"} (never needs the token)
GET/statsthe statistics snapshot (below), refreshed about once a second
GET/controlthe control state (below)
POST/controlany of rate, rate_scale, paused, users, limitthe control state after applying them, in that order; 400 with SIPp’s warning on the first refusal
POST/quit{"force":false} (default) drains, true aborts202 + control state
POST/command{"command":"set rate 10"} — any control-socket command linethe control state, or 400 + warning
GET/scenario{"name","role","steps":[...]} (the --check dump)

Control state:

{"rate":10,"rate_scale":1,"paused":false,"users":null,"limit":null,"quitting":"no"}

quitting is no, soft (draining), or hard.

Statistics snapshot — SIPp’s counter names, durations in _ms:

{"scenario":"uac","role":"UAC","elapsed_ms":12034,"live":3,
 "rate_target":10,"rate_period_cps":9.8,"rate_cumulative_cps":9.9,"paused":false,"hide":true,
 "display":"main","mixed":false,
 "created":120,"successful":117,"failed":0,
 "failed_unexpected":0,"failed_timeout":0,"failed_retrans":0,"failed_other":0,
 "messages_sent":360,"messages_matched":351,"retrans_sent":0,"retrans_recv":0,
 "auto_answered":0,"unexpected":0,"garbage":0,
 "rtp_streams_started":0,"rtp_packets_sent":0,"rtp_bytes_sent":0,"rtp_bytes_received":0,
 "rtp_echo_packets":0,"rtp_echo2_packets":0,"rtp_check_ok":0,"rtp_check_failed":0,
 "rtd":[{"name":"1","count":117,"mean_ms":12.5,"stddev_ms":3.1,"p99_ms":22,"max_ms":40}],
 "call_length":{"count":117,"mean_ms":3010.2,"max_ms":3050},
 "response_time_repartition":[{"label":"<10","count":40}],
 "call_length_repartition":[],
 "steps":[{"label":"send INVITE","hidden":false,"sent":120,"recv":0,"retrans":0,"timeouts":0,"unexpected":0}]}

The snapshot is the same object the TUI renders and the -bg stat line summarizes, so the three never disagree.

What the API does not do (yet)

No streaming/WebSocket (poll /stats), no scenario replacement, no per-call detail, no Prometheus endpoint. The API is a control plane for one run; orchestration of many runs belongs in whatever launches them.

Glossary — SIP and SIPp terms

Read this before touching engine or scenario code if you’re not fluent in SIP. Confusing these terms causes real bugs (especially transaction vs dialog vs call).

SIP protocol

  • UAC / UAS — User Agent Client (sends a request) / Server (answers it). Roles are per transaction: the callee becomes a UAC when it sends BYE. In sipp/sipr CLI terms, uac = caller scenario, uas = callee scenario.
  • Transaction — one request + its responses (+ retransmissions). INVITE transactions end at a final response (ACK for non-2xx is part of it; ACK for 2xx is a separate transaction per RFC 3261). Identified by the Via branch.
  • Dialog — a peer-to-peer relationship spanning transactions, identified by Call-ID + local tag + remote tag. Established by INVITE/2xx (or early via 1xx with tag). Holds CSeq counters both directions, route set, remote target.
  • Call — in sipp/sipr: one execution of the scenario (one line in the call table), which usually maps to one dialog but needn’t (e.g. REGISTER scenarios have no dialog).
  • Via / branch — routing breadcrumb header; branch (must start with magic cookie z9hG4bK) identifies the transaction.
  • CSeq — per-dialog, per-direction request sequence number + method.
  • Tags — random tokens in From/To identifying dialog ends. UAS adds the To tag; [peer_tag_param] in SIPp exposes the remote one.
  • Record-Route / Route / route set — proxies inserting themselves into the dialog path; rrs="true" on a recv captures them, [routes] replays them.
  • T1 / T2 — RFC 3261 retransmission timers for unreliable transport: T1=500ms initial, doubling per retransmit, capped at T2=4s.
  • REGISTER / OPTIONS / INFO / UPDATE / NOTIFY — non-INVITE methods that show up in test scenarios; -aa auto-answers the in-dialog ones with 200.
  • Digest auth — 401 (UAS) / 407 (proxy) challenge → request re-sent with Authorization/Proxy-Authorization. RFC 2617 (MD5) / RFC 7616 (SHA-256, qop).
  • SDP — session description carried as the body of INVITE/200; for signaling-only v1 it is opaque template bytes (only [len] cares).
  • 3PCC — third-party call control: an external controller coordinates two scenario halves; SIPp implements it as sendCmd/recvCmd between instances.
  • B2BUA — back-to-back user agent (two dialogs bridged); relevant only as the kind of DUT sipr often tests.
  • DUT / SUT — device/system under test.

SIPp-specific

  • Scenario — the XML file: an ordered list of send/recv/pause/nop steps the call must follow. Compiled in sipr to a flat step IR (“message index” order).
  • Keyword[call_id]-style placeholder substituted into message templates at send time.
  • Action — per-step operation (<action> on recv/nop): regex capture (ereg), variable math, branching (test+next), logging, exec.
  • Call variables — per-call named values ([$1], [$name]) written by actions, read by keywords/conditions.
  • Injection file (-inf) — CSV whose rows feed [field0..N] keywords; sequential/random/user modes. (v1.x)
  • Open loop vs closed loop — SIPp default is open loop: -r new calls per period regardless of completions (models real traffic, can overload DUT). -users mode is closed loop: fixed population, new call only when one ends.
  • cps — calls per second (the -r rate).
  • RTD (response time duration) — stopwatch between start_rtd and rtd markers in the scenario; reported in percentiles/histograms.
  • Repartition — SIPp’s term for histogram bucket tables (ResponseTimeRepartition, CallLengthRepartition).
  • Unexpected message — inbound that matches no pending recv (incl. optional window); increments counters and by default kills the call.
  • Retrans — either the protocol-level UDP retransmission (timer-driven) or the retrans attribute overriding its base interval. Context matters.
  • Timewait — post-scenario linger absorbing late retransmissions before the call slot is freed (SIPp timewait element / deadcall handling).
  • OOC (out-of-call) — messages not attributable to any live call.
  • PCAP play / rtp_stream — media features (later; out of v1).

Performance baselines

Loopback cps runs of the embedded uac scenario (-d 10, release build) against a scripted Python UAS (8 MB rcvbuf). Wall time ≈ m/r + drain. Update this file when the number moves materially (docs/TESTING.md §5).

DateMachineCommandResult
2026-08-16cloud sandbox (Linux, shared vCPUs)-r 500 -m 50005000/5000 ok, 0 retrans, 10.1s
2026-08-16cloud sandbox (Linux, shared vCPUs)-r 2000 -m 2000020000/20000 ok, 0 retrans, 10.1s (~2000 cps sustained, 60k msgs)

Notes: at M3 the far end (single-threaded Python) is the likely bottleneck above ~2000 cps, not sipr; a sipr-UAS peer (M4) will let us probe higher.

M4: sipr ↔ sipr self-test (both ends real)

DateMachineCommandResult
2026-08-16cloud sandbox (Linux, shared vCPUs)uac -r 2000 -m 20000 -d 10 vs uasboth sides 20000/20000 ok, 0 retrans, 10.1s
2026-08-16cloud sandbox (Linux, shared vCPUs)uac -r 5000 -m 50000 -d 10 vs uasboth sides 50000/50000 ok, ~0.45% retrans covering kernel drops, 10.3s (~5000 cps sustained, 150k msgs)

Architecture

Authoritative reference for how sipr is structured. PLAN.md §3 is the summary; this file is the working detail. Update it when structure changes.

1. Workspace layout

sipr/
├── Cargo.toml            # workspace root
├── crates/
│   ├── sipr-scenario/    # XML → Scenario IR; keyword tokenizer; actions; (later) infile
│   ├── sipr-net/         # UDP transport, socket mgmt, timer service, retransmit schedule
│   ├── sipr-engine/      # call state machine, call table, pacer, dialog bookkeeping
│   ├── sipr-auth/        # digest auth (RFC 2617/7616) for [authentication]
│   ├── sipr-stats/       # counters, RTD histograms, repartitions, CSV export
│   ├── sipr-media/       # pcap reader, SDP endpoint scan, RTP replay scheduler (M14)
│   ├── sipr-control/     # SIPp's UDP control socket + the HTTP/JSON API (M17)
│   └── sipr-tui/         # terminal screens, key handling
└── src/main.rs           # bin: SIPp-style CLI → assemble and run

Dependency direction (must stay acyclic): sipr-tuisipr-stats → (nothing internal); sipr-enginesipr-scenario, sipr-net, sipr-auth, sipr-stats, sipr-media, sipr-control; sipr-controlsipr-stats; sipr-mediasipr-auth (AES/HMAC/KDF for SRTP); sipr-scenario, sipr-net, sipr-auth depend on no internal crate. The control front ends (UDP socket, HTTP server) are threads that only send ControlRequests into the engine’s channel and read the shared once-a-second snapshot — the same rule as the TUI: nothing outside the loop touches a call. The exec command= runner (sipr-engine/src/exec.rs, started on the first command) is a thread that receives rendered command strings over a channel, spawns each through a shell and reaps the children it started; the engine thread never forks, waits or blocks on an external process.

The media thread (sipr-media::replay) follows the same rule as every other thread: it owns its sockets, receives owned stream specs over a channel, and reports back with events — it never touches engine state. The binary depends on all. rsip types may appear in sipr-net and sipr-engine APIs; scenario IR types must not leak rsip types (templates are raw bytes + slots).

2. Runtime model

As built (M2): std-first, no tokio. SIPp’s own architecture is a single event loop — so the runtime is dedicated std threads (recv loops, timer thread, the pacer) all sending into ONE mpsc channel drained by the engine’s event loop thread. The only external dependency is rustls (M13, TLS transport); SIP over UDP/TCP is pure std. The diagram below still describes the moving parts accurately; read “task” as “thread”. The pure logic (TimerQueue, RetransSchedule, Inbound parsing, call state machines) is driver-agnostic: if tokio joins the workspace later, only the thin thread drivers in sipr-net change. Multi-core scaling comes from sharding engine loops (N loops × 1 socket each with SO_REUSEPORT), not from a work-stealing runtime.

The moving parts:

                    ┌────────────┐   rate ticks    ┌──────────────┐
                    │   Pacer    │ ───────────────▶│              │
                    └────────────┘  new UAC calls  │              │
┌──────────┐ datagrams ┌─────────┐  route by       │  Call table  │
│ UDP recv │──────────▶│ Router  │  Call-ID        │ (sharded map │
│  loop(s) │           │(parse + │ ───────────────▶│  call-id →   │
└──────────┘           │ match)  │  new UAS calls  │  CallState)  │
                       └─────────┘                 └──────┬───────┘
┌──────────┐ fired timers    ▲                            │ events drive
│  Timer   │─────────────────┘                            ▼ step execution
│ service  │◀───── arm/cancel ────────────── per-call state machines
└──────────┘                                              │
                                                          ▼ sends
                                                   UDP send path
  • Recv loop: owns the socket(s), parses inbound with rsip, routes by Call-ID. Unmatched initial requests spawn UAS calls (server mode) or count OutOfCall.
  • Call table: sharded concurrent map, Call-ID → call state. Calls are state machine objects, not spawned tasks — events (message-in, timer-fired, pause-done) are delivered to worker tasks that advance the machine. Target: 100k concurrent calls without per-call task overhead.
  • Pacer: open-loop arrival. Every rate_period (default 1s) start rate new calls, smoothed within the period; respects -l (concurrent cap → calls beyond it are not queued, they’re simply not started, matching SIPp), -m (total).
  • Timer service: single hashed-wheel/DelayQueue task arming retransmission timers, recv timeouts, pauses, timewait, watchdog. Timer resolution 10ms is fine; SIPp uses the same order.
  • Stats: per-worker atomic/thread-local counters, aggregated each second into an immutable StatsSnapshot (Arc). TUI and CSV writer are pure readers of snapshots. Nothing on the hot path takes the stats lock.
  • Two scenarios, one engine (M33, M34): -oocsf/-oocsn load an out-of-call scenario and -rxsf/-rxsn a mixed-mode receive scenario next to the main one — at most one of the two, held as the engine’s SecondaryScenario with a SecondaryKind. Both scenarios are compiled independently and each owns its stat set, CSeq guard and step labels; a call carries which one it runs (CallState::secondary) and every per-call path — recv-window scan, step execution, actions, timers, stats routing — resolves the scenario through that flag. Global concerns (pacer, -l/-users, end of run, the auto-answered counter, CSV dump, exit code, twin socket, transports) stay on the main scenario, as SIPp’s open_calls/main_scenario do. The router spawns a secondary call for a request of no known call in client mode only (an ooc call carries no injection line and counts as auto-answered; an rx call draws lines like a UAS call’s); unmapped responses are counted and dropped. -rxinf files join the one injection table after the -inf ones. The snapshot the TUI and HTTP API read follows set display main|ooc|rx wholesale (Snapshot::display), as SIPp’s screens read display_scenario->stats.

3. Hot path rules (enforced in review)

The per-message path is: event → look up call → advance state machine → fill template slots → sendto. On this path:

  1. No heap allocation except the outbound buffer fill (reuse per-call buffers).
  2. Message templates are pre-tokenized at scenario load into Vec<Span> = Literal(&'static [u8]) | Keyword(KeywordId). Per-send work is slot substitution only. Never scan/parse template strings per send.
  3. No regex execution unless a scenario step explicitly uses ereg (that cost is the user’s choice); regexes are compiled once at scenario load.
  4. No lock held across .await; prefer message passing to shared mutation.
  5. Inbound parse uses rsip’s lazy header parsing — extract only the headers the current recv step and dialog bookkeeping need (Call-ID, CSeq, Via branch, To/From tags, and rrs-requested Record-Route/Contact).

4. Call state machine essentials

Per-call state: scenario index (position in the flat Vec<Step> IR), variable store, dialog state (local/remote tag, CSeq counters both directions, route set, remote target), last-received message per [last_*], RTD start timestamps, retransmission context for the in-flight send, per-step counters, and one slot per manual transaction of the scenario (start_txn/ack_txn/ response_txn: the sent request’s Via branch, the ACK step’s index and the accepted response’s hash — empty and free when the scenario names none).

Variables live in three layers, SIPp’s table chain (sipr-engine/src/vars.rs): the call’s own Vec<Value>, its user’s table (one per user id, created when the id is first handed out and kept for the run — <User variables>), and the one global table (<Global variables>). Names resolve to ids at compile time and each id’s scope is fixed then; at engine start a VarSpace unions the user and global names of every loaded scenario and gives each scenario a VarLayout (id → layer + index), so a read or write is one match and one index, never a search. The shared layers are Rc<RefCell<…>> (single engine thread, a borrow never outlives one expression); a call with no user id gets a private user layer, as in SIPp.

Execution loop for a call: advance through IR steps until blocked (waiting on recv/pause/timer), then park. optional="true" recv steps form a window: an inbound message is matched against the current non-optional recv plus any preceding optional ones, in SIPp’s documented order. On unexpected message: count it, apply SIPp semantics (abort call unless the message matches an optional or auto-answer list). next/test/chance and label implement jumps by IR index; validate all label references at compile time.

UDP retransmission (RFC 3261 §17.1.1 shape, but scenario-driven like SIPp): sends retransmit on T1=500ms doubling to T2=4s cap until the step’s expected response arrives; retrans="N" on a send overrides the base interval; -max_retrans-equivalent caps attempts. INVITE vs non-INVITE differences and ACK/2xx handling follow SIPp behavior, not full RFC state machines — the C++ (call.cpp) is the oracle when in doubt.

5. Shutdown semantics

Soft quit (q, SIGINT once, or -m reached): stop the pacer, let active calls finish, run timewait, exit with SIPp-compatible exit codes: 0 all calls passed, 1 some failed, 97 aborted by user, 99 aborted on error (check exact codes vs SIPp docs before M3 completion). Hard quit (Q, second SIGINT): drop everything.

6. Where things will NOT go

  • No global mutable state; SIPp’s C++ is a museum of it and it’s the main reason call.cpp is 300KB. Configuration is built once and passed as Arc<Config>.
  • No protocol logic in sipr-tui or sipr-stats.
  • No scenario semantics in sipr-net (it moves bytes and fires timers; it does not know what an INVITE is beyond what routing requires).

Conventions

Rust

  • Edition 2024, MSRV = 1.85 (rust-version in the root Cargo.toml). Unsafe code is forbidden workspace-wide ([workspace.lints.rust] unsafe_code = "forbid"; every crate sets [lints] workspace = true).
  • rustfmt with default settings — no local style debates. cargo clippy --workspace --all-targets -- -D warnings must pass; #[allow] requires an adjacent comment justifying it.
  • Public items in library crates get doc comments. Doc examples must compile (cargo test runs them).

Errors

  • Library crates: typed errors with thiserror, one error enum per crate (ScenarioError, NetError, …). Include position context in scenario errors (file, line, element) — scenario authors debug with these.
  • Binary: anyhow at the top level; user-facing messages must be actionable (“unknown attribute ‘retrnas’ on <send> at uac.xml:41 — did you mean ‘retrans’?”), not debug dumps.
  • No unwrap()/expect()/panic! in library code outside tests and truly unreachable states (unreachable! with a comment). In tests, unwrap freely.

Logging and tracing

  • tracing everywhere; no println!/eprintln! outside the TUI crate and CLI output paths. Levels: error = call/tool integrity, warn = compat surprises (unknown keyword, unexpected message), info = lifecycle, debug = per-call, trace = per-message. Per-message logging must be zero-cost when disabled (guard with tracing::enabled! where formatting is expensive).
  • SIPp-style file outputs (-trace_msg, -trace_err, -trace_stat) are product features, implemented as dedicated writers — not routed through tracing.

Async

  • Tokio only. No blocking calls on the runtime (file I/O for traces goes through a dedicated writer task or spawn_blocking).
  • Prefer channels + ownership over Mutex. If a lock is unavoidable it must never be held across .await (clippy’s await_holding_lock is promoted to deny).

Dependencies

Sanctioned: tokio, tokio-util, rsip, quick-xml, ratatui, crossterm, regex, hdrhistogram, thiserror, anyhow, tracing, tracing-subscriber, rand, md-5, sha2, dashmap, arc-swap, bytes, rustls, rustls-pki-types (PEM parsing; replaced the unmaintained rustls-pemfile), socket2 (std has no SCTP, and socket2 is the safe way to open SOCK_STREAM/IPPROTO_SCTP sockets — decision recorded in MILESTONES.md M32; unconditional in sipr-net since M44, which needs SO_SNDBUF/ SO_RCVBUF (-buff_size) and SO_BINDTODEVICE (-bind_to_device), neither of which std exposes and both of which would otherwise need unsafe), and for tests proptest, criterion, assert_cmd, tempfile, rcgen. Add them to [workspace.dependencies] when a milestone first needs them. The CLI is a deliberate exception: src/cli.rs is a bespoke table-driven parser (not clap) because SIPp’s single-dash multi-char flags don’t fit clap’s model — extend the FLAGS table there rather than introducing clap. Anything else: state the reason in the commit/PR description. Prefer std over a crate for trivial needs. No crates with native/C dependencies without discussion (portability is a selling point vs SIPp’s build).

TLS note (M13): rustls is used with default-features = false and the ring provider — NOT the default aws-lc-rs, whose C build can require cmake. ring vendors its own C/asm but builds with cc alone, keeping cargo build dependency-free at the system level (no OpenSSL headers — the exact pain point of building SIPp with TLS). rcgen (dev-only, ring feature) generates test certificates at test time so no expiring PEM fixtures are checked in.

Testing (summary — full detail in TESTING.md)

  • Every bugfix lands with a regression test.
  • Scenario parser changes: extend the golden corpus.
  • Engine/net changes from M3 on: interop suite must pass.
  • New public API: at least one doc example.

Commits

Conventional Commits: type(scope): summary where scope is the crate short name (scenario, net, engine, auth, stats, tui, cli, docs). Types: feat, fix, perf, refactor, test, docs, chore, build. Imperative mood, ≤72-char subject. Body explains why when non-obvious. One logical change per commit; keep the tree building at every commit.

Documentation upkeep

Docs are part of the change, not a follow-up: structural changes update ARCHITECTURE.md; discovered SIPp behaviors go to SIPP_COMPAT.md §Behavior notes; completed criteria get checked in MILESTONES.md in the same commit.

Testing

Five layers. A change is done when the layers it touches pass.

1. Unit tests (cargo test --workspace)

Colocated #[cfg(test)] modules. Required coverage by crate:

  • sipr-scenario: parser (every element/attr in SIPP_COMPAT v1 tier), keyword tokenizer (goldens: template in → span list out), action semantics, compile errors (bad label refs, unknown attrs → correct file:line in error).
  • sipr-net: retransmission schedule math (T1 doubling, T2 cap, overrides), timer service ordering, router matching (Call-ID extraction incl. torture inputs).
  • sipr-engine: state machine transitions per step type, optional-recv window matching, jump/branch logic (next/test/chance distributions), dialog bookkeeping (tags, CSeq, route set from rrs).
  • sipr-auth: RFC 7616 test vectors (MD5, SHA-256, qop=auth), stale-nonce path.
  • sipr-stats: counter aggregation, repartition bucketing, CSV column goldens.
  • sipr-control: SIPp command grammar (with its warning texts), JSON round trips, HTTP request parsing, UDP datagram → request, every API route against a fake engine.

2. Golden corpus tests

crates/sipr-scenario/tests/corpus/ contains scenario XML files that must compile without warnings, plus *.expected IR dumps for a subset. Seed corpus: the embedded uac/uas defaults, signaling-only files from ../../cprojects/sipp/sipp_scenarios/ (registration ones: mcd_register.xml, uc360_register*.xml), and examples from SIPp docs. play_pcap_* scenarios are positive goldens since M14; rtp_stream/SRTP scenarios from that directory are negative goldens for now: they must fail loudly with the correct “not supported yet” message, not crash or silently skip. Media e2e tests fabricate their pcap fixtures with sipr_media::pcap::build — no binary captures are checked in.

3. Property tests (proptest)

Keyword tokenizer never panics on arbitrary bytes; tokenize→render round-trips templates without keywords byte-identically; inbound parser (rsip wrapper) never panics on arbitrary datagrams (fuzz-shaped corpus incl. truncated messages, huge headers, non-UTF8).

4. Interop suite (M3+): cargo test -p sipr --test interop

Runs sipr against a real sipp binary, both directions:

  • sipr -sn uac ↔ sipp -sn uas, and sipp -sn uac ↔ sipr -sn uas
  • then every corpus scenario with a paired counterpart scenario
  • assertions: both processes exit 0, call counts match (-m N completed = N, 0 failed), no unexpected-message counters incremented.

Locating sipp: $SIPP_BIN env var, else sipp on PATH, else skip with a visible ignored — set SIPP_BIN marker (never silently green). Build it from ../../cprojects/sipp (cmake . -DUSE_GSL=1 && make, or ./build.sh; GSL — libgsl-dev / brew install gsl — is what lets sipp run statistical pauses, so the M38 interop test’s sipp-side half skips visibly without it) or apt/brew install sipp. Building it on macOS needs a little more than ./build.sh --common says (M44): git submodule update --init first (pugixml is required and gtest must exist for the sipp_unittest target CMake references), pkg-config must be installed or CMake cannot see Homebrew’s OpenSSL, and GSL’s headers are not on clang’s default search path — sipp adds the GSL library but never its include directory, so pass -DCMAKE_C_FLAGS=-I/opt/homebrew/include -DCMAKE_CXX_FLAGS=-I/opt/homebrew/include. Configuring in-tree also writes include/version.cmake; cmake -B <dir> -S <src> keeps the build out of the checkout. Tests bind ephemeral ports on 127.0.0.1 and must run in parallel safely; each test gets its own port pair.

Lessons from driving real sipp in tests (M26): a scenario file handed to sipp must start with the <?xml version="1.0" encoding="ISO-8859-1" ?> declaration or its loader reports “Unable to load or parse”; never assert on the exit code of a sipp started with -bg (the forked parent exits 99 at once) — run it in the foreground with stdin/stdout/stderr null and reap it; sipp as a UAS ignores SIGTERM once curses is up — kill it with SIGKILL.

SCTP cannot be tested on this development host: macOS has no SCTP stack and the Homebrew sipp is built without USE_SCTP (its banner lacks -SCTP). SCTP therefore lives behind the sctp cargo feature, its tests skip when the host has no stack, and the CI job sctp (ubuntu: modprobe sctp, a sipp built with -DUSE_SCTP=1) is where it is actually exercised: cargo test --workspace --features sctp and SIPP_BIN=... cargo test --features sctp --test interop sctp.

5. Performance (M3+)

criterion benches: template fill, inbound parse+route, timer churn — plus a loopback end-to-end cps test (sipr↔sipr, -r ramp until failure) reported in CI logs. Regression rule: >10% drop on hot-path benches blocks merge. Baselines kept in benches/BASELINES.md per machine class. Target trajectory: match single-core SIPp cps by M3 review; exceed it multi-core by v1.

CI order

fmt → clippy → unit+golden+property → build sipp (cached) → interop → benches (benches on-demand/nightly, not per-PR).

Releasing sipr

Releases are cut by pushing a vX.Y.Z tag. The CD workflow (.github/workflows/cd.yml) then builds the binaries, publishes the GitHub release, and pushes to crates.io, the Homebrew tap and Chocolatey. The manual part is the pre-flight, the version bump, and the tag.

1. Pre-flight

Everything must be green (this is what CI runs on every push):

make check            # fmt-check + clippy -D warnings + tests
make deny             # cargo-deny: dependency licenses, advisories, sources

Then the gate CI can only run with a SIPp binary, both directions:

make interop          # SIPP_BIN defaults to ~/development/cprojects/sipp/sipp

If SIPp is not built locally, push to master first and wait for the interop and sctp CI jobs; the tag must not go on a commit CI has not validated.

Optionally confirm that every crate still packages cleanly (no network writes, but it builds each crate):

make publish-dry-run

2. Version + changelog

The version lives in four places; they must agree:

  1. version in the root Cargo.toml [workspace.package], and the pinned internal-dependency versions in [workspace.dependencies] (they are pinned so the crates are publishable).
  2. .version — the plain version without v, no trailing newline. The install scripts read it from master to find the latest release.
  3. Formula/sipr.rb and dist/chocolatey/sipr.nuspec + tools/chocolateyinstall.ps1 — reference copies; CD rewrites the live ones with real checksums, but keep these on the same version.
  4. CHANGELOG.md: move the [Unreleased] items under a new dated heading and add the compare link at the bottom.

Then:

git commit -am "release: v0.27.1"
git tag -a v0.27.1 -m "sipr v0.27.1"
git push origin master --tags

3. What CD does on the tag

  1. Creates a draft GitHub release.
  2. Builds sipr for x86_64-unknown-linux-musl, aarch64-unknown-linux-musl (static), x86_64-apple-darwin, aarch64-apple-darwin, and x86_64-pc-windows-msvc, and uploads sipr-vX.Y.Z-<target>.tar.gz (.zip on Windows) to the draft.
  3. Publishes the release once every asset is up.
  4. In parallel, scripts/publish-crates.sh pushes the nine crates to crates.io in dependency order, skipping any version already there — only when CARGO_REGISTRY_TOKEN is set.
  5. After the release is public, rewrites Formula/sipr.rb in tareqmy/homebrew-tap with the new version and checksums — only when TAP_GITHUB_TOKEN is set.
  6. Packs and pushes the Chocolatey package — only when CHOCO_API_KEY is set.

Each publish step skips with a workflow warning when its secret is missing, so a release without them still produces the GitHub release and binaries.

4. One-time setup (repository secrets)

Settings → Secrets and variables → Actions:

SecretUsed forWhere to get it
CARGO_REGISTRY_TOKENcrates.io publishcrates.io → Account Settings → API Tokens, scope publish-new + publish-update
TAP_GITHUB_TOKENpushing the formula to tareqmy/homebrew-tapa fine-grained PAT with Contents: write on the tap repo only
CHOCO_API_KEYChocolatey pushchocolatey.org → account → API key

The crate names sipr and sipr-* must be free on crates.io at first publish (they were, as of 2026-09-19). The Homebrew tap already exists and serves other formulas; CD adds Formula/sipr.rb next to them.

If the crates.io step fails part-way

crates.io rate-limits brand-new crate names (five per ten minutes), and the tag’s workflow file cannot be edited after the fact. Run the Publish crates workflow from master with the tag name; it checks out the tag’s sources and publishes only the crates still missing:

gh workflow run publish-crates.yml -f tag=v0.27.1

The same script works locally after cargo login: make publish from a checkout of the tag.

5. After the workflow

  • Check the release page: five assets, notes pointing at the changelog.
  • brew update && brew upgrade sipr on a Mac, and the shell installer on a Linux box, should both land the new version.
  • If a step failed, fix it and re-run the job from the Actions tab; the workflow is idempotent (existing release and assets are reused or overwritten with --clobber).

Contributing to sipr

Thanks for your interest. sipr aims to run existing SIPp scenarios unchanged, so compatibility with SIPp’s documented behavior is the bar for every change.

Before you start

  • Read AGENTS.md. It is written for AI coding agents but it is the project’s operating manual for people too: the hard rules, the docs to read for each area, and where SIPp’s C++ is used as the behavioral reference (learn the behavior, never copy the code; SIPp is GPL and sipr is MIT).
  • Check docs/MILESTONES.md and docs/SIPP_COMPAT.md before proposing a feature. Some gaps are deliberate and listed in the README.

Documentation

The site at https://tareqmy.github.io/sipr/ is built with mdBook from docs/ (book.toml at the root) and deployed by .github/workflows/docs.yml on every push to master. A new page must be listed in docs/SUMMARY.md or it will not be rendered. make book serves it locally (cargo install mdbook).

Pull requests

All of these must pass from the repo root; CI runs the same commands:

cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
cargo deny check      # dependency licenses, advisories, sources (cargo install cargo-deny)

Changes to engine, net, or scenario code also need the interop suite against a real SIPp binary (cargo test -p sipr --test interop; see docs/TESTING.md for building SIPp). If you cannot run it locally, say so in the PR and CI will.

  • Keep PRs small and focused; one behavior per PR.
  • Add tests with the change: unit tests in the crate, an e2e or interop test when the behavior is visible on the wire.
  • Commit messages follow Conventional Commits with the crate short name as scope (feat(scenario): …, fix(net): …), explaining the why.
  • If you learned a non-obvious SIPp behavior while working, record it in docs/SIPP_COMPAT.md §6 so the next person does not re-derive it.

AI-assisted contributions are welcome and held to the same bar. Please review what the tool produced before opening the PR.

Reporting bugs

A minimal scenario file plus the exact sipr and sipp command lines, and -trace_msg output from both sides where relevant, makes most bugs reproducible in minutes. Security issues go through SECURITY.md, not the issue tracker.

AGENTS.md — operating instructions for AI agents working on sipr

sipr is a SIPp-like SIP testing tool and traffic generator written in Rust. It plays call flows described in SIPp’s XML scenario format, as UAC or UAS, at a controlled rate, and reports live (TUI) and aggregate statistics.

Current state: v1 shipped, M0–M44 complete (UDP/TCP/TLS transports incl. per-call and per-IP sockets, SCTP behind the sctp feature, -rsa, reconnection, UAC/UAS, out-of-call scenarios (-oocsf/-oocsn), mixed mode (-rxsf/-rxsn, -rxinf), stats + TUI, the full SIPp action set incl. _unexp.main, exec command= and <setdest>, manual transactions (start_txn/ack_txn/response_txn), <User>/<Global> variable scopes, auth incl. IMS AKA + AUTS resync + verifyauth, -inf injection, 3PCC, -users, IPv6, pcap replay, RTP streaming + DTMF, RTP echo + rtpcheck, SRTP + SRTP echo server, SIPp control socket + HTTP API; and post-v1, statistical pauses, the tracing and log files at parity, the timer/behavior knobs, extended 3PCC, and the M44 leftovers — PRINTF= injection files, <rtp_echo variable=>, the socket options, pcapng captures). PLAN.md is the master plan (its dependency choices were superseded by in-tree implementations — docs/MILESTONES.md notes record each swap); the post-v1 backlog at the bottom of docs/MILESTONES.md (M45+, sipr’s own additions now that parity is done) is what comes next.

Read this first

  1. PLAN.md — architecture decisions, milestones, rationale. Do not contradict it; if a decision needs revisiting, say so explicitly and ask rather than silently diverging.
  2. docs/MILESTONES.md — what to build next and the acceptance criteria for “done”.
  3. The doc relevant to your task:
    • docs/ARCHITECTURE.md — crate map, runtime model, data flow, hot-path rules
    • docs/SIPP_COMPAT.md — the exact SIPp XML/keyword/CLI surface we implement
    • docs/CONVENTIONS.md — code style, error handling, logging, commit rules
    • docs/TESTING.md — test layers and how to run them, incl. interop with real SIPp
    • docs/GLOSSARY.md — SIP/SIPp domain terms; read it if you are unsure what a transaction vs dialog vs call is, or what RTD/repartition/3PCC mean

Repo-local skills exist under .claude/skills/ (sip-protocol, sipp-scenarios, interop-testing). Use them when implementing protocol behavior, touching scenario parsing, or writing/running interop tests.

Reference material

  • The original SIPp C++ source is expected as a sibling checkout at ../../cprojects/sipp (i.e. ~/development/cprojects/sipp). It is the behavioral oracle: when SIPp’s documented behavior is ambiguous, read the C++ (src/call.cpp, src/scenario.cpp, src/socket.cpp) rather than guessing. Never copy C++ code verbatim (GPL); learn the behavior, implement independently.
  • ../../cprojects/sipp/sipp.dtd — the scenario XML grammar.
  • ../../cprojects/sipp/sipp_scenarios/*.xml and docs/ there — golden corpus.
  • SIPp docs: https://sipp.readthedocs.io — RFC 3261 (SIP), RFC 7616 (digest auth).

Build, test, lint

Standard cargo workspace. Before considering any change complete, all of these must pass from the repo root:

cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
cargo deny check      # dependency licenses, advisories, sources (deny.toml)

From milestone M3 onward, also run the interop suite when touching engine, net, or scenario code: cargo test -p sipr --test interop (requires a built sipp binary; see docs/TESTING.md). Never mark a milestone item done with failing or skipped acceptance tests.

Hard rules

  • Compatibility is the product. Anything in the v1 tier of docs/SIPP_COMPAT.md must behave like SIPp. New CLI flags must use SIPp’s names where an equivalent exists (-sf, -r, -l, -m, …). Do not invent alternative names for things SIPp already has a name for.
  • Never silently ignore scenario input. Unknown XML elements and actions are hard errors (silently skipping a step would change the call flow); unknown attributes and keywords produce a loud warning with file:line context; --check treats any diagnostic, warnings included, as failure. Silent skips are a SIPp failure mode we deliberately do not inherit.
  • Hot path discipline (per-message send/recv code): no allocations beyond template slot filling, no locks held across .await, no synchronous I/O, no regex compilation. Message templates are pre-tokenized at scenario load; if you find yourself scanning strings per-send, stop and re-read docs/ARCHITECTURE.md §3.
  • The TUI never touches the engine. It reads 1-second stat snapshots only.
  • unsafe is forbidden in this workspace (#![forbid(unsafe_code)] in every crate). If you believe an exception is warranted, ask; do not just add it.
  • No unwrap()/expect()/panic! in library crates outside tests. Errors are typed per crate (thiserror) and bubble to the binary (anyhow) — see docs/CONVENTIONS.md.
  • A test tool must be able to misbehave on purpose. Do not “fix” scenario behavior into RFC compliance: lost, retrans, malformed templates, and rule-breaking scenarios are features. Strictness belongs on the inbound parse side, and even there be tolerant (real devices send garbage).
  • Do not add dependencies casually. The sanctioned set is in docs/CONVENTIONS.md §Dependencies. Adding a new crate requires a stated reason in the PR/commit description.

Engineering principles

  • Prioritize readability over cleverness. Write code that clearly communicates intent. Code is read far more often than it is written: use descriptive names, keep functions small and single-purposed, and follow the project’s style conventions instead of writing dense one-liners.
  • Write automated tests early. Cover core logic and edge cases with unit and integration tests as the code lands, not after. Tests reduce regressions, act as living documentation, and buy the confidence to refactor aggressively (see docs/TESTING.md for the required layers per crate).
  • Practice strict version-control hygiene. Small, atomic commits with clear, imperative messages explaining the why, not just the what (Conventional Commits, per docs/CONVENTIONS.md). Keep branches short-lived and review changes thoroughly before they reach main.
  • Avoid premature optimization (YAGNI). Build what is needed for the current milestone rather than engineering for hypothetical futures. Implement the simplest solution that works, profile actual bottlenecks with data (criterion benches, M3+), and optimize only when necessary. The documented hot-path rules in docs/ARCHITECTURE.md §3 are the measured-by-design exception, not a license to micro-optimize elsewhere.
  • Design defensively and fail gracefully. Never trust external input — inbound datagrams, scenario files, injection CSVs, CLI values. Validate at the boundaries, use structured error handling (typed errors, no swallowed failures), log actionable context, and fail without leaking internals. Remember the test-tool nuance: outbound scenario traffic may deliberately violate the SIP RFCs; defensiveness applies to what we accept, not what scenarios choose to send.

Core refactoring strategies:

  • Extract method. If a block inside a function needs a comment to explain what it does, extract it into a helper named after that intent.
  • Use guard clauses. Return early on invalid input and edge cases instead of nesting the happy path inside if pyramids.
  • Favor composition over inheritance. In Rust terms: no god-objects or deep trait hierarchies — inject small, focused types (services, strategies) to handle specific behaviors, and keep trait bounds narrow. SIPp’s call.cpp absorbed most concerns over the years; the crate boundaries exist so that does not happen here.

Workflow

  • Work in small, compilable increments; keep main green.
  • Update docs/MILESTONES.md checkboxes in the same change that completes them.
  • A new file under docs/ must be added to docs/SUMMARY.md: that is the table of contents of the documentation site (https://tareqmy.github.io/sipr/, built by mdBook from book.toml). make book-build checks it renders.
  • If you learn a non-obvious SIPp behavior from the C++ source, record it in docs/SIPP_COMPAT.md §Behavior notes so the next agent doesn’t re-derive it.
  • Commit messages follow Conventional Commits (feat(scenario): ..., fix(net): ...) — scope = crate short name. See docs/CONVENTIONS.md.

sipr brand — “Ferrous”

Industrial slab, oxidized orange. Wears the Rust heritage openly.

Files

  • logo/mark.svg — hex + wave mark (works on light and dark)
  • logo/mark-mono.svg — single-color mark for stamps, stickers, favicons
  • logo/lockup.svg — mark + wordmark, ink text (light grounds)
  • logo/lockup-dark.svg — mark + wordmark, paper text (dark grounds)
  • logo/mark-512.png, logo/lockup-1600.png, logo/lockup-dark-1600.png — raster exports

Note: the lockup SVGs embed Alfa Slab One as a data-URI font — fine in browsers, but some pipelines (GitHub README image proxy, Figma import) strip embedded fonts; use the PNGs there, or the mark SVG which is pure paths.

  • tokens.css — CSS custom properties + @font-face for the bundled fonts
  • fonts/ — WOFF2, all SIL Open Font License

Color

RoleHex
Rust (primary)#B7410E
Rust bright (on dark)#E05A1E
Ink#221C18
Paper (ground)#F3ECDF
Paper edge (borders)#DDD2BD
Taupe (secondary text)#7A6F60
Sage (success)#5E7A52
Charcoal (dark ground)#1C1613

Terminal mapping: rust -> red/orange (208), sage -> green (65), taupe -> bright black, paper -> bright white.

Type

  • Display: Alfa Slab One 400 — product name and big numbers only, always lowercase “sipr”
  • Body/UI: Archivo 400/600/800
  • Data & code: IBM Plex Mono 400/500

Mark usage

  • Clear space around the lockup: height of the wave (~1/3 mark height) on all sides.
  • Minimum mark size 24px; below that use mark-mono.svg.
  • Don’t recolor the hex; on dark grounds prefer #E05A1E accents around it.
  • Wordmark is always lowercase.

Fonts are redistributed under the SIL OFL 1.1 (Alfa Slab One, Archivo, IBM Plex Mono).

sipr — Implementation Plan

A SIPp-like SIP testing tool and traffic generator, written in Rust.

Decisions locked in: our own SIP message layer + transport + transaction layer (a tester’s stack, not a compliant one) · SIPp XML-compatible scenarios · v1 scope is signaling-only over UDP · CLI + live TUI.

Status (2026-09-03): v1 shipped (M0–M6) and the post-v1 milestones through M13 are done — see docs/MILESTONES.md. The crate choices below were revisited during implementation: almost everything ended up in-tree (§3.1). The architecture and milestone rationale are otherwise as written.


1. Goal and non-goals

sipr is a scenario-driven SIP traffic generator: it plays call flows described in SIPp’s XML scenario format, as UAC or UAS, at a controlled rate, and reports live and aggregate statistics. The target for v1 is that the classic workflow works end to end:

sipr -sn uas -p 5060                    # terminal side
sipr -sn uac -r 50 -m 10000 127.0.0.1   # caller side, 50 cps, 10k calls

and that real-world custom -sf scenario.xml files (signaling-only ones) run unmodified.

Non-goals for v1 (planned later, see roadmap): TCP/TLS/WS transports, RTP media (pcap replay, rtp_stream), 3PCC (sendCmd/recvCmd), SRTP, IPv6 (stretch), distributed control API. Explicit non-goal overall: being a general-purpose SIP stack — the transaction layer is deliberately a tester’s transaction layer (it must be able to send broken messages, ignore retransmission rules on demand, inject lost/retrans behavior).

This is why we build our own message and transaction layer instead of using a full stack like rsipstack: a compliant stack actively prevents the rule-breaking a test tool needs.

2. What SIPp actually is (component map from the C++ source)

From cprojects/sipp/src, the tool decomposes into roughly ten functional areas. This is the checklist of what “SIPp-like” ultimately means:

SIPp sourceResponsibilitysipr home (crate/module)
sipp.cppmain loop, CLI parsing, global optionssipr bin, cli.rs
xp_parser.cpp, scenario.cppXML parsing → scenario model, keyword substitutionsipr-scenario
call.cpp (300KB!)per-call state machine executing scenario stepssipr-engine::call
call_generation_task.cpp, ratetask.cppopen-loop call arrival at rate -r/-rpsipr-engine::pacer
socket.cpptransport, socket mgmt, retransmissionssipr-net
sip_parser.cpp, message.cppSIP message parse/buildsipr-net::message (in-tree lazy parser) + sipr-scenario templates
auth.cpp, milenage.cdigest + AKA authenticationsipr-auth (digest only in v1)
actions.cpp, variables.cpp<action> exec: ereg/assign/test/…, call variablessipr-scenario::actions
stat.cppcounters, RTDs, repartitions, CSV dumpssipr-stats
screen.cppncurses live UIsipr-tui (hand-rolled ANSI)
infile.cpp-inf CSV injection filessipr-scenario::infile
rtpstream.cpp, jlsrtp.cpp, prepare_pcap.c, send_packets.cmediaout of v1 scope
watchdog.cpp, logger.cpphealth, trace filessipr-stats trace writers + small glue

Two structural lessons from the C++ worth keeping in mind: call.cpp grew to 300KB because scenario execution, message building, retransmission logic and stats all live in one class — our crate boundaries above are chosen specifically to prevent that; and SIPp is single-threaded with a select() loop, which is why its per-process ceiling is ~1–3k cps — an async multi-core design is our chance to beat it decisively.

3. Architecture

3.1 Workspace layout

sipr/
├── Cargo.toml            # workspace
├── crates/
│   ├── sipr-scenario/    # XML parser → Scenario IR; keywords; actions; injection files
│   ├── sipr-net/         # UDP transport, socket pool, timer wheel, retransmit schedule
│   ├── sipr-engine/      # call state machine, pacer, call table, dialog bookkeeping
│   ├── sipr-auth/        # RFC 2617/7616 digest for [authentication]
│   ├── sipr-stats/       # counters, HDR histograms, repartitions, CSV export
│   └── sipr-tui/         # ANSI screens + key handling
└── src/main.rs           # thin bin: table-driven SIPp-style CLI → wire everything together

External crates (as shipped): rustls + rustls-pemfile for the TLS transport (M13, ring provider — no system OpenSSL), and dev-only rcgen + tempfile for test certificates. That is the whole list. The original plan named rsip, tokio, quick-xml, clap, ratatui/crossterm, regex, hdrhistogram, tracing, rand, md-5/sha2; each was replaced by a small in-tree implementation, and the per-milestone notes in docs/MILESTONES.md record why:

NeedPlanned crateShipped instead
CLI parsingclaptable-driven parser in src/cli.rs (SIPp’s single-dash multi-char flags don’t fit clap)
Scenario XMLquick-xmlsipr-scenario/src/xml.rs, a subset parser with exact line tracking (like SIPp’s xp_parser.cpp)
Inbound SIP parsersipsipr-net/src/message.rs, lazy and panic-free on arbitrary bytes; templates are raw bytes with slot filling
Runtime, UDP, timerstokio, tokio-utilstd threads feeding one mpsc event channel; pure TimerQueue + condvar driver
ereg regexregexsipr-scenario/src/regex.rs, a backtracking ERE engine with a step budget
RTD histogramshdrhistogramsipr-stats/src/histogram.rs, 1 ms buckets
TUIratatui + crosstermhand-rolled ANSI + stty raw mode, rendering as pure Snapshot → Vec<String>
Trace filestracingplain writers in sipr-stats with SIPp-style framing
Randomnessrandseeded xorshift (deterministic, reproducible lost/chance)
Digest hashesmd-5, sha2sipr-auth/src/hash.rs, checked against the RFC/FIPS vectors

The sanctioned set for future additions is in docs/CONVENTIONS.md §Dependencies.

3.2 Runtime model

The core is an open-loop pacer feeding a sharded call table, on std threads (one event-loop thread fed by an mpsc channel — SIPp’s single-loop shape — with the socket reader and timer driver as separate threads):

  • One (later N, SO_REUSEPORT) UDP socket driven by a recv loop task. Inbound datagrams are parsed by the in-tree message parser and routed by Call-ID to their call in a sharded call table. Unmatched inbound requests either spawn a new UAS call (server mode) or count as OutOfCall messages.
  • Each active call is a small state machine object — not one spawned task per call by default. Calls advance via events (message-in, timer-fired, pause-elapsed) delivered to worker tasks; scenario position + variables + last-messages live in the call struct. This keeps 100k concurrent calls cheap and mirrors SIPp semantics closely.
  • The pacer implements SIPp’s open-loop arrival: every rate_period (default 1s), start -r new calls, respecting -l (max concurrent), -m (total), with burst smoothing within the period. Rate changes come from the TUI (+/-/*// keys) or CLI.
  • A timer service (pure TimerQueue + condvar thread driver) owns retransmission timers (T1=500ms doubling to T2=4s for unreliable transport, as in RFC 3261 §17), recv timeouts, pauses, and global watchdog deadlines.
  • Stats are lock-free-ish: per-worker counters aggregated by a 1s ticker into the snapshot the TUI and CSV writer read. RTDs (start_rtd/rtd attrs) use the in-tree 1 ms-bucket histogram.

3.3 Scenario IR and execution

sipr-scenario compiles XML into a flat Vec<Step> IR at startup — exactly SIPp’s message-index model, which next/label/jump semantics depend on:

#![allow(unused)]
fn main() {
enum Step {
    Send { template: MsgTemplate, retrans: Option<u32>, lost: Option<f32>,
           start_rtd: .., common: StepCommon },
    Recv { expect: Expect /* response code | request method */, optional: bool,
           timeout: Option<Duration>, ontimeout: Option<Label>, auth: bool,
           rrs: bool, actions: Vec<Action>, common: StepCommon },
    Pause { duration: PauseSpec /* fixed | variable | distribution */, common: StepCommon },
    Nop { actions: Vec<Action>, common: StepCommon },
    Label(LabelId),
    Timewait { ms: u64 },
}
}

MsgTemplate is the raw CDATA pre-tokenized once at parse time into literal spans + keyword slots, so per-call message building is just slot filling — no per-send string scanning. That single decision is worth more to throughput than anything else.

3.4 SIPp XML compatibility matrix (from sipp.dtd)

TierSurfaceWhen
v1 (M1–M6)send, recv, pause, nop, label, timewait; Reference, ResponseTimeRepartition, CallLengthRepartition; step attrs next/test/chance/condexec/optional/timeout/ontimeout/rrs/auth/lost/retrans/crlf/counter/rtd/start_rtd/repeat_rtd; actions ereg, log, warning, assign, assignstr, strcmp, test, add/subtract/multiply/divide, jump, lookup, insert, replace, gettimeofday, exec int_cmd, todouble, trim, urlencode/urldecode, error; keywords [service] [remote_ip] [remote_port] [local_ip] [local_ip_type] [local_port] [transport] [call_id] [call_number] [cseq] [branch] [msg_index] [pid] [routes] [next_url] [peer_tag_param] [field0..N] [$var] [last_*] [authentication] [len] [tdmmap?no]core
v1.x-inf injection files ([fieldN], lookup), -key keywords, sendCmd/recvCmd (3PCC), setdest, sample/statistical pauses, exec command= (external), regexp variantsdone through M38 except -key (second backlog, M39)
laterexec play_pcap*, rtp_stream, rtp_echo, verifyauth, closecon, pauserestore, TCP/TLS-dependent attrswith media/transport milestones

Unknown elements/attributes must produce a loud warning with file:line, never a silent skip — half of SIPp debugging misery is silent scenario behavior.

4. Milestones

Each milestone ends with something runnable, and from M3 on, every milestone is validated against real SIPp from cprojects/sipp as the interop peer.

M0 — Scaffolding (small). Workspace + crates, CLI skeleton mirroring SIPp flag names (-sf -sn -r -rp -l -m -d -s -p -i -t u1 -trace_msg -trace_err -trace_stat -nd -timeout -bg), CI (fmt, clippy, test), embedded uac/uas default scenarios as string constants (port them from SIPp’s -sd dumps).

M1 — Scenario front end. XML → IR for the v1 surface; keyword tokenizer; golden tests: parse every signaling-only XML in sipp/sipp_scenarios/ and the docs examples without error; sipr --check -sf x.xml lint mode that prints the compiled IR.

M2 — Net + message layer. UDP transport with message round-trip (parse → build byte- identical where possible); timer wheel; UDP retransmission schedule with per-send override (retrans attr) and lost simulation; Call-ID router + call table.

M3 — UAC engine end to end. Execute IR for outbound calls: send/recv/pause matching, optional messages, next/label jumps, default uac scenario completes INVITE–180–200–ACK–pause–BYE–200 against real sipp -sn uas. Pacer with -r/-rp/-l/-m, clean shutdown (q semantics: stop placing, drain, timewait). Exit codes matching SIPp (0 ok, 1 some calls failed, 97/99 aborts) so scripts/CI ports work.

M4 — UAS mode + stats. Inbound call creation from initial requests, [last_*] keywords, rrs/[routes]/[peer_tag_param] for dialog correctness as callee; sipr-uac ↔ sipr-uas self-test; stats engine: the full SIPp counter set (created/completed/failed breakdowns, retransmissions, response-code tallies), RTDs + repartitions, -trace_stat CSV with SIPp-compatible column naming where sane, periodic -fd dumps.

M5 — TUI. Terminal screens replicating SIPp’s ncurses layout: main stats screen and per-step scenario screen (messages sent/recv/retrans/timeout/unexpected per step), repartition screen; keys + - * / (rate), p (pause traffic), q (soft quit), Q (hard quit), s screens cycle. Also -bg-style headless mode with periodic stat lines, since CI is a first-class user.

M6 — Actions, variables, auth. Variable store per call + ereg capture, arithmetic/string actions, test/condexec branching, chance; [authentication] with digest (MD5, SHA-256, qop=auth) against a challenging registrar; -aa auto-answer of in-dialog OPTIONS/INFO/UPDATE/NOTIFY like SIPp. ← v1 ships here.

Post-v1 roadmap, in rough order: -inf injection + lookup (it’s the most-used feature not in v1 — could be pulled into M6 if appetite), TCP then TLS transports, 3PCC, -users closed-loop mode, pcap replay/RTP streaming (study gossipper’s Go approach before designing), AKA auth (milenage), IPv6, HTTP control API.

5. Testing strategy

Unit tests per crate (parser goldens, keyword expansion, timer math, digest vectors from RFC 7616). Integration: a tests/interop harness that shells out to the real sipp binary — every scenario runs sipr-as-UAC vs sipp-as-UAS and the reverse, asserting both sides exit 0 and counters agree. Fuzz-style no-panic tests on the inbound parser and tokenizer (crates/sipr-net/tests/no_panic.rs: seeded random bytes, truncations, mutations — proptest was unavailable, the seeded equivalent is reproducible by construction). Performance gate from M3: make bench (loopback sipr-UAC vs sipr-UAS) with numbers recorded in benches/BASELINES.md; target ≥ SIPp’s single-core cps early.

6. Risks and open questions

The big one is compatibility depth: SIPp’s DTD is small but its behavior is folklore (exact keyword expansion quirks, default header injection, when Contact/tags are added, optional recv reordering rules). Mitigation: interop harness from M3 onward, and call.cpp/scenario.cpp as the reference — read the C++ when behavior is ambiguous, the docs lie less than they omit. Second: an off-the-shelf SIP parser’s strictness may reject the deliberately-malformed messages testers send — mitigation (adopted): templates are raw bytes with slot filling, and the in-tree inbound parser is lazy and tolerant. Third: TUI + high cps contention — keep the TUI a pure reader of 1s snapshots, never on the hot path.

7. Next steps

M0–M37 are done (v0.27.1). The ordered backlog lives at the bottom of docs/MILESTONES.md: the second backlog (M38+) closes the remaining SIPp parity gaps — statistical pauses, keyword parity incl. -key, the statistics and log file families, timer/behavior knobs, extended 3PCC, leftovers — and then sipr’s own additions.

Milestones and acceptance criteria

Live tracking document. Check items in the same commit that completes them. Rationale and detail: PLAN.md §4. Do not start milestone N+1 while N has unchecked required items (unchecked stretch items are fine, move them down).

M0 — Scaffolding ✅

  • Workspace + six crates compile empty; #![forbid(unsafe_code)] everywhere (workspace lint, unsafe_code = "forbid")
  • CLI accepts the v1 flag set (SIPP_COMPAT §3) with help text; unknown flags error out with a did-you-mean suggestion. NOTE: implemented as a bespoke table-driven parser in src/cli.rs, not clap — SIPp’s single-dash multi-char flags (-sf, -trace_msg) can’t be expressed in clap without an argv-rewriting shim, and zero deps keeps M0 buildable anywhere. Revisit only if flag complexity outgrows the table.
  • -sn uac|uas selects embedded default scenarios (clean-room ports of SIPp’s defaults, crates/sipr-scenario/assets/); -sd prints them
  • CI: fmt + clippy(-D warnings) + test on push
  • rust-toolchain.toml, MSRV (1.85) recorded in CONVENTIONS.md

M1 — Scenario front end ✅

  • XML parser → IR for full v1 tier of SIPP_COMPAT §1 (elements, attrs, actions), with file:line errors. NOTE: hand-rolled XML subset parser (sipr-scenario/src/xml.rs, like SIPp’s own xp_parser.cpp) instead of quick-xml — zero deps, exact line tracking; swap only if the XML surface outgrows it.
  • Keyword tokenizer: full v1 keyword list (+ [media_*] placeholders); unknown keyword = loud warning, verbatim passthrough (IPv6 literals in URIs rely on this)
  • Label/next/ontimeout references validated and resolved to step indices; variables interned to table ids; read-never-set = error, set-never-read = warning, Reference suppresses
  • --check mode: lint + print compiled IR; exit 1 on any diagnostic (warnings included — check mode is strict); -sf role detection now drives the remote-target requirement
  • Golden corpus passing (tests/corpus/{positive,negative}), incl. negative goldens for media/3PCC/unknown-element scenarios with expected error markers

M2 — Net + message layer ✅

  • UDP transport: bind -i/-p, recv loop, inbound parse, Call-ID routing fields, sharded call table. NOTES: (a) inbound parsing is an in-tree lazy parser (sipr-net/src/message.rs) instead of rsip — start-line classification, compact forms, folding, Call-ID/CSeq/ branch/tags — panic-free on arbitrary bytes; (b) runtime is std-only threads feeding one mpsc event channel (SIPp’s single-event-loop shape) instead of tokio — see the note in ARCHITECTURE §2; pure logic (TimerQueue, RetransSchedule, Inbound) is driver-agnostic if tokio lands later.
  • Timer service: pure TimerQueue (arm/cancel/pop_due, unit-tested without sleeping) + condvar thread driver; retransmission schedule with T1→T2 doubling, retrans override (incl. retrans="0" = off, base > T2 respected), max-retrans cap, -nr kill switch
  • lost simulation on send and recv paths (deterministic seeded xorshift; per-send override beats transport default)
  • No panic on arbitrary inbound datagrams: deterministic fuzz-style suite (tests/no_panic.rs) — random bytes, random ASCII, all truncations, single-byte mutations, pathological shapes. proptest is unavailable in this build env; the seeded equivalent is reproducible by construction.

M3 — UAC end to end ✅ — first interop milestone

  • Embedded uac scenario completes against real sipp -sn uas; 0 failed. Verified 2026-08-17 on the dev machine against SIPp v3.7.7 (Homebrew, on PATH): cargo test -p sipr --test interop — both directions pass. The same flow is also verified end-to-end in-container against a scripted UAS (tests/e2e.rs): INVITE–180–200–ACK–pause–BYE–200, 20 000 calls at 2000 cps, 0 failed, plus lost-first-INVITE retransmission recovery.
  • Pacer: -r, -rp, -l (non-queuing cap), -m; smoothing within the rate period (≤20 ms sub-ticks); runtime rate change API (EngineControl::set_rate, consumed by the TUI at M5)
  • optional-recv window semantics verified against call.cpp and recorded in SIPP_COMPAT §6 (forward scan, backward contiguous scan, CSeq-method guard, retrans cancel — incl. SIPp’s own stall wart)
  • Soft quit (q+Enter / -m drain) and hard quit (Q); exit codes 0/1/99 per SIPp’s documented table (+2 usage, 255 fatal; 97 lands with exec int_cmd at M6); global -timeout fails active calls
  • Interop job added to CI (installs sip-tester on the runner); loopback cps baseline recorded in benches/BASELINES.md (~2000 cps sustained, far-end-bound)

M4 — UAS mode + stats ✅

  • UAS call creation from initial requests (unknown Call-ID matching the initial window creates a call bound to the packet’s source address); embedded uas scenario runs; sipr↔sipr self-test green (E2E test + 50 000 calls at 5000 cps in BASELINES.md); retransmitted inbound requests are answered by re-sending the last response; timewait absorbs late traffic without failing (deadcall behavior)
  • sipp-uac ↔ sipr-uas: verified 2026-08-17 against real SIPp v3.7.7 (cargo test -p sipr --test interop), same run as the M3 gate
  • [last_*], rrs+[routes], [peer_tag_param] correct as callee — proven by the self-test (the uac’s ACK/BYE dialogs only complete if the uas mirrors correctly)
  • -aa auto-answers in-dialog OPTIONS/INFO/UPDATE/NOTIFY with a mirrored 200 (E2E test drives an unexpected in-dialog OPTIONS)
  • Stats: SIPp counter set incl. failure breakdown and auto-answered; RTDs via an in-tree 1 ms-bucket histogram (hdrhistogram unavailable — same dependency situation as always, recorded in the crate docs); both repartitions; -trace_stat/-stf/-fd CSV (pragmatic subset of SIPp’s columns with (P)/(C) naming — SIPP_COMPAT §6); -trace_msg/-trace_err files with SIPp-style framing
  • Periodic stat line in -bg mode incl. rtd1 avg/p99

M5 — TUI ✅

  • Main screen (rates target/period/avg, call counts, message counters, failure breakdown, RTD table, call length) ≈ SIPp layout. NOTE: hand-rolled ANSI + stty raw mode instead of ratatui/crossterm (unreachable registry, same as every dependency decision) — rendering is pure Snapshot → Vec<String> functions in sipr-tui/src/render.rs and fully unit-tested; the interactive shell is a thin guard layer.
  • Scenario screen: per-step sent/recv/retrans/timeout/unexpected table (per-step counters wired through the engine into StatSet)
  • Repartition screen (both tables, placeholder when unconfigured)
  • Keys: + - * / live rate, p pause (pacer skips), q soft quit, Q hard quit, s screen cycle; TUI reads 1 s snapshots over a channel and never touches engine state; keys flow back through the same bridge (run_with_ui), verified without a terminal in sipr-engine/tests/ui_bridge.rs
  • Terminal restored on every path: RawGuard drop (stty -g save / restore), panic hook chaining the restore, alternate-screen leave — verified under a real pty (script): restore sequence emitted once, exit 0. TUI auto-enables only when stdin+stdout are terminals and -bg is absent; headless behavior unchanged.

M6 — Actions, variables, auth ✅ — v1 shipped

  • Per-call variable store + action executor: ereg (capture groups via an in-tree ERE engine), assign/assignstr/strcmp/test, add/subtract/multiply/divide, todouble, trim, urlencode/urldecode, gettimeofday, jump, log/warning/ error, exec int_cmd. test/condexec branching, chance, named counters, and pause variable= all live. NOTE: the regex engine is in-tree (sipr-scenario/src/regex.rs) — leftmost-first greedy with a backtracking budget, POSIX classes; divergence from POSIX leftmost-longest noted in SIPP_COMPAT §6. insert/replace/lookup stay in the v1.x tier (they need -inf).
  • [authentication] digest MD5 + SHA-256, qop=auth, cnonce/nc, opaque, proxy (407); verified end-to-end against a scripted registrar that recomputes and compares the response server-side (tests/e2e.rs::digest_authentication_round_trips). Hash primitives are in-tree (sipr-auth/src/hash.rs), checked against the RFC 1321 / FIPS 180-4 / RFC 2617 / RFC 7616 vectors. Stale-nonce retry: the challenge exposes stale; a scenario re-auths by looping to the send.
  • rtd/start_rtd/repeat_rtd + counters flow into the stats/CSV/TUI (M4/M5)
  • Docs pass: README quickstart, SIPP_COMPAT §6 updated (regex semantics, auth, action executor). v1 ships here.

v1 is feature-complete for signaling over UDP. The two interop gates (sipr↔real-sipp, both directions) remain open only because the build sandbox has no sipp binary — run SIPP_BIN=... cargo test --test interop on a machine with sipp to close them.

M7 — Injection files ✅

  • -inf FILE (repeatable): SEQUENTIAL / RANDOM / USER mode header (matched by substring, per SIPp), ;-separated fields, # comments, blank line terminates. One line is drawn per call per file — SEQUENTIAL cycles (wrapping), RANDOM picks uniformly, USER defers to -users (not yet wired, so USER renders empty with a load-time warning, matching SIPp). Parser is pure (sipr-scenario/src/inject.rs), assignment happens once at call birth in the engine.
  • [fieldN] keyword resolves to field N of this call’s drawn line. sipr extensions over SIPp: [fieldN file=K] selects the K-th -inf (0-based) and [fieldN line=M] pins a literal line. Unknown field/file indices are rejected at load. Verified end-to-end (tests/e2e.rs::injection_file_fields_land_in_sent_messages).
  • SIPP_COMPAT §6 documents the injection semantics and the deferral of lookup/insert/replace (indexed-file mutation needs the file store).

M8 — Indexed injection: lookup / insert / replace ✅

  • -infindex FILE FIELD builds a key→line index over one field of an -inf file (matched by basename, SIPp-style); duplicate keys resolve to the last line. Index/lookup/insert/replace live in sipr-scenario/src/inject.rs (pure) with RefCell-wrapped files in the engine so [fieldN] reads and insert/replace mutations share them on the one event-loop thread.
  • <lookup assign_to=… file=… key=…/> stores the matched line (or -1), <insert file=… value=…/> appends, <replace file=… line=… value=…/> swaps — all with rendered-template arguments (compile.rs, executed in sipr-engine/src/actions.rs).
  • [fieldN] gained SIPp-faithful selectors: file=NAME (basename key, or a numeric -inf index as a sipr extension) and line=EXPR rendered at send time — line=[$var] is what makes lookup usable. The tokenizer now balances nested brackets to parse line=[$1]. Verified end-to-end (tests/e2e.rs::lookup_reads_indexed_field_by_key). SIPP_COMPAT §6.

M9 — TCP transport (-t t1) ✅

  • sipr-net/src/tcp.rs: a TcpFramer that de-frames a byte stream into SIP messages by Content-Length (RFC 3261 §7.5), skipping keep-alive CRLFs, and a TcpTransport with one connection per peer — the UAC dials the target once, the UAS accepts, each connection gets a framed reader thread, and writes route back by peer address. Unit + integration tested.
  • Engine transport abstracted into a Udp/Tcp enum; -t t1 binds TCP by role (connect for UAC, listen for UAS), [transport] renders TCP, and SIP retransmissions are gated off for reliable transports (RFC 3261 §18.2). CLI accepts t1/tn. Both directions covered end to end (tcp_uac_places_call_over_stream, tcp_uas_answers_over_stream).
  • Fixed a latent framing bug the stream transport exposed: body-less messages were missing the mandatory \r\n\r\n header/body separator (UDP hid it). SIPP_COMPAT §6.

M10 — Classic 3PCC (sendCmd/recvCmd) ✅

  • sipr-net/src/twin.rs: an EscFramer (0x1B-delimited) and a TwinChannel over one TCP connection — connect() for controller A, listen() for controller B, with a framed reader thread delivering commands. Unit + loopback tested.
  • Scenario <sendCmd> (rendered CDATA command) and <recvCmd> (blocks the call; its <action>s’ ereg searches the raw command text). Compiler + model, with extended-3PCC dest=/src= rejected. -3pcc HOST:PORT CLI.
  • Engine wiring: the twin role is derived from the scenario’s first twin command (sendCmd→dial, recvCmd→listen); Event::TwinCmd wakes a call blocked on <recvCmd>, with a pending-command queue for ordering. Verified end to end (tests/e2e.rs::threepcc_controller_a_round_trips_a_command drives SIP → twin → SIP through the real binary). SIPP_COMPAT §6.

M11 — -users closed loop ✅

  • -users N: closed-loop generation keeping N concurrent calls. A free-user pool (1..=N) hands each call a 1-based user id; a finished call returns its id and refill_users opens a replacement immediately, so the population stays constant until -m total. The rate pacer is disabled in users mode; -users and -l are mutually exclusive.
  • [userid]/[users] keywords, and USER-mode -inf files now resolve line = userId-1 (the M7 stub is lit up). Verified end to end (tests/e2e.rs::users_closed_loop_binds_user_to_injection_line: three users each run twice under -users 3 -m 6, each [field0] matching its [userid]). SIPP_COMPAT §6.
  • Deferred: per-user persistent variables (shipped as M35; runtime user-count changes shipped with the control socket, M17).

M12 — IPv6 ✅

  • Targets accept bracketed IPv6 ([::1], [2001:db8::1]:5060) and bare literals (::1); resolve_target handles all forms, and a v6 target with no -i auto-binds the :: family. -i already took a v6 local address.
  • [local_ip]/[remote_ip] render bracketed for IPv6 (SIPp local_ip_w_brackets) so URIs/Via are well-formed; [media_ip] stays raw for SDP. Unit-tested (resolve_target, render bracketing); the loopback e2e (ipv6_uac_places_call_over_loopback) runs where ::1 binds and self-skips in the v6-less build sandbox. SIPP_COMPAT §6.

M13 — TLS transport (-t l1) ✅

Behavioral oracle: sslsocket.cpp / socket.cpp in the SIPp source. TLS is the TCP path with a TLS layer on top — same framing, same connection-per-peer model, same no-retransmission rule, same default port 5060, no sips: scheme. First external dependency of the workspace: rustls (with the ring provider — pure-ish Rust, builds with cc only, no system OpenSSL; this is a selling point vs SIPp’s mandatory OpenSSL) + rustls-pemfile; rcgen dev-only for generating test certs at test time. Rationale recorded in CONVENTIONS §Dependencies.

  • sipr-net/src/tls.rs: TlsTransport mirroring TcpTransport (connect/listen/local_addr/send_to), reusing TcpFramer. rustls connection per peer: handshake completes in connect/accept, a reader thread feeds the framer, writes lock the connection briefly to encrypt. A failed inbound handshake drops that connection with a loud warning — deliberate divergence from SIPp, which kills the whole process on SSL_accept failure (SIPP_COMPAT §6).
  • Config/CLI, SIPp names: -t l1 (and ln, collapsing onto connection-per-peer like tn); -tls_cert [cacert.pem], -tls_key [cakey.pem], -tls_ca, -tls_crl, -tls_version. Verification matches SIPp: OFF unless -tls_ca/-tls_crl given; when on, the client checks the chain but NOT the hostname, and the server demands + verifies a client cert (mutual TLS); the client always presents its cert if asked. Documented divergences: -tls_version 1.0/1.1 errors (rustls has no TLS ≤1.1; SIPp’s floor is 1.0), encrypted keys rejected (SIPp uses a hardcoded passphrase ksgr).
  • Engine: TransportKind::TlsMono, Transport::Tls arm, [transport] renders TLS (Via SIP/2.0/TLS, transport=TLS in Contact), reliable = true so retransmissions stay off.
  • Tests: tls.rs unit tests (roundtrip, mutual TLS, mute-peer handshake failure, missing-cert error, 1.3 pin); e2e loopback both directions with an rcgen cert (tls_uac_places_call_over_stream, tls_uas_answers_over_stream); interop vs real sipp (-t l1 both roles), self-skipping when the sipp binary lacks TLS — and the sipp-as-client direction also self-skips on macOS, where sipp’s own stream-client bind fails (EADDRINUSE; note in SIPP_COMPAT §6). fmt/clippy/test all green.

M14 — pcap replay (exec play_pcap_*) ✅

Behavioral oracle: prepare_pcap.c / send_packets.c / call.cpp (get_remote_media_addr, [media_port]/[auto_media_port]), studied alongside gossipper’s Go media engine (scale lessons: one scheduler thread, absolute timeline, sockets per stream, no per-stream threads). Design and divergences in SIPP_COMPAT §6.

  • New std-only crate sipr-media: a pure-Rust classic-pcap reader (pcap.rs: µs/ns magic either byte order; Ethernet + one 802.1Q tag, raw IP, Linux SLL v1/v2, BSD null/loop; IPv4 any IHL, IPv6 without extension headers; non-UDP packets skipped and counted; truncated captures rejected with SIPp’s -s0 hint), the SDP endpoint scan (sdp.rs: session/media-level c=, first live m=<kind>, port 0 skipped), and the replay scheduler (replay.rs: one sipr-media thread, min-heap of due streams, frames sent at start + offset on the capture’s absolute timeline with burst catch-up, one UDP socket per destination-port offset preserving SIPp’s port_diff mapping, RTP bytes verbatim). No libpcap, no raw sockets, no root.
  • Scenario: exec play_pcap_audio|video|image=Action::PlayPcap (one per exec; play_pcap= is rejected as SIPp never implemented it; rtp_stream/rtp_echo/play_dtmf are clear “M15” errors); <recv ignoresdp> (and the DTD’s ignosesdp) accepted; [auto_media_port] and [media_port+N]/[auto_media_port+N] keyword forms. Corpus: negative/media_pcap.xml became positive/pcap_play.xml; negative/media_rtp_stream.xml added.
  • Engine: -mi/-mp (-min_rtp_port alias) → [media_ip], [media_port] (default 6000; auto = + 4*(call-1) % 10000), pcaps resolved next to the -sf file then the CWD and parsed once at startup (missing/malformed = fatal, like SIPp), remote endpoints learned from any response body or INVITE/ACK/PRACK request SDP (stale values kept), the local port per kind read off the SDP template’s m= line at load (SIPp’s runtime “audio”/“video”/“image” line scan, done once), replay stopped on every call teardown path, socket/send failures logged and the call continues. Stats: rtp_streams_started/rtp_packets_sent/ rtp_bytes_sent sampled once a second into the stat set, TUI main screen line, -bg line, and the final summary (rtp-sent N).
  • Tests: 23 unit tests in sipr-media (incl. a deterministic no-panic sweep of truncations/mutations), compiler tests for every exec form, e2e play_pcap_audio_replays_capture_to_the_sdp_endpoint (two calls, distinct auto_media_port blocks, every payload verbatim) and play_pcap_with_a_missing_file_is_fatal_at_startup, interop uac_pcap_against_real_sipp_uas (sipp -rtp_echo UAS answers with its own SDP; 30/30 frames sent). fmt/clippy/test green.
  • Deferred to M15: rtp_stream (file/pattern streaming, pause/resume), play_dtmf (RFC 4733 generation), rtp_echo, -rtpcheck, -key lookups in play_pcap_* values, ~ expansion in media paths.

M15 — RTP streaming and DTMF (exec rtp_stream=, exec play_dtmf=) ✅

Behavioral oracle: rtpstream.cpp (rtpstream_playrtptask, rtpstream_get_localport, rtpstream_cache_file), actions.cpp setRTPStreamActInfo (the payload table), prepare_pcap.c prepare_dtmf, call.cpp E_Message_RTPStream_*_Port. Divergences in SIPP_COMPAT §6.

  • sipr-media::rtp: SIPp’s payload table verbatim (0/8/9 → 160 B/20 ms, 13 → 1 B/150 ms, 18 → 20 B/20 ms, dynamic H264/90000 → 1280 B/160 ms video, iLBC/8000 → 50 B/30 ms; missing/mismatched names error with SIPp’s wording), RIFF/WAVE header skip (not a decoder — as SIPp), apattern/vpattern 1..=6 fills, and RtpSource: 12-byte header (V=2, no marker, seq from 0, wall-clock-derived timestamp advancing by ticks_per_packet, SSRC 0xCA110000 + 2*(call-1) + video), payload spliced across the file end when looping, loop count -1 = forever, pause fast-forwards the clock (SIPp TI_PAUSERTP).
  • sipr-media::dtmf: RFC 4733 bursts with SIPp’s exact shapes/timing (20 warm-up PT 97 packets 20 ms apart, per-digit starts every 20 ms at 400 + (k+1)*2*tone + cur with marker on the first and duration = cur*8, three end packets 1 ms apart, one RTP timestamp per event, digits 0-9*#A-D, tone clamped to 50..=2000 else 200). Generated as a synthetic PcapStream and replayed on the audio stream, as SIPp does. Fixed on purpose: sequence numbers are consecutive (SIPp’s warm-up steps by two).
  • Scheduler: Source::{Pcap, Rtp}; generated packet n is due at start + n*interval (burst catch-up, no drift); pause/resume commands per call or per rtp-audio/rtp-video tag.
  • Scenario: rtp_stream="file|apattern|vpattern|pause|resume| pause[av]pattern|resume[av]pattern[,loops|id[,pt[,name]]]"Action::RtpStream, play_dtmf="digits[,tone]"Action::PlayDtmf (a template — keywords render, as SIPp), [rtpstream_audio_port] / [rtpstream_video_port] (+N) keywords. rtp_echo= stays a clear error. Corpus positive/rtp_stream.xml, negative/media_rtp_echo.xml.
  • Engine/CLI: -rtp_payload (default 8), -max_rtp_port, -random_base_ssrc; files loaded and codec parameters validated at startup (fatal, like SIPp); [rtpstream_*_port] allocated per call from -mp in steps of two when first rendered (SIPp’s cursor, minus the trial bind); a stream sends from the port the SDP advertised (the allocated rtpstream port, else the [media_port] form on that m= line) — SIPp binds a fresh unrelated port; DTMF sequence per call from 1200; streams stop with the call.
  • Tests: 12 new unit tests (payload table, WAV skip, patterns, splice/ loop/pause/timestamp math, DTMF shapes, scheduler pacing + pause), compiler tests for the whole grammar, e2e rtp_stream_and_play_dtmf_send_generated_rtp (every packet checked: PT, seq, SSRC, payload, DTMF bodies) and a fatal bad-payload case, interop uac_rtp_stream_against_real_sipp_uas (endless stream vs sipp -rtp_echo, stops with the call).
  • Deferred: exec rtp_echo= SRTP echo control and -rtp_echo’s global echo sockets, -rtpcheck/-audiotolerance (RTP check verdicts, exit -3), SRTP (a=crypto), -key lookups and ~ expansion in media paths, -rtp_threadtasks (meaningless for one scheduler).

M16 — IMS AKA authentication (AKAv1-MD5) ✅

Behavioral oracle: auth.cpp (createAuthHeader, createAuthHeaderAKAv1MD5), milenage.c, message.cpp parseAuthenticationKeyword/getHexStringParam, docs/scenarios/sipauth.rst. Divergences in SIPP_COMPAT §6.

  • In-tree primitives in sipr-auth: AES-128 block encryption (FIPS 197 Appendix B/C vectors), Milenage f1/f1*/f2345/f5* with OPc derivation (3GPP TS 35.208 Test Sets 1 and 2, every output), base64 (RFC 4648 vectors, unpadded input tolerated). No new dependency.
  • Algorithm::AkaV1Md5 (case-insensitive prefix match on algorithm=, as SIPp); aka_challenge_response decodes the nonce as base64(RAND ‖ SQN⊕AK ‖ AMF ‖ MAC-A), recovers SQN, verifies MAC-A against f1, and yields RES/CK/IK; digest_response uses the 8 raw RES bytes as the password (NUL bytes survive — SIPp passes RESLEN explicitly for the same reason). authorization_header now returns Result.
  • [authentication ... aka_K= aka_OP= aka_AMF=] with SIPp’s 0x hex values (exact length enforced — SIPp never validates) or raw bytes; aka_OPc= as a sipr addition (SIPp only takes OP); SIPp’s documented fallback of K = first 16 password bytes honoured when the password is long enough; missing OP/OPc, a malformed nonce, or a MAC mismatch fails the call with a clear reason — SIPp aborts the whole process. XMAC uses aka_AMF when given (SIPp always) else AUTN’s AMF.
  • Tests: unit (AES, Milenage, base64, a full AKAv1-MD5 digest over Test Set 1 incl. wrong-AMF/bad-nonce/no-keys errors), corpus positive/register_aka.xml (SIPp’s documented example), e2e aka_v1_md5_registration_round_trips (a registrar built from Test Set 1 verifies the response with RES) and aka_with_the_wrong_key_fails_the_call_not_the_process.
  • Deferred: AUTS resynchronisation (dead code in SIPp: if (1/*…*/)), AKAv2-MD5 (SIPp rejects it too), -auth_uri, keyword-rendered aka_* values (SIPp renders them as sub-messages so [field0] works; sipr takes them literally for now), AKA as a challenging server.

M17 — Runtime control: SIPp’s control socket + the HTTP API ✅

Behavioral oracle: socket.cpp setup_ctrl_socket / handle_ctrl_socket / process_command / process_set / process_trace / process_key, docs/controlling.rst. gossipper’s HTTP API studied for shape (its single Summary struct for live + final stats, partial-update control POST, and token-via-query for browsers were copied; its three /stats shapes, unitless nanosecond durations, open-by-default bind, and missing quit/limit controls were avoided). Spec in docs/CONTROL_API.md.

  • New std-only crate sipr-control: SIPp’s command grammar with SIPp’s warning texts (command.rs: byte-0 hot key vs c + set rate| rate-scale|users|limit|display|hide / trace error|messages|logs| shortmessages on|off / dump tasks|variables / reset stats, first-space tokenization, strtol base-0 numbers), the UDP socket (udp.rs: -cp tried once and fatal, else 8888..8947 probed and a warning; fire-and-forget), a tiny HTTP/1.1 server (http.rs), a minimal JSON reader/writer (json.rs), and the API routes (api.rs).
  • Engine: Event::Control; every command runs on the event-loop thread and answers over a reply channel (HTTP) or not at all (UDP, as SIPp). Hot keys now follow SIPp: rate keys step by rate-scale and act on the user count in -users mode, q twice = Q. set users grows the id pool or lets excess calls finish; set limit updates the cap; trace messages|error on|off opens/closes trace files at runtime with SIPp’s names; dump tasks lists active calls in the error trace; reset stats zeroes counters and histograms (new StatSet::reset). Mode-dependent refusals use SIPp’s exact wording.
  • HTTP API (--sipr-http [HOST:]PORT, --sipr-http-token): /health, /stats (the once-a-second snapshot the TUI renders, SIPp counter names, _ms durations), /control GET/POST (partial update), /quit (drain or force), /command (any control-socket line), /scenario. Bearer or ?token= with constant-time compare; a non-loopback bind without a token is refused at startup.
  • Divergences (SIPP_COMPAT §6): control socket defaults to loopback (SIPp: every interface), -cp 0 disables it, the chosen port is printed, screen digits are ignored, set display ooc|rx and trace logs|shortmessages warn instead of silently doing nothing.
  • Tests: unit (grammar with SIPp’s errors, JSON round trips, HTTP parsing, UDP datagrams → requests + warnings, every API route against a fake engine incl. auth/405/404), e2e control_socket_speaks_sipp_protocol (a cset rate datagram finishes a slow run, q drains early, a bad command warns), http_api_reports_stats_and_controls_the_run, and the token gate.
  • Deferred: set hide/display semantics in the TUI, a streaming endpoint, scenario hot-replace, Prometheus.

M18 — RTP echo and the RTP check ✅

Behavioral oracle: sipp.cpp rtp_echo_thread / setup_media_sockets / bind_rtp_sockets / sipp_exit, rtpstream.cpp rtpstream_playrtptask (post-send recv + compare) and the thread-exit verdict, call.cpp E_AT_RTP_ECHO, scenario.cpp <rtp_echo>. Divergences in SIPP_COMPAT §6.

  • -rtp_echo / -mb: sipr-media::echo binds the media port and +2 (probing upward in steps of two, like SIPp, and the port that bound is what [media_port] renders), two threads with SIPp’s 100 ms receive timeout echo every datagram to its sender; counters rtp_echo_packets / rtp_echo2_packets (SIPp’s 1st/2nd stream) on the TUI, -bg line, and /stats.
  • <rtp_echo value="0|1"/> action → Action::RtpEchoState: flips the process-wide switch (SIPp rtp_echo_state); a scenario using it without -rtp_echo gets a startup warning. variable= is rejected.
  • The RTP check: generated streams’ sockets are non-blocking and after every send the scheduler drains what came back, comparing the last datagram’s payload to the one just sent (SIPp’s semantics — an echo lags a packet, so only constant-payload patterns pass); tallies per stream travel as MediaEvent::CheckResult when the stream ends. -audiotolerance / -videotolerance (0.0..=1.0): failed/sent ≥ tolerance fails the check. rtp_check_ok / rtp_check_failed / rtp_bytes_received stats; a failed check makes the exit code 253 (SIPp’s EXIT_RTPCHECK_FAILED = -3 as the shell sees it) and the summary says rtpcheck N/M failed. Tallies of streams ending with their calls are collected at shutdown before the report.
  • Deliberate divergence: sipr judges a stream only when a tolerance flag was given. SIPp judges always with a default of 1.0, so any rtp_stream run against a peer that does not echo exits -3.
  • Tests: echo unit tests (both sockets, counters, the toggle, probing past a taken port), a scheduler test proving the check passes against an echo peer, e2e sipr-vs-sipr rtp_echo_uas_makes_the_uac_rtpcheck_pass, the silent-peer 253 case (and its non-judged 0 twin), the missing -rtp_echo warning, interop rtpcheck_against_real_sipp_echo (sipp -rtp_echo echoes, sipr passes 1/1).
  • Deferred: exec rtp_echo=startaudio|… (SIPp’s per-call SRTP echo threads — SRTP is out of scope), -rtpcheck_debug hex dumps.

M19 — AKA resynchronisation (AUTS) ✅

Behavioral oracle: RFC 3310 §3.2 and 3GPP TS 33.102 §6.3.3 — SIPp’s own resync branch is dead code (auth.cpp if (1/*…*/)), so this is the real flow SIPp only sketched. Divergences (all additions) in SIPP_COMPAT §6.

  • sipr-auth: AkaKeys gains sqn_ms (the client’s highest accepted SQN) and force_resync; aka_challenge_response still verifies MAC-A first, then — when the challenge’s SQN is not above SQN_MS, or when forced — computes AUTS = (SQN_MS ⊕ AK*) ‖ MAC-S with AK* = f5*(RAND) and MAC-S = f1*(K, RAND, SQN_MS, AMF* = 0x0000). authorization_header then carries auts="base64(AUTS)" and a digest computed with the empty password, as RFC 3310 requires. A forced resync without sqn_ms echoes the challenge’s own SQN.
  • Keyword params (sipr additions): aka_sqn=0x<12 hex> (SQN_MS) and aka_resync=1 (force AUTS on every challenge, to exercise a server’s resync path). Corpus positive/register_aka_resync.xml.
  • Tests: unit (AUTS bytes verified against f5*/f1* over Test Set 1, empty-password response, forced resync), e2e aka_resynchronisation_round_trips — a registrar at the client’s SQN_MS rejects nothing but gets AUTS, verifies it (SQN_MS recovered with AK*, MAC-S with AMF* = 0, empty-password digest), re-challenges at SQN_MS + 1, and accepts the RES digest.
  • Deferred: a full TS 33.102 Annex C window (Δ, wrap-around; sipr uses “must be greater than SQN_MS”), keyword-rendered aka_* values, -auth_uri.

M20 — Rate ramps (-rate_increase, -rate_max, -rate_interval, -no_rate_quit) ✅

Behavioral oracle: ratetask.cpp (ratetask::run/wake), sipp.cpp option table (SIPP_OPTION_TIME_SEC), include/sipp.hpp defaults.

  • -rate_increase N: a ramp that exists only when set; every -rate_interval (SIPp time values: seconds, or ms/s/m/h suffixes; default the -fd interval) rate += N. -rate_max N: a tick that would exceed it clamps the rate to N and, unless -no_rate_quit, quits with a drain (SIPp quitting += 10). Reaching the cap exactly does not quit — only the next tick does (ramp_step, unit-tested against SIPp’s arithmetic). The task dies once quitting and is inert in -users mode, as in SIPp. -rate_scale N (SIPp’s CLI flag for the + - * / step) added too.
  • Tests: unit ramp_step_follows_sipp_ratetask, e2e rate_increase_ramps_the_rate_up (a 1 cps run finishes 40 calls in seconds after the ramp) and rate_max_quits_when_exceeded_unless_no_rate_quit (both branches).
  • Note: sipr’s -fd default is 1 s, so an unqualified ramp ticks every second; SIPp’s -fd default is 60 s. Give -rate_interval explicitly for scripts shared between the two.

M21 — -auth_uri and rendered [authentication] parameters ✅

Behavioral oracle: call.cpp (~l.4149-4170: the sip: + -auth_uri / remote_ip:remote_port digest uri, and the per-parameter createSendingMessage rendering), message.cpp parseAuthenticationKeyword, sipp.cpp option table.

  • The digest uri= now follows SIPp exactly: sip: + (-auth_uri, else remote_ip:remote_port). sipr used to sign sip:service@ip:port — a visible-on-the-wire difference, now gone. SIPp’s sip:sip:… quirk for a value that already carries a scheme is kept for fidelity and warned about at startup.
  • Every [authentication] parameter value is rendered as a sub-message before use (username=[field0] password=[field1], aka_K=[$k], aka_sqn=[field3]…), as SIPp does; the compiler registers the [$var] reads inside them so read-never-set diagnostics still fire.
  • Tests: e2e authentication_params_render_keywords (credentials from an -inf file verify against the digest registrar), auth_uri_flag_and_default_follow_sipp (the message trace shows uri="sip:ip:port" by default and uri="sip:ims.example.com" with the flag).
  • Deferred: a whole [authentication …] keyword arriving from an injection field (SIPp re-parses rendered text at runtime).

M22 — TUI hide / display and SIPp’s screen keys ✅

Behavioral oracle: scenario.cpp (~l.1852: hide bool and display text read for every message command, though the DTD lists display only on nop), screen.cpp (do_hide, default true; hidden rows skipped on the scenario screen), socket.cpp process_key (1..9 screens).

  • hide="true" and display="…" on any message command land in StepCommon; display replaces the derived scenario-screen label, hide marks the row. set hide true|false (control socket / HTTP /command) now has its SIPp effect: hidden rows are skipped while it is true (the default). Both flags reach /stats (hidden per step, hide overall) so headless runs can see them too.
  • Screen keys: 1 scenario, 2 statistics, 3 repartition work at the TUI keyboard and over the control socket (forwarded through the snapshot as a sequenced request); 4/5 (variables, TDM map) and 6..9 (secondary repartitions) have no sipr screen and are ignored. s still cycles.
  • Tests: compiler (hide/display on recv, nop, pause; blank display is none), TUI render (hidden rows follow the switch; digit mapping), corpus positive/hide_display.xml, e2e hidden_steps_and_display_labels_reach_the_stats_api (display label and hidden flag in /stats; set hide false over /command flips hide).
  • Deferred: -hide CLI default. (set display ooc shipped with M33, set display rx with M34.)

M23 — SRTP (SDES) ✅

Behavioral oracle: jlsrtp.cpp/jlsrtp.hpp (JLSRTP: AES-CM-128 or NULL cipher, HMAC-SHA1 80/32, kdr 0, no MKI, no replay list, no SRTCP, 12-byte header), call.cpp (crypto keywords ~l.2860-3300, extract_srtp_remote_info ~l.564-886, session state machine, swapCrypto on answer), message.cpp keyword table, rtpstream.cpp (SRTP in the sender’s echo check and the per-call echo), SIPp’s pfca_*crypto* scenarios. Divergences in SIPP_COMPAT §6.

  • Crypto in-tree, no new dependency: SHA-1 + HMAC-SHA1 (FIPS / RFC 2202 vectors), AES-CM keystream and the RFC 3711 §4.3 KDF (Appendix B vectors) in sipr-auth::srtp_kdf; sipr-media::srtp — the four suites, SDES inline: encode/decode (40 base64 chars, |lifetime|MKI ignored), SrtpContext::protect/unprotect with RFC 3711 §3.3.1 ROC estimation, UNENCRYPTED_SRTP (authenticate only). sipr-media now depends on sipr-auth.
  • SIPp’s keywords verbatim: [cryptotag{1,2}{audio,video}], [cryptosuite{aescm128sha180,aescm128sha132,nullsha180,nullsha132}{1,2}{audio,video}], [cryptokeyparams{1,2}{audio,video}] (+ the -N offset that reuses the key on re-INVITE), [ue{aescm128sha180,aescm128sha132}{1,2}{audio,video}]UNENCRYPTED_SRTP. Keys are generated before rendering (a prepare_crypto pass, like rtpstream ports), from the seeded RNG.
  • SDP: the first two a=crypto: lines of the live m= section are the peer’s primary/secondary (sdp::crypto_attributes). Negotiation at stream start: send under the local slot whose suite the peer’s primary names (slot 2 only if slot 1 does not match — SIPp’s swap), receive under the peer’s primary; no peer line → plain RTP; an unsupported suite or undecodable key logs and falls back to plain.
  • Media thread: generated streams are protected on send and the echo check unprotects with the peer’s key before comparing plaintext (an auth failure counts as a miss). pcap replays stay as captured.
  • -srtpcheck_debug / -rtpcheck_debug accepted as no-ops.
  • Tests: KDF/keystream/HMAC vectors, transform round trips for every suite incl. rollover and tampering, SDP crypto parsing, keyword tokenizing, corpus positive/srtp_sdes.xml (two suites, pattern stream, reuse on re-INVITE), e2e srtp_stream_passes_the_echo_check_against_an_srtp_echo_peer (a scripted peer that re-keys the echo, as SIPp’s does), interop srtp_against_real_sipp_echo (sipr’s SDES offer + PRACK against pfca_uas_audio_crypto_simple.xml; self-skips without the SIPp tree).
  • Interop finding: sipp’s -srtpcheck_debug log proves it accepts sipr’s SRTP (rc == 0 on every packet), but its echo sendto fails with EISCONN on macOS (connected socket + explicit address), so the interop test asserts SIPp’s acceptance and notes the missing echo; the e2e SRTP echo peer covers the round trip.
  • Found by the interop run: the CSeq-method guard kept only the last sent method, so the INVITE’s 200 after a PRACK was “unexpected”; it now concatenates every sent method like SIPp’s recv_response_for_cseq_method_list (SIPP_COMPAT §6 corrected).
  • Deferred: exec rtp_echo=start… (sipr as an SRTP echo server), SRTCP, MKI, per-call video crypto beyond the keywords, SRTP on pcap replays.

M24 — [authentication] from an injection field ✅

Behavioral oracle: call.cpp E_Message_Injection (~l.4022-4045) and the header-line rendering (~l.4149-4155); docs/scenarios/sipauth.rst (“Make a CSV like this…”).

  • A [fieldN] whose text contains [authentication …] is re-parsed as the keyword at send time (up to the first ], as SIPp’s temporary NUL does); the rest of the field stays literal. So a CSV column can hold per-call credentials or AKA secrets, exactly SIPp’s recipe.
  • Found on the way and fixed: SIPp’s [authentication] renders the whole header line (Authorization: for a 401, Proxy-Authorization: for a 407) and its scenarios put the keyword on a line of its own; sipr rendered only the value, so a real SIPp scenario produced a nameless header. sipr now renders the full line like SIPp and still accepts its older Authorization: [authentication] spelling (the name already on the line → value only).
  • Tests: e2e authentication_keyword_from_an_injection_field (SIPp’s documented CSV + [field1] on its own line) and bare_authentication_keyword_renders_the_full_header_line; the existing Authorization: [authentication …] tests keep passing.
  • Deferred: SIPp’s “only one [authentication] per message” error.

M25 — SRTP echo server (exec rtp_echo=) ✅

Behavioral oracle: actions.cpp setRTPEchoActInfo (grammar <verb>,<payload_type>,<payload_name>), scenario.cpp ~l.1729 (verbs by prefix: startaudio/updateaudio/stopaudio and the …video trio), rtpstream.cpp rtpstream_audioecho_thread/rtpstream_videoecho_thread (~l.2519-2665), and SIPp’s own pair pfca_uas_audio_crypto_simple.xml / pfca_uac_apattern_crypto_simple.xml.

  • exec rtp_echo="<verb>[,pt[,name]]" compiles to Action::RtpEcho (RtpEchoCmd{verb, video, payload_type, payload_name}); unknown verbs, a payload type > 127 and a codec SIPp would not know (via RtpParams::resolve, checked at load) are errors, as in SIPp.
  • start: one echo thread per (call, audio|video) bound to the port the call advertised ([rtpstream_*_port], else the [media_port] form), keyed from CallCrypto::negotiate — receive under the peer’s SDES key, re-protect under ours keeping the caller’s SSRC and sequence numbers, send_to the packet’s source. Plain RTP when the peer offered no crypto. update: restart with the current negotiation (SIPp re-derives keys in place). stop, call teardown: the thread is woken and joined so the port is free at once. Counters feed rtp_echo_packets/ rtp_echo2_packets alongside the global -rtp_echo echo.
  • Found on the way and fixed: ereg search_in="hdr" handed the regexp whole Name: value lines and could not match SIPp’s header="CSeq:" spelling at all; SIPp’s extractSubMessage yields the rest of the first line after the header string (leading space included) and fails the call under check_it when the header is absent. SIPp’s UAS scenarios replay CSeq: [$1] from that capture.
  • Tests: media unit (plain echo, SRTP re-key round trip with the caller’s key, unauthenticated packet dropped, port released synchronously); compile rtp_echo_exec_parses_sipp_verbs; corpus positive/srtp_echo_uas.xml; e2e srtp_echo_server_passes_a_peers_echo_check (sipr UAC’s rtpcheck against a sipr echo server) and rtp_echo_with_an_unknown_codec_fails_at_load; interop real_sipp_srtp_uac_against_sipr_echo_server — real sipp plays its SRTP UAC scenario against sipr running SIPp’s UAS scenario unchanged and passes its own RTP check (exit 0).
  • Deferred: SIPp’s per-process echo state shared across calls (its echo threads are global singletons; sipr’s are per call), forwarding of packets that fail authentication (sipr drops them).

M26 — <verifyauth>

Behavioral oracle: scenario.cpp ~l.1572 (attributes assign_to, username, password), call.cpp ~l.5946 (E_AT_VERIFY_AUTH: method from the start line, Authorization: only, body for auth-int), auth.cpp verifyAuthHeader + createAuthResponseMD5/SHA256, and its gtests (DigestAuth.BasicVerification*); docs/scenarios/actions.rst recipe.

  • <verifyauth assign_to= username= password=/> compiles to Action::VerifyAuth with both credentials as message templates (rendered at execution, [$var]/[fieldN] allowed).
  • sipr_auth::verify_authorization: MD5 and SHA-256, with or without qop (cnonce present selects the RFC 2617 form, as SIPp), auth-int body hashing, -auth_uri override of the header’s uri=; non-Digest and other algorithms are typed errors → false + a warning line.
  • Engine: the verdict is stored as a boolean (test= branches on it); the method is the received start line’s first token, the credential the first Authorization: header.
  • Tests: auth unit (SIPp’s own MD5 and SHA-256 vectors, sipr’s qop=auth header, -auth_uri, auth-int, scheme/algorithm errors); compile verifyauth_compiles_with_templated_credentials; e2e verifyauth_accepts_the_right_password_and_branches_to_200 / …rejects_a_wrong_password_and_branches_to_403 (SIPp’s registrar recipe verbatim, branching with test=/next=); interop sipr_verifyauth_judges_real_sipp_credentials and real_sipp_verifyauth_judges_sipr_credentials — both directions, right and wrong password.
  • Deferred: SIPp’s TRACE_CALLDEBUG line with the expected and received response values.

M27 — _unexp.main handler, pauserestore, jump variable=, closecon

Behavioral oracle: scenario.cpp ~l.1065 (_unexp.main, _unexp.retaddr, _unexp.pausedaddr), ~l.1344 handle_rhs; call.cpp ~l.5449 (the jump on an unexpected message, queue_up), ~l.1975 / ~l.2315 (paused_until), ~l.6003 (E_AT_PAUSE_RESTORE), ~l.5836 (E_AT_CLOSE_CON); socket.cpp SIPpSocket::close refcount; docs/scenarios/actions.rst (jump).

  • <label id="_unexp.main"/> turns an unexpected in-call message into a jump: _unexp.retaddr ← interrupted index, _unexp.pausedaddr ← the running pause’s deadline (ms since start, 0 = none), timers cancelled, the message re-offered to the handler’s <recv>; refused while _unexp.retaddr is non-zero (SIPp’s “already in a jump”).
  • <jump value=|variable=> (SIPp’s handle_rhs; the variable form is the recipe’s return); out of range fails the call.
  • <pauserestore value=|variable=>: the deadline is served before the next step executes and that step is then skipped (run() + next()), so an interrupted <pause> resumes for exactly its remaining time.
  • <closecon/>: accepted as a no-op — in SIPp it is a reference-count drop that never closes a mono-socket transport (§6 note). The per-call socket modes (un/tn/ln) stay out of scope.
  • Tests: compile unexp_handler_pauserestore_jump_variable_and_closecon_compile + corpus positive/unexp_handler.xml (SIPp-loadable); e2e unexp_handler_restores_the_interrupted_pause (an INFO 0.5 s into a 3 s pause; the BYE after the pause must come ~2.5 s later, and the run must last the full 3 s) and closecon_is_accepted_over_tcp; interop sipr_unexp_handler_against_real_sipp_uac and real_sipp_unexp_handler_against_sipr_uac — the same corpus scenario played by each tool against the other’s INFO.
  • Deferred: un/tn/ln per-call sockets (the only mode where closecon closes a connection), -rsa.

M28 — per-call sockets: -t un|tn|ln, -max_socket

Behavioral oracle: sipp.cpp ~l.1660 (multisocket), call.cpp connect_socket_if_needed ~l.1419 / createSendingMessage ~l.1737 / E_Message_Local_Port ~l.2753, socket.cpp new_sipp_call_socket ~l.1340 and the call-creation branches ~l.1148-1185.

  • sipr-net: UdpTransport::open_call_socket (own recv thread, woken and reaped on drop), TcpTransport::client_pool + connect_call, TlsTransport::client_pool + connect_call (handshake per call), send_via on each; a dropped call socket/connection closes.
  • Engine: a client call opens (or, past -max_socket, shares round-robin) its socket at its first send; sends and retransmissions go out on it; [local_port] renders its port; the socket closes with the last call holding it; <closecon/> drops the reference and the next send opens a fresh one. Servers keep the socket the call arrived on, as SIPp. A per-call connect failure fails only that call.
  • CLI: -t un|tn|ln, -max_socket <n> (≥ 1, default 50000); -t ui stays a clear error.
  • Tests: net unit (per_call_socket_round_trips_and_closes, per_call_connections_are_distinct_and_close_on_drop); e2e udp_per_call_sockets_give_each_call_its_own_port (source port == Via port, all distinct), max_socket_makes_calls_share_sockets, tcp_per_call_connections_one_per_call (a counting TCP UAS), tls_per_call_connections_complete_calls; interop sipr_per_call_sockets_against_real_sipp_uas (un, tn) and real_sipp_per_call_uac_against_sipr_uas (un, tn).
  • Deferred: -t ui, -rsa, -max_reconnect/-reconnect_close/ -reconnect_sleep, SCTP.

M29 — -rsa remote sending address ✅

Behavioral oracle: sipp.cpp ~l.1827, call_generation_task.cpp ~l.152, socket.cpp ~l.1146-1230 / ~l.2588, call.cpp ~l.1489 / send_raw ~l.1570-1600 / E_Message_Remote_IP ~l.2741.

  • -rsa host[:port] (default 5060) resolves like the target; a UAC’s calls send there (mono and per-call TCP/TLS dial it), a UAS’s calls answer there from a socket of their own (shared, or per call under un/tn/ln), and [remote_ip]/[remote_port]/digest uri= keep rendering the nominal remote (CallState::render_remote).
  • Tests: CLI parse; e2e rsa_uac_sends_to_the_sending_address_but_renders_the_target, rsa_uas_answers_towards_the_sending_address (responses reach the rsa address from a non--p port, the caller gets nothing), rsa_tcp_uas_dials_the_sending_address; interop rsa_both_ways_against_real_sipp (sipr UAC -rsa → sipp, sipp UAC -rsa → sipr, sipp UAS -rsa answering sipr from its extra socket).
  • Deferred: [remote_ip] on a UAS follows SIPp’s remote_ip global.

M30 — TCP/TLS reconnection: -max_reconnect, -reconnect_close, -reconnect_sleep

Behavioral oracle: socket.cpp reconnect_allowed ~l.2257, reset_connection ~l.2265, close_calls, the recv/send error paths ~l.1866-1880 / ~l.1940-1970, write_primitive ~l.2098; sipp.cpp ~l.551, ~l.635; docs/transport.rst “TCP reconnections”.

  • sipr-net: NetEvent::Disconnected { peer, local, clean } from every TCP/TLS read loop (clean = FIN / close_notify), TcpTransport::reconnect and TlsTransport::reconnect re-dialing the mono connection under the same peer key.
  • Engine: SIPp’s reset in SIPp’s order — a clean close invalidates the mono connection and (under -reconnect_close) closes its calls; the call whose send next hits it fails (“cannot send message”), then the socket is re-dialed within the -max_reconnect budget after -reconnect_sleep, or the run ends with exit 255 (“Max number of reconnections reached”); an error close resets at once. The reader only reports a connection’s end and the engine forgets it when it processes the event, so a queued ACK still leaves on the half-closed socket as SIPp’s does. Counters failed_cannot_send / failed_tcp_closed / failed_tcp_connect; RunReport::fatal. Servers close the affected calls only; per-call connections re-dial lazily.
  • CLI: -max_reconnect <n> (default 0, -1 unlimited), -reconnect_close true|false (default true), -reconnect_sleep <ms> (default 1000).
  • Tests: net unit disconnect_is_reported_and_reconnect_restores_sending; e2e against a hanging-up TCP UAS: reconnect_between_calls_with_budget (the call that finds the socket dead fails, the next completes on the new connection), no_reconnect_budget_is_fatal_like_sipp (exit 255), reconnect_close_fails_the_interrupted_call, reconnect_close_false_keeps_the_interrupted_call (its BYE goes out on the connection another call’s failure re-dialed); interop sipr_tcp_uac_reconnects_to_real_sipp and real_sipp_tcp_uac_reconnects_to_sipr (the UAS is restarted between two calls).
  • Deferred: budgeted re-dial of per-call connections; a start-up connect failure consuming the budget; a UAS re-dialing its client.

M31 — -t ui: one UDP socket per injected IP, -ip_field, [server_ip]

Behavioral oracle: sipp.cpp ~l.316/~l.1572/~l.1996, socket.cpp open_connections ~l.2466-2560, call.cpp connect_socket_if_needed ~l.1430-1475 and E_Message_Server_IP ~l.2768, docs/transport.rst “UDP with one socket per IP address”.

  • -t ui (UDP only, needs -inf) and -ip_field <n> (default 0): the main socket binds line 0’s IP; a client call sends from the socket of the IP in its own line (created once, kept for the run; unbindable → fatal); a server binds every distinct listed IP on the same port and answers on the socket a request arrived on.
  • [server_ip]: the IP of the socket the call sends from (InboundPacket::local carries the receiving address for every transport).
  • Tests: net unit call_socket_at_binds_the_given_address_and_packets_carry_local; CLI parse; e2e ui_client_sends_each_call_from_its_lines_ip (source IP alternates with the file, [server_ip] in the Via matches it) and ui_server_answers_on_the_ip_the_request_hit; interop with real sipp’s -t ui in both roles (skipped when the host has no second local IPv4 address).
  • Deferred: host names in the IP column.

M32 — SCTP -t s1|sn behind the sctp cargo feature ✅ (verified only in Linux CI)

Decision (owner, 2026-09-05): option A — socket2 as a sanctioned dependency, used only behind an off-by-default sctp feature; SCTP-specific socket options stay out of scope. Findings that led here are in the git history of this section (macOS has no SCTP stack and the local sipp lacks USE_SCTP; Rust std has no SCTP; libc FFI would need an unsafe exception).

Behavioral oracle: sipp.cpp ~l.209-243, socket.cpp ~l.806-850 (notify), ~l.888-905 (sctp_recvmsg, one SIP message per SCTP message), ~l.1575-1590 (connect), ~l.1694-1775 (peer params, SCTP_EVENTS, SCTP_NODELAY).

  • sipr-net::sctp (feature sctp): one-to-one SOCK_STREAM/ IPPROTO_SCTP sockets via socket2; blocking connect returns at association-up (SIPp’s SCTP_COMM_UP gating); each read is one SCTP message = one SIP message (no Content-Length framing); s1 mono and sn per-call associations, reconnect/forget, disconnect reports — the same shape as the TCP transport. available() probes the kernel at run time; the module compiles on every OS.
  • Engine: TransportKind::SctpMono|SctpPerCall, [transport] = SCTP, reliable (no retransmissions), per-call pool, reconnection. Without the feature or without a stack, -t s1 is a clear start-up error (SIPp: “SCTP support is not enabled!”).
  • CLI: -t s1|sn; SIPp’s -multihome, -heartbeat, -assocmaxret, -pathmaxret, -pmtu, -gracefulclose are rejected with a message naming why (SCTP socket options socket2 cannot set).
  • Tests: net unit messages_keep_their_boundaries_and_round_trip (skips without a stack), e2e sctp_mono_and_per_call_calls_complete (skips) and sctp_without_a_stack_or_feature_is_a_clear_error, interop sctp_both_ways_against_real_sipp (skips unless sipp banners -SCTP). CI job sctp on ubuntu: modprobe sctp, sipp built from source with USE_SCTP, cargo test --features sctp, the interop test.
  • Verified in Linux CI (run 33977957760, 2026-09-05): sctp job green — messages_keep_their_boundaries_and_round_trip, sctp_mono_and_per_call_calls_complete, and sctp_both_ways_against_real_sipp all ran (not skipped) against a SIPp 3.7.7 built with USE_SCTP. The development host (macOS) still cannot run them.
  • Deferred for good: SCTP_NODELAY, notifications, per-path parameters, multi-homing, SHUTDOWN-vs-ABORT.

Post-v1 backlog (ordered)

(Checked after M30: the pacer’s first call comes one inter-call interval after start-up in SIPp too — call_generation_task.cpp opens calls when elapsed × rate / rate_period reaches the count; no divergence, see SIPP_COMPAT §6.)

M33 — Out-of-call scenarios: -oocsf, -oocsn, set display ooc

Behavioral oracle: sipp.cpp ~l.177 (option table), ~l.1792-1800 (parse: -oocsf <file> loads a scenario file, -oocsn <name> an embedded one), ~l.2113-2116 (the ooc_default fallback is commented out — with no -oocs* flag a UAC keeps discarding unmapped requests with the “Discarding message which can’t be mapped to a known SIPp call” warning), ~l.2147-2149 (fatal in server mode: “SIPp cannot use out-of-call scenarios when running in server mode”); socket.cpp ~l.1195-1217 (dispatch: a request whose Call-ID matches no call, in client mode, spawns a call on the ooc scenario with no user id, counts E_CREATE_INCOMING_CALL on the ooc scenario’s stats plus the global E_AUTO_ANSWERED, logs the “Received out-of-call %s message, using the out-of-call scenario” warning and feeds it the message; an unmapped response only counts E_OUT_OF_CALL_MSGS), ~l.191 (set display ooc swaps the TUI scenario); call.cpp ~l.6641 (ooc calls may not use -inf: “Automatic calls (created by -aa, -oocsn or -oocsf) cannot use input files!”); scenario.cpp ~l.1933 (embedded names ooc_default, ooc_dummy); docs/int_scenarios.rst “UAC Out-of-call Messages” and docs/ooc_default.xml (recv request=".*" regexp_match="true", send 200 with [last_*] copies and a Contact, timewait 4000).

  • -oocsf <file> / -oocsn <name>: a second, independently compiled scenario next to the main one (own variable table, own per-step stats and repartitions — OocScenario in engine.rs). Client mode only — fatal at startup in server mode with SIPp’s wording. Mutually exclusive with each other (usage error). The ooc scenario may not use <sendCmd>/<recvCmd> (startup error, sipr addition).
  • Embedded ooc_default (SIPp’s XML, sipr’s own comment header like uac/uas) and ooc_dummy; -sd ooc_default|ooc_dummy dumps them and -sn accepts them too. With no -oocs* flag sipr keeps the discard-and-count path (SIPp’s commented-out fallback; SIPP_COMPAT §6).
  • Dispatch in engine.rs (on_packetspawn_ooc_call): an unmapped request in client mode creates a call on the ooc scenario keyed by the incoming Call-ID, remote = the packet source (or -rsa), no user id, no injection line, replying on the per-IP/per-call socket the request hit when there is one; runs it from step 0 with the request as the first inbound message; counts an incoming call on the ooc stats and bumps the global auto-answered counter; logs SIPp’s warning. Unmapped responses stay ignored. Ooc calls never count toward -m/-l/-users (live_main), and — correcting the entry above — SIPp’s open_calls ignores them for the end of the run too, so the run ends when the main calls are done and lingering ooc calls are dropped (SIPp additionally BYEs them from its generic exit abort; not reproduced).
  • [fieldN] in an ooc scenario is a startup error with SIPp’s wording (“Automatic calls (created by -aa, -oocsn or -oocsf) cannot use input files!”); [userid] renders 0.
  • TUI + control: set display ooc|main over the control socket (SIPp has no screen key for it — sipp.cpp key switch verified) swaps the scenario page to the ooc scenario’s steps (Snapshot::display_ooc, HTTP /stats display field); the statistics stay the main scenario’s. Correcting the entry above: SIPp never dumps ooc stats to CSV (reporttask.cpp stattask::report dumps main_scenario only), so neither does sipr.
  • --check lints the ooc scenario with the same rules and prints its IR after the main one; unknown elements are hard errors there too.
  • Found on the way and fixed: regexp_match="true" was compiled but never applied by the engine’s matcher (recv_matches compared literally), so ooc_default’s request=".*" matched nothing. Now the regex runs over the method / decimal status code as in SIPp’s matches_scenario (unit test regexp_match_searches_the_method_and_the_status_code).
  • Tests: scenario unit (embedded ooc scenarios parse; [fieldN] detection); CLI unit + binary (-oocsf/-oocsn parse and conflict, server-mode fatal, injection fatal, unknown name, -sd, --check); e2e uac_answers_out_of_call_options_with_ooc_scenario (ooc_default answers with the copied headers and the main flow is clean; no flag → discarded and counted; ooc_dummy → spawned, failed on the ooc stats, unanswered) and set_display_ooc_swaps_the_scenario_screen; interop real_sipp_ooc_scenario_answers_siprs_out_of_call_options and sipr_ooc_scenario_answers_real_sipps_out_of_call_options, both green against SIPp 3.7.x.
  • Docs: SIPP_COMPAT §3 flags and §6 behaviour note (commented-out default, client-only, no -inf, unmapped responses never spawn, the two SIPp quirks seen in interop), CONTROL_API, ARCHITECTURE “two scenarios, one engine”, AGENTS state line; M22 deferral updated.
  • Out of scope here (recorded in SIPP_COMPAT §6): -rxsf/-rxinf mixed-mode receive scenario (MODE_MIXED, rx_scenario) — queued as M34.

M34 — Mixed mode: -rxsf/-rxsn receive scenario, -rxinf, set display rx

A UAC that also terminates calls: the main scenario originates, a second server-mode scenario answers whatever the peer originates towards us.

Behavioral oracle: sipp.cpp ~l.174-197 (option table: rxsf and rxrn — the latter a typo; its help text names -snrx/-sfrx, which exist nowhere), ~l.1778-1790 (parse: -rxsf <file> loads a file, -rxsn <name> an embedded one, both set creationMode = MODE_MIXED — but rxsn is missing from the option table, so SIPp rejects it as an unknown option, and -rxrn reaches the “Internal error, I don’t recognize” branch: in SIPp 3.7 only -rxsf works), ~l.307 and ~l.1584-1605 (-rxinf registers the CSV in the shared inFiles map under its basename and sets rx_ip_file/rx_default_file, which nothing ever reads: a [fieldN] without file= in the rx scenario resolves to the first -inf file — message.cpp ~l.294, “No injection file was specified!” without one — and [fieldN file=x.csv] reaches a -rxinf file by basename), ~l.2140-2148 (runInit/computeSippMode run for the main and ooc scenarios only: the rx scenario’s <init> section never runs, and nothing enforces the help text’s “rx MUST be server-mode, main MUST be client-mode”), ~l.2203 (the call generator runs in MIXED as in CLIENT), ~l.556-561 (-m and the end of the run look at main_scenario’s counters only; rx calls are dropped by abort_all_tasks at exit), ~l.1182 (the exit code’s failed/successful counters come from display_scenario — whichever scenario is displayed at exit); scenario.cpp ~l.1249 (computeSippMode keeps MIXED, sendMode still comes from the main scenario); socket.cpp ~l.1184-1195 (dispatch: in MIXED any message whose Call-ID matches no call — request or response, quitting or not (the quitting check is commented out) — creates a call on rx_scenario through the UAS constructor, no user id, counts E_CREATE_INCOMING_CALL on the rx stats, logs nothing; the ooc and -aa out-of-call branches are unreachable in MIXED), ~l.193 (set display rx); screen.cpp ~l.83-90 and ~l.242-245 (display_client()/display_server(): header “Sipp Mixed Mode - main - call originating scenario” / “Sipp Mixed mode - rx - call terminating scenario”, server-style columns when rx is displayed), ~l.294, ~l.710, ~l.796 (the main counters, the statistics screen and the repartition screens all read display_scenario->stats); call_generation_task.cpp ~l.106-130 (-l/-users measured on main_scenario). No docs page and no regress test mentions mixed mode.

  • -rxsf <file> / -rxsn <name>: a second, independently compiled server-mode scenario next to the main one (own variable table, own per-step stats and repartitions). Generalise M33’s OocScenario into one secondary-scenario type carrying a role (ooc | rx) so the two share compile, stats, display and --check plumbing. -rxsn accepts uas and the other embedded names as SIPp’s parser intends (record SIPp’s table typo in SIPP_COMPAT §6; do not add -rxrn). -rxsf/-rxsn are mutually exclusive (usage error). Startup checks, all fatal with a clear message (sipr additions — SIPp promises them in its help and enforces none): the main scenario must be client-mode, the rx scenario server-mode (first significant step a <recv>), no <sendCmd>/<recvCmd> in the rx scenario, and -rxs* may not be combined with -oocs* (SIPp silently never reaches the ooc branch in mixed mode — a loud refusal beats a scenario that never fires).
  • Dispatch in engine.rs (on_packet, next to spawn_ooc_call): in mixed mode an unmapped request creates a call on the rx scenario keyed by the incoming Call-ID, remote = the packet source (or -rsa), no user id, replying on the per-IP/per-call socket the request hit; runs it from step 0 with the request as the first inbound message; counts an incoming call on the rx stats. Spawning continues while draining, as in SIPp. Unmapped responses: SIPp spawns an rx call for them too (the UAS quirk M33 already declined to reproduce) — sipr keeps discarding and counting them; record it. No warning line (SIPp logs none) but a -trace_err-level debug line is fine.
  • Rx calls never count toward -m/-l/-users (live_main) and never end the run; the run ends when the main calls are done and lingering rx calls are dropped (SIPp’s exit abort may BYE an established one; not reproduced, as with M33). Exit code: SIPp derives it from whichever scenario is displayed at exit — sipr keeps the main scenario’s counters and records the divergence.
  • -rxinf <file> (repeatable): registers the CSV in the shared injection-file table under its basename, reachable from either scenario with [fieldN file=<basename>]. A bare [fieldN] in the rx scenario resolves to the first -inf file, as in SIPp, and is a startup error without one (SIPp’s wording). Rx calls draw lines the way sipr’s UAS calls do (SEQUENTIAL/RANDOM; USER-mode files behave as they do for a UAS today); [userid] renders 0.
  • <init> in the rx scenario: SIPp never runs it — and sipr has no <init> support at all (an unknown element is a hard error), so there was nothing to decide; recorded in SIPP_COMPAT §6.
  • TUI + control: set display rx|ooc|main over the control socket (HTTP /stats display gains rx); the screen header reads SIPp’s mixed-mode lines. Align the display semantics with SIPp: its main counters, statistics screen and repartition screens all follow display_scenario, with server-style columns when rx is displayed. This corrects M33’s “the statistics stay the main scenario’s” — fix Snapshot::display_ooc (a display: Main|Ooc|Rx enum with the displayed scenario’s counters) so ooc gets the same treatment, and update the M33 wording in SIPP_COMPAT §6, CONTROL_API and the snapshot doc comment. -trace_stat/-stf stay main-only (SIPp stattask::report).
  • --check lints the rx scenario with the same rules and prints its IR after the main (and ooc) one; unknown elements are hard errors.
  • Tests: CLI unit (mixed_mode_flags_parse_and_conflict) + binary (mixed_mode_flags_conflict_and_roles_are_checked, receive_scenario_with_a_bare_field_needs_an_inf_file, check_mode_lints_the_receive_scenario_too); e2e uac_terminates_incoming_calls_with_rx_scenario (sipr UAC on the main scenario against a peer that originates an INVITE mid-run; the rx uas scenario answers 180/200 and the 200 to the BYE with the copied headers, the main flow is clean; without -rxs* the INVITE is discarded and counted), rx_scenario_reads_rxinf_by_file_name (named -rxinf field plus a bare [fieldN] from the first -inf), set_display_rx_swaps_the_screens (role, steps and counters follow; the M33 ooc display test updated to the corrected semantics); interop real_sipp_and_sipr_terminate_each_others_calls_in_mixed_mode (both sides uac + -rxsf/-rxsn uas, three calls each way, a timewait on the main scenario keeping each side up for the peer’s last call) and sipr_receive_scenario_answers_a_plain_real_sipp_uac (sipr mixed between a sipp UAS and a sipp UAC), both green against SIPp 3.7.x.
  • Docs: SIPP_COMPAT §3 flags and §6 behaviour note (SIPp’s -rxsn/-rxrn breakage, -rxinf files unread by SIPp, no rx init, unmapped responses, the exit-code quirk, the corrected display semantics), CONTROL_API, ARCHITECTURE “two scenarios, one engine” → secondary scenarios, README, AGENTS state line; the M22 set display rx deferral and M33’s out-of-scope line updated.

M35 — Dynamic users: <User>/<Global> variable scopes, SIPp’s user-id retirement

Runtime user-count changes already exist (set users N, the + - * / keys, HTTP /control, M17). What is missing is the other half of SIPp’s user model: variables that outlive a call — per user (<User variables="…"/>, one table per user id, userVarMap) and per run (<Global variables="…"/>, one table for the process) — plus SIPp’s exact user-id bookkeeping when the count shrinks and grows again. Today both elements are hard errors in sipr (“unknown element”), so any SIPp scenario using them fails to load.

Behavioral oracle: scenario.cpp ~l.718 (every scenario’s allocVars is a child of userVariables), ~l.756-779 (<Global variables> and <User variables> allocate the comma-separated names in globalVariables/userVariables), ~l.780-790 (<Reference> must name an existing variable); variables.cpp ~l.187-210 (a VariableTable chains to its parent and carries a level), ~l.284-296 (getVar climbs to the level encoded in the variable id), ~l.303-330 (AllocVariableTable::find: the scenario’s own map first, then the parents, then allocate — so a name used before its <User>/<Global> declaration is already call-scoped and the declaration changes nothing for it); sipp.cpp ~l.1450 (userVariables is a child of globalVariables), ~l.2123-2126 (one VariableTable(userVariables) per user id at startup), ~l.1097 (the tables live for the whole run — a retired id keeps its values); call.cpp ~l.1100-1115 (a call with a user id parents its table on userVarMap[userId]; a call without one — UAS, ooc, rx, rate mode — gets a fresh private table, so “user” variables are per call there), ~l.1296 (free_user at call end); call_generation_task.cpp ~l.252-290 (set_users: growth takes retiredUsers first, then users + 1 with a fresh table; users = open_calls_allowed = new; free_user retires an id while CurrentCall > open_calls_allowed, else returns it to the pool); socket.cpp ~l.164-176 (set users wordings, already matched), ~l.407-437 (keys step users by rate_scale, already matched); sipp.dtd (declares Reference only — Global/User are accepted by the parser and absent from the DTD and the docs; the regress suite never uses them).

  • Scenario: <Global variables="a,b"/> and <User variables="x"/> elements (variables required; unknown attributes warn). Each variable id carries a scope — Call (default), User, Global — (VarTable::scope/in_scope, VarScope) resolved at compile time so the hot path never searches; dump() prints the user and global name lists. SIPp’s declaration-order quirk (a use before the declaration stays call-scoped): sipr applies the scope to the whole scenario and warns naming the line of the earlier use, so --check catches what SIPp silently gets wrong (SIPP_COMPAT §6). A name declared both <User> and <Global> is an error. <Reference> keeps rejecting unknown names. A <Global> read but never set is no diagnostic (-set or the other scenario may set it); a <User> one stays the usual error.
  • Engine: a layered variable store (sipr-engine/src/vars.rs) — the call’s own store, the user’s store (by user id, owned by the engine, created when the id is first handed out and kept for the run, SIPp’s userVarMap) and one global store shared by every call of both scenarios (the secondary scenario’s allocVars hangs off the same userVariables). Reads and writes from every action (assign, assignstr, ereg, arithmetic, strcmp, test, lookup, gettimeofday, trim, urlencode/urldecode, todouble, jump variable=) and [$var] rendering go through it by scope. Calls with no user id get a private “user” layer, as SIPp. Single engine thread: no locks, no allocation per access beyond what call-scoped variables do today (VarStore::get returns a VarRef borrowing the layer; the shared layers are Rc<RefCell<…>>). A VarSpace unions the user and global names of both scenarios so one name is one slot across them (SIPp’s shared userVariables/globalVariables); a name scoped differently by the two is a start-up error. Also shipped: SIPp’s -set VARIABLE VALUE (seeds a <Global>; fatal with SIPp’s wording, plus the declared names, when none declares it).
  • User-id bookkeeping like SIPp’s: growth takes retired ids first (so a returning user sees its old variables), then fresh ones; a shrink does not touch the pool — a finishing call’s id is retired while the live count exceeds the target and returned otherwise (SIPp free_user), so whichever users happen to be live keep their ids and injection lines. sipr used to drop ids above the target regardless of liveness; now aligned, including SIPp’s pool order (filled 1..N, served from the back — the first call is user N’s — returned to the front). One divergence, recorded: fresh ids on a growth are never-used numbers, not SIPp’s users + 1 that can collide with a live id after a shrink. [users] keeps rendering the current count.
  • Control: dump variables — SIPp prints the displayed scenario’s variable names by scope (AllocVariableTable::dump); implemented into the error trace with SIPp’s lines (N level 0 variables: … global, user, call).
  • --check prints the scopes; embedded scenarios untouched.
  • Tests: scenario unit (both elements parse; scopes resolve; a use before the declaration warns; <Reference> to an undeclared name still errors); engine unit (a user variable survives into the same user’s next call, a global one is visible to every call, a call variable resets, a UAS call’s user variable does not leak into the next call); e2e user_variables_persist_across_a_users_calls (-users 2 -m 6: the scenario adds 1 to a <User> counter and 1 to a <Global> counter per call and sends both in headers; the peer sees per-user 1,2,3 and global 1..6 in call order), set_users_retires_and_reuses_ids_like_sipp (control socket 3 → 1 → 3 mid-run; the ids seen after the regrow are the ones that were retired, with their counters continuing); interop: the same counter scenario run by real sipp against a sipr UAS and by sipr against a sipp UAS, the header sequences compared.
  • Found on the way, recorded in SIPP_COMPAT §6: SIPp renders double variables as %lf (3.000000, sipr printed 3) and treats a zero double / false bool as unset — fixed in v0.24.0 right after M35; and SIPp’s one-step-per-call-per-turn scheduler interleaves same-tick calls’ <nop> actions before their sends, visible only through globals — left as is.
  • Docs: SIPP_COMPAT §1 (the two elements), §3 (-set), §6 note (scope chain, declaration-order divergence, private user layer for id-less calls, retirement rules, dump variables); ARCHITECTURE variable-store paragraph; README feature bullet; the M11 deferral updated.

M36 — Manual transactions: start_txn, ack_txn, response_txn

Today sipr matches a response to the call’s outstanding request by CSeq method (expected_cseq_method, SIPp’s recv_response_for_cseq_method_list guard), which cannot tell two concurrent transactions of the same method apart — a re-INVITE racing the initial INVITE’s late 200, an UPDATE overlapping another, forked provisional responses. SIPp’s manual transactions name a request’s Via branch so the scenario can say exactly which transaction a recv answers. sipr’s compiler rejects the three attributes today (“not supported yet — v1.x”).

Behavioral oracle: scenario.cpp ~l.343-400 (get_txn: names may not be empty or contain $/,; one txnControlInfo per name with started/ responses/acks counts and isInvite), ~l.878-931 (a send request may carry start_txn (not an ACK: “An ACK message can not start a transaction!”) or ack_txn (only an ACK: “The ack_txn attribute is valid only for ACK messages!”); a send response may carry neither (“Responses can not start a transaction” / “Responses can not ACK a transaction”); response_txn only on recv response= (“response_txn can only be used for received messages.” on a send, “… for received responses.” on recv request=); a request with start_txn/ack_txn is not added to the CSeq-method list), ~l.588-602 (validate_txn_usage: “Transaction %s is never started!”, “… has no responses defined!”, “… is an INVITE transaction without an ACK!”, “… is a non-INVITE transaction with an ACK!”); call.cpp ~l.1128 (per-call txnInstanceInfo: txnID, txnResp hash, ackIndex), ~l.2110-2116 (on send: start_txn stores the sent message’s top-Via branch (extract_transaction, ~l.4431-4450, up to ;/,/space), ack_txn records the ACK’s message index), ~l.4581-4587 (matches_scenario: a recv with response_txn matches only when the response’s top-Via branch equals the stored one — before and instead of the CSeq-method guard; index == 0 and the method list apply only without it), ~l.5395-5430 (a matching response for a recv that is not the current step — an old transaction: a 1xx is ignored with “Ignoring provisional %s message for transaction %s”; a final response to an INVITE transaction re-sends the recorded ACK (ackIndex); a final response to a non-INVITE transaction whose hash equals the stored txnResp is ignored with a WARNING “Ignoring final %s message for transaction %s (hash %lu)”), ~l.5502-5504 (the accepted response’s hash becomes txnResp); docs/scenarios/ownscenarios.rst “start_txn”/“ack_txn”/“response_txn” rows. Note [branch] itself is unchanged by transactions (E_Message_Branch, z9hG4bK-pid-number-index): an ack_txn ACK carries its own branch, as in SIPp.

  • Scenario: the three attributes parse into a per-scenario transaction table (Scenario::transactions: name, is_invite; the use counts live in the compiler) and per-step start_txn: Option<TxnId> / ack_txn: Option<TxnId> on SendStep, response_txn: Option<TxnId> on RecvStep, ids resolved at compile time. All of SIPp’s placement errors above with its wording; validate_txn_usage at finish(). A request step with start_txn/ack_txn stays out of the CSeq-method guard list (precompute_cseq_methods follows suit). The IR dump shows start_txn=name / ack_txn=name / response_txn=name and a transactions: line. sipr addition: start_txn and ack_txn on the same <send> is an error (SIPp silently takes the first).
  • Engine: per-call txns: Vec<TxnInstance> (branch: Option<String>, final_hash: Option<u64>, ack_index: Option<StepIndex>), sized from the scenario table (empty when unused — no cost for the common case). On send: a start_txn step stores the rendered message’s top-Via branch, an ack_txn step its index. On receive (scan_for_match/recv_matches): a response_txn recv matches a response only by branch (parsed once per inbound message, alongside the CSeq method); the first-step and CSeq-method rules apply only to recvs without it. Out-of-window responses to a named transaction follow SIPp: provisional ignored (error-trace line), final to an INVITE transaction re-sends the recorded ACK, a repeat of the accepted final response (same hash — use the message bytes’ hash) ignored with SIPp’s WARNING; the accepted final’s hash is stored. Everything without response_txn behaves exactly as today (Scan::OldTxn, on_old_transaction_response, resend_step over the extracted render_send; the backward scan only walks past the contiguous optional block when the scenario names transactions).
  • --check prints the transaction table; embedded scenarios untouched.
  • Tests: scenario unit (the attributes compile and resolve; each placement error and each validate_txn_usage error with SIPp’s wording; a start_txn request leaves the method list); engine unit (branch extraction from a rendered Via with parameters and commas; recv_matches with a response_txn accepts the branch and rejects a same-method response from another branch); e2e response_txn_matches_the_right_invite_of_two_overlapping_ones (a UAC sends INVITE start_txn="a", then a re-INVITE start_txn="b" before a’s 200 arrives; the scripted UAS answers b first — the scenario’s recv response="200" response_txn="a" waits for the right one and both ACKs (ack_txn) go out; without the attributes the same flow mis-matches, proving the point) and late_final_response_to_a_named_invite_transaction_is_acked_again (after an INFO round trip the UAS sends a late 180 and the INVITE’s 200 again; sipr ignores the 180 with SIPp’s trace line, re-sends the recorded ACK and does not fail the call — neither is a repeat of the last message received, so the generic dedupe cannot be what saves it); interop manual_transactions_complete_against_real_sipp_both_ways (SIPp’s basic UAC flow with every transaction named, run by real sipp against a sipr UAS and by sipr against a sipp UAS: every call completes on both sides, nothing unexpected). The overlapping e2e also proves the strictness: with the peer answering first first, the call fails on that response, as in SIPp.
  • Docs: SIPP_COMPAT §1 (send: start_txn, ack_txn; recv: response_txn), the v1.x tier paragraph, §6 note (branch-based matching order, the out-of-window rules, [branch] unchanged); ARCHITECTURE §4 (per-call transaction slots next to the retrans context); README feature bullet.

M37 — exec command= (external process) and <setdest>

The two remaining v1.x-tier actions. Both are hard errors in sipr’s compiler today (“exec command= (external process) is not supported yet”, “action <setdest> is not supported yet”), so SIPp’s documented hook and redirect idioms — <exec command="echo [last_From] >> from_list.log"/>, <setdest host="[$host]" port="[$port]" protocol="[$transport]"/> after an ereg over [next_url] — fail to load.

Behavioral oracle: scenario.cpp ~l.1596-1600 (setdest: host, port, protocol, each a message template — xp_get_string — so keywords and [$var] render at run time), ~l.1637-1640 (exec command="…" is a message template too; the DTD, sipp.dtd ~l.90-95, lists command, int_cmd, play_pcap*, rtp_stream, rtp_echo); call.cpp ~l.6144-6178 (E_AT_EXECUTE_CMD: the rendered command runs through a double fork() and system() — a shell — the parent reaps only the intermediate child and never waits for the command nor sees its status; the grandchild logs “system call error for %s” when system() itself fails; stdin/stdout/stderr are inherited, which is why the >> file idiom works and why output lands on the curses screen), ~l.5841-5935 (E_AT_SET_DEST: render host, port, protocol; port must be numeric (“Invalid port for setdest: %s”); protocol is udp|tcp|tls|sctp in either case (“Unknown transport for setdest: ‘%s’”); it must equal the call’s transport (“Can not switch protocols during setdest.”); TLS is refused (“Changing destinations is not supported for TLS.”); TCP/SCTP need per-call sockets (“Changing destinations for TCP or SCTP requires multisocket mode.”) and a socket nobody else shares (“Can not change destinations for a TCP/SCTP socket that has more than one user.”); the host is resolved with a blocking getaddrinfo (“Unknown host ‘%s’ for setdest”); UDP then just retargets the call’s peer; TCP/SCTP close the call’s connection and reconnect(), a failure logging “Unable to connect a TCP/SCTP/TLS socket” and spending one -max_reconnect credit — all of those are SIPp ERRORs, i.e. fatal for the whole run), ~l.2741 ([remote_ip]/[remote_port] keep rendering the global remote: setdest moves the traffic, not the keywords); docs/scenarios/actions.rst “External commands” and “setdest” (incl. the IPv6-without-brackets warning: brackets would be read as a keyword).

  • Scenario: <exec command="…"/> compiles to Action::ExecCommand (MsgTemplate) (mutually exclusive with the other exec attributes, as today); <setdest host= port= protocol=/> to Action::SetDest { host, port, protocol: MsgTemplate } with the three attributes required (xp_get_string is fatal without them; SIPp’s wording) and unknown attributes warning. Both run from <recv>, <nop>, <send> actions like any other; --check dumps them. The DTD’s sample and the standalone index stay rejected.
  • Engine, exec command=: render the template (all keywords, the call’s variables), then hand the string to an exec runner — one background thread (sipr-engine/src/exec.rs) that spawns sh -c <cmd> (cmd /C on Windows) with inherited stdout/stderr, stdin closed, and reaps each child when it exits, so the engine thread never forks, waits or blocks and no zombies accumulate under load. Fire-and-forget like SIPp: no exit status, no effect on the call; a spawn failure is one stderr warning (“system call error for <cmd>”, SIPp’s text — the runner thread has no error trace) and nothing more. Dropping the runner at the end of the run drains the queue (every command still starts) without waiting for running commands, as SIPp’s grandchildren outlive it. The only hot-path cost is the render.
  • Engine, <setdest>: render the three values; validate exactly as SIPp (port numeric; protocol one of the four, case-insensitive; protocol == the run’s transport; TLS refused; TCP/SCTP only in the per-call modes tn/sn — the call’s own connection is closed and re-dialled to the new peer, a failure counting against -max_reconnect and failing the call with SIPp’s “Unable to connect” warning) — but, as with “Jump statement out of range”, fail the call, not the run (record). UDP retargets the call’s remote only: [remote_ip]/[remote_port] and the digest URI keep the nominal remote (render_remote), as SIPp’s globals do. A literal IP costs no I/O; a host name is resolved with a blocking lookup on the engine thread, SIPp’s documented stall — an error-trace line notes it the first time. IPv6 literals bare, as in SIPp (bracketed ones read as keywords). -rsa: verified — SIPp copies the sending address into remote_sockaddr at start-up and setdest overwrites the call’s peer, so setdest wins; sipr overwrites call.remote the same way. A call that has not sent yet is simply retargeted (its first send dials the new peer).
  • Tests: scenario unit (both actions compile; missing setdest attributes error; exec command= with a media attribute still errors); engine unit (setdest validation messages; protocol parsing incl. case); e2e exec_command_runs_a_shell_per_matching_ message (a UAS scenario echo [last_From] >> from_list.log on each INVITE against 3 sipr UAC calls: the file holds the three From headers, sipr exits 0, no zombie — check ps shows no defunct children of sipr while it runs), setdest_redirects_the_rest_of_ the_call_over_udp (the scripted UAS answers the INVITE with a Contact on a second socket; the scenario eregs host and port out of [next_url], setdests, and the BYE arrives on the second socket while [remote_ip]:[remote_port] in it still name the first), setdest_over_per_call_tcp_reconnects (-t tn: the BYE arrives on a second TCP listener) and setdest_is_refused_where_ sipp_refuses_it (mono TCP → the call fails with SIPp’s wording; TLS likewise; the run goes on); interop: SIPp’s own setdest example shape ([next_url]eregsetdest) run by real sipp against a sipr UAS that answers with a Contact pointing at a second sipr UAS port, and by sipr against the same with sipp on the second port; and an exec command= scenario on both, each side’s >> file output compared. Deviations from the plan, all recorded in SIPP_COMPAT §6: the zombie check is one ps after the calls (the runner reaps within 100 ms, so a poll can catch a child in between); the TLS refusal is a unit test (no certificates needed); the setdest scenarios setdest in a <nop> after the ACK so the redirecting peer sees INVITE and ACK and the second peer only the BYE (SIPp’s -sn uas-style peers need that shape); real sipp’s hook writes blank lines ([last_*] timing) so the interop test compares line counts on the sipp side and content on the sipr side.
  • Found on the way: ereg search_in="body" and search_in="var" variable= were missing (SIPp’s setdest idiom needs var) — added; [next_url] in SIPp needs rrs="true" on the recv to carry the Contact (sipr does not — left as is); SIPp’s [last_*] inside a recv’s own actions still name the previous message (sipr: the one just received — left as is); SIPp’s docs example echo [last_From] needs quoting under any shell. All in SIPP_COMPAT §6.
  • Docs: SIPP_COMPAT §1 (actions table: exec command=, setdest), the v1.x tier paragraph, §6 note (fire-and-forget exec, the fatal → per-call divergence, blocking resolution, keywords unchanged by setdest); ARCHITECTURE (the exec runner thread next to the media threads); README feature bullet; CONVENTIONS if the runner needs a dependency (it should not — std::process suffices).

Second backlog (ordered, after M37)

Drawn up 2026-09-20 from a sweep of what still fails loudly: SIPp’s CLI option table (sipp.cpp) against sipr -h, its keyword table (message.cpp) against the renderer, sipp.dtd against the compiler, the “not supported yet” errors in the source, and the “post-v1” / “left as is” notes in docs/SIPP_COMPAT.md §6. Every DTD element is handled; the gaps are inside attributes, keywords and flags. Ordered by how often real SIPp scenarios and CI wrappers hit them. Each entry states its behavioral oracle; read the C++ before implementing, as before. Parity first (M38–M44), sipr’s own additions after (M45+).

M38 — Statistical pauses: SIPp’s distribution= attributes, all kinds, and <sample>

The last v1.x-tier item in PLAN.md §3.4. Found on the way: sipr’s distribution= took a positional form of its own invention, distribution="uniform(200,3000)", and rejected SIPp’s real syntax — separate attributes, distribution="uniform" min="200" max="3000" — so every SIPp scenario with a distributed pause failed to load. Beyond that, <pause> accepted only fixed, uniform, normal and exponential; the engine rejected lognormal, weibull, pareto, gpareto, gamma and negbin (“pause distribution ‘…’ is not implemented yet”; poisson was listed but SIPp has none), and <sample> was a compile error.

Behavioral oracle: scenario.cpp ~l.1112 parse_distribution (the attribute names per kind, read from the source: fixed value; uniform min/max; normal and lognormal mean/stdev; exponential mean; weibull lambda/k; pareto k/x_m; gpareto shape/scale/location; gamma k/theta; negbin n/p; no poisson; plus the old-style <pause> spellings — min/max alone, or a bare normal/exponential/… flag), the CSample subclasses in stat.cpp (CFixed, CUniform, CNormal, CLogNormal, CExponential, CWeibull, CPareto, CGPareto, CGamma, CNegBin) and their sample() / textDescr() (the TUI shows the description), HAVE_GSL — SIPp builds all but fixed/uniform only with GSL, so a GSL-less sipp errors “…requires GSL” at parse; that is the interop baseline, not a behavior to copy; actions.cpp E_AT_ASSIGN_FROM_SAMPLE (the sample lands in a double variable).

  • Scenario: parse every kind from SIPp’s attribute names with SIPp’s validation messages (sipr-scenario/src/distribution.rs), the old-style spellings, the sanity_check 99th-percentile guard; <sample> compiles to Action::Sample { assign_to, distribution }; --check and the scenario screen show SIPp’s textDescr. The positional shorthand stays as a documented sipr extension.
  • Engine: samplers in-tree over the existing seeded xorshift generator (sipr-engine/src/sample.rs, no new dependency): Box–Muller normal, lognormal = exp(normal), Weibull/Pareto/gpareto by inverse CDF, gamma by Marsaglia–Tsang (with the shape<1 boost), Poisson by exponential arrivals below a mean of 30 and the normal approximation above, negbin as the gamma–Poisson mixture. A sample below 1 is no pause (SIPp’s clamp). One draw per pause step or <sample>; the action runner takes the engine’s RNG.
  • Tests: statistical unit tests (mean/variance/median over 200k seeded draws per kind), parse/describe/percentile unit tests, compile tests for every kind, the old style, <sample>, and SIPp’s error wording; corpus statistical_pauses.xml (+ a negative poisson); an e2e run of that scenario with its --check dump; interop statistical_pauses_both_ways_against_real_sipp (sipr UAC vs sipp UAS and the reverse; a GSL-less sipp’s “only available with GSL” refusal skips the sipp-side half visibly). CI builds sipp with -DUSE_GSL=1 so both halves run there.
  • Docs: SIPP_COMPAT §1 (pause attributes, sample action, the v1.x tier paragraph) and §6 note (incl. SIPp’s negbin argument swap and the gpareto shape-0 division, both diverged from deliberately); README “Not yet” loses <sample>; PLAN.md §3.4 v1.x row; CHANGELOG.

M39 — Keyword parity: -key, [fill], [last_message], [clock_tick] and friends ✅

Ten keywords from SIPp’s table render nothing in sipr today and are warned as unknown: [clock_tick], [date], [dynamic_id], [last_cseq_number], [last_message], [remote_host], [sipp_version], [tdmmap], [timestamp], [fill variable=…]; and the generic -key keyword value flag ([keyword] expands to value) is an unknown option. -key is the most common one in real wrappers.

Behavioral oracle: message.cpp the keyword table (~l.60-120) and SendingMessage::SendingMessage (bracketed-value parsing; the -key values are themselves message templates — “Bracketed -key values” in SIPP_COMPAT §6 M14 note); call.cpp createSendingMessage for each E_Message_*: Clock_Tick (ms since start), Timestamp (SIPp’s %Y-%m-%d %H:%M:%S.%f-style — verify), Date (RFC 1123, for the Date: header), Sipp_Version, Dynamic_ID (a per-run counter starting at a random base — used for [dynamic_id] REGISTER contacts), Last_CSeq_Number, Last_Message (the whole last received message), Remote_Host (the -rsa/target host as given, not resolved), Fill (variable= names a numeric variable, emits that many Xs — verify the fill character), TDM_Map (-tdmmap circuit map keyword); sipp.cpp -tdmmap parsing ({a-b}{c-d}{e-f}{g-h} form).

  • CLI: -key <keyword> <value> (repeatable; the value is a literal, as SIPp’s), -tdmmap <map> (SIPp’s bad-form wording), -dynamicStart/-dynamicMax/-dynamicStep (found on the way: SIPp has them, the option-table sweep missed their help shape) and -rfc3339 for [timestamp]. A -key name that is also a built-in keyword loses: SIPp checks its table first, so does sipr.
  • Renderer: eleven new Keyword variants (the ten plus [file name=], SIPp’s prefix-handled keyword the table sweep missed) and Generic for -key; a RunInfo on the render context carries the run-wide inputs (clock, -key pairs, the [dynamic_id] counter, the TDM table, the [file] cache). [sipp_version] renders the bare version number like SIPp’s; [timestamp] is UTC (documented). -tdmmap circuits are handed to outgoing calls and released with them (engine::alloc_tdm/release_tdm); [tdmmap] without the flag is refused at start-up with SIPp’s wording.
  • Tests: tokenizer, renderer, clock and TDM unit tests; compile test (dump names, [fill] counts as a variable read, -key names via CompileOptions); corpus keywords_m39.xml; CLI tests for the two-argument -key and -tdmmap’s wording; e2e runs asserting the rendered headers a responder receives (-key, [remote_host], [dynamic_id], [fill], [last_cseq_number+1], [tdmmap]); interop m39_keywords_both_ways_against_real_sipp (sipr and sipp each run the same -key scenario as UAC against the other’s responder). Byte comparison modulo the clock values was dropped: [timestamp] is UTC here and local time there.
  • Docs: SIPP_COMPAT §2, §3 and a §6 note (incl. SIPp’s TDM off-by-one, not copied); README “Not yet” loses -key; CHANGELOG.

M40 — Statistics files at parity: -trace_stat columns, -trace_rtt, -trace_counts, -trace_error_codes

-trace_stat writes “a pragmatic subset” of SIPp’s columns (SIPP_COMPAT §6 M4: “full column parity is a v1-polish item”); wrappers that parse the CSV by column name break on the missing ones. -trace_rtt/ -rtt_freq, -trace_counts, -trace_error_codes, -periodic_rtd, -stat_delimiter, -f and -trace_screen/-screen_file are unknown options.

Behavioral oracle: stat.cpp CStat::dumpData (the exact header — every counter with (P)/(C), the repartition columns ResponseTimeRepartition1_<n> / CallLengthRepartition_<n>, per-code columns from -trace_error_codes? — verify — and the ; delimiter default), dumpDataRtt (-trace_rtt: Date_ms;response_time_ms;rtd_no per call every -rtt_freq calls), CStat::displayData + dumpScreens (-trace_screen writes the final screens as text, the -bg idiom), -trace_counts (<scenario>_<pid>_counts.csv: one row per -f interval with every message-command counter — the counter= and per-step send/recv counts), -trace_error_codes (<scenario>_<pid>_error_codes.log: unexpected response codes), -periodic_rtd (reset repartition counters each interval), -f (screen refresh period; sipr’s TUI tick is fixed at 1 s — keep the flag for the file dump period).

  • -trace_stat: every SIPp column in SIPp’s order (sipr-stats csv_header/csv_row, the fixed set as a checked-in list), the per-RTD mean/stdev and repartition blocks sized from the scenario’s <ResponseTimeRepartition>/<CallLengthRepartition>, SIPp’s hh:mm:ss / hh:mm:ss:uuuuuu / three-decimal formats, (P) as a per-dump period; -stat_delimiter; -periodic_rtd; -fd default 60 s as SIPp’s. Counters sipr cannot source are 0 (listed in §6).
  • -trace_rtt + -rtt_freq (rows buffered in the stat set, flushed from the engine loop’s tick), -trace_counts (per-step columns from a StepKind per step), -trace_error_codes (codes captured where an unexpected response fails a call), -trace_screen + -screen_file (main renders the TUI’s three screens from the run report’s final snapshot), -f (the snapshot/-bg line period). File names <scenario>_<pid>_{,rtt,counts,error_codes}.csv and _screens.log as SIPp’s. Rows are built off the per-message path, in the -fd dump and the once-a-second tick.
  • Tests: stats unit tests (header column set and positions, period roll-over, periodic RTD, counts columns, error-code and RTT rows, SIPp’s number formats); an e2e run with every file on asserting the headers, names, delimiter and row widths; interop statistics_file_headers_match_real_sipps — sipr and real sipp run the same embedded UAC and the -trace_stat, -trace_rtt and -trace_counts headers must be byte-for-byte equal (the M40 acceptance criterion), rows the headers’ width on both sides.
  • Docs: SIPP_COMPAT §3 (tracing flags), §6 M4 note closed and an M40 note (formats, zero columns, the seconds-not-ms RTT quirk, the -fd default change); CHANGELOG.

M41 — Message and error logs at parity: short messages, <log> files, calldebug, rotation ✅

<log> actions print to stderr with a [log] prefix; SIPp writes them to <scenario>_<pid>_logs.log under -trace_logs. -trace_shortmsg (one CSV line per message, the format most SIPp CI wrappers grep), -trace_calldebug, -trace_timeout, -error_file, -message_file, -log_file, -shortmessage_file, -calldebug_file, the *_overwrite flags, -ringbuffer_files/-ringbuffer_size/-max_log_size rotation, -rfc3339 timestamps and -deadcall_wait (how long a finished call’s Call-ID stays known so late messages log against it) are unknown options.

Behavioral oracle: logger.cpp (print_message/print_short_message formats — the shortmessage CSV fields: date, call id, direction, message type, method/code, …; the *_overwrite semantics — default overwrite true; rotate_* and the ringbuffer scheme <name>_<n>.log), sipp.cpp the option table defaults, call.cpp ~call / deadcall handling and -deadcall_wait (keeps Call-ID → final status for the error log: “Received message for a dead call”), -trace_calldebug (call.cpp dumpCall: the message history of aborted calls).

  • -trace_logs/-log_file (<log> lines go there and nowhere else, as SIPp’s LOG_MSG; <warning> stays in the error trace); -trace_shortmsg/-shortmessage_file with SIPp’s tab layout and its receive-side time quirk; -trace_calldebug/-calldebug_file with SIPp’s entries, dumped on abort only; -trace_timeout accepted as the no-op it is in SIPp; -error_file, -message_file; the message frame and error line rewritten to SIPp’s exact shapes (-rfc3339 aware). Found on the way: the frame used to carry the peer address and the error lines no timestamp.
  • Rotation: -ringbuffer_files/-ringbuffer_size/-max_log_size with SIPp’s rotated names and the -<kind>_overwrite flags, in the stats crate’s TraceFile (the writes stay on the engine thread as before — a buffered write_all per event, the same cost as the existing message trace; moving them to a thread is not needed at the rates measured so far).
  • -deadcall_wait: finished calls stay in a map (Call-ID → reason, expiry) consulted before the unknown-call handling: a late message counts as DeadCallMsgs, warns and traces as SIPp’s deadcall does, and spawns nothing; expired entries are swept once a second.
  • Tests: stats unit tests for every line format and the ring-buffer rotation and size cap; e2e log_files_have_sipps_shapes (logs, timestamped errors under the header, short messages’ seven columns, rotated message files, a dead-call message) and calldebug_dumps_aborted_calls; interop short_message_log_matches_real_sipps — the (S|R, start line) sets of sipr’s and sipp’s short-message logs for the same run are equal.
  • Docs: SIPP_COMPAT §3 (the flags) and a §6 note (naming, formats, the receive-side time quirk, rotation, dead calls, SIPp’s fixedname bug not copied); the M17 note on trace logs; CHANGELOG.

M42 — Timer and behavior knobs: retransmission counts, timeouts, -lost, -default_behaviors

The retransmission policy is SIPp’s default with only -max_retrans and -nr; -max_invite_retrans, -max_non_invite_retrans, -timer_resol, -recv_timeout, -send_timeout, -timeout_error, -lost (default lost= for every send), -pause_msg_ign, -default_behaviors, -callid_slash_ign, -sleep, -nostdin are unknown options. Also two SIPP_COMPAT “post-v1” notes: UAS replies go to the request’s source address, not Via received/rport (§6 M4), and the digest uri= shape (§6 M6, partly closed by -auth_uri).

Behavioral oracle: call.cpp call::run / sendmsg retransmission schedule (DEFAULT_T1_TIMER, the INVITE vs non-INVITE caps, when -max_retrans applies to both), -recv_timeout (recv_timeout on every recv without its own timeout=), -send_timeout, -lost (the per-send default lost percentage — scenario lost= overrides), -pause_msg_ign (messages arriving during a <pause> are dropped without “unexpected”), -default_behaviors (all|none|bye|abortunexp| pingreply|cseq and the - prefixed removals; -nd = none; cseq = check CSeq on responses), -callid_slash_ign (the /// 3PCC Call-ID prefix rule), -timeout_error (exit non-zero when -timeout fires), -timer_resol (the scheduler tick — sipr’s pacer is elapsed-time based, so this becomes the wake-up granularity, verify it has any observable effect worth matching), -sleep, -nostdin; socket.cpp process_message for where SIPp sends responses (it does not honour received/rport either — verify before implementing, and if SIPp replies to the source address as sipr does, close the §6 note as no divergence).

  • CLI + engine: each flag with SIPp’s default and unit parsing (parse_time shared; a parse_time_ms variant for SIPp’s TIME_MS options whose bare number is milliseconds).
  • -default_behaviors as a Behaviors bitset (-nd = none) driving SIPp’s abortCall messages from its own built-in templates, the unexpected BYE/CANCEL/PING answers, the continue-on-unexpected mode and the ACK CSeq guard; -lost as the send and recv default; RetransCaps split INVITE/non-INVITE with -max_retrans as the ceiling and the T2 cap only for non-INVITE. Found on the way: sipr parsed -nd but never used it, never sent abort messages, capped INVITE retransmissions at T2 and defaulted every message to 5.
  • Tests: schedule unit tests (both caps, the INVITE doubling, the ceiling), Behaviors::parse, Call-ID trimming, the default templates; e2e default_behaviors_abort_or_continue_on_an_unexpected_message (default abort + abort BYE, -nd continue, all,-bye, -pause_msg_ign) and timeout_retrans_and_loss_knobs (-recv_timeout, -max_invite_retrans 1, -timeout_error, -lost 100); interop max_invite_retrans_counts_like_real_sipp (both send the INVITE three times and give up within seconds).
  • Docs: SIPP_COMPAT §3 and a §6 note; the Via received/rport question closed (SIPp replies to the source address too); the -nd sentence; CHANGELOG.

M43 — Extended 3PCC: -master/-slave/-slave_cfg, sendCmd dest=, recvCmd src=

The last “Not yet” README item with a real user base (IMS and conference testing). sendCmd dest= and recvCmd src= were compile errors (“extended 3PCC is not supported yet — classic -3pcc only”); -master, -slave, -slave_cfg were unknown options; optional recvCmd fall-through and twin reconnection were listed as unsupported in SIPP_COMPAT §6 M10.

Behavioral oracle: sipp.cpp the twin-socket setup for extended mode (-slave_cfg file format: master / slave sections of name;host:port lines — verify), socket.cpp open_connections / connect_to_peer / process_twin_command / free_peer_socket (the master listens, slaves connect, peers are named; a command carries the destination peer name; reconnection on a dropped twin), call.cpp E_AT_SEND_CMD with dest (routing by name) and recvCmd src (accept only from that peer; optional? — the fall-through rule), docs/3pcc.rst “Extended 3PCC”.

  • Scenario: dest=/src= attributes compile to peer names; the classic form stays the default.
  • Net/engine: named peer connections from -slave_cfg, master accept loop, slave dial with reconnection, commands routed by name; recvCmd src= matched by origin; fall-through for optional recvCmd.
  • Tests: unit tests for the cfg parser and routing; e2e with three sipr processes (master + two slaves); interop: sipr master with sipp slaves and the reverse, on SIPp’s documented extended 3PCC example scenarios.
  • Docs: SIPP_COMPAT §1 and §6 M10 note; README “Not yet” loses extended 3PCC.

Findings (from the C++; the full note is SIPP_COMPAT §6 M43): SIPp has no twin reconnection at all — a closed control connection ends the run with a warning, in classic mode too — so “slave dial with reconnection” became “slave dials back on first contact, and a closed twin ends the run at once, aborting the calls still open”. src= is matched against the command’s own From: line, not the socket; commands are routed by their Call-ID like SIP messages, and the 3PCC server sides (controller B, slaves) open calls on the commands that name them. Both of those replaced sipr’s earlier “hand it to whichever call is waiting” routing, so classic peers must now echo the Call-ID. Extended mode never sends 3pcc_abort; classic mode does on an unexpected-message abort, and both sides now honor internal-cmd: abort_call. -trace_msg still does not log twin commands.

M44 — Leftovers that still reject loudly ✅

Small, independent items; ship in any order, each its own commit:

  • PRINTF= virtual-line injection files (infile.cpp: a header line PRINTF=<n> and a printf-style template expanded to n lines — verify the exact substitution) — the last injection-file mode missing. Done: PRINTF=/PRINTFOFFSET=/PRINTFMULTIPLE=, %[0-9.-]*d and %%, virtual lines over cycling rows, indexing and -users over the virtual count, insert/replace refused; the two divergences are in SIPP_COMPAT §1.
  • <rtp_echo variable="…"> (toggle from a variable, call.cpp E_AT_RTP_ECHO). Done: the action takes SIPp’s handle_rhs pair (value= xor variable=) and the engine reads the variable — where SIPp reads its literal slot and so always switches echoing off; SIPP_COMPAT §6 M18 records the slip.
  • -bind_local (UAS listens on -i only, not all interfaces), -buff_size, -sendbuffer_warn; -bind_to_device on Linux (SO_BINDTODEVICE, needs root; reject clearly elsewhere). Done, and bigger than it looked: SIPp keeps the advertised address apart from the bound one, so sipr grew SIPp’s connect-probe for [local_ip] (it used to render 0.0.0.0 without -i) and -bind_local binds that address. -buff_size/-bind_to_device are socket2 calls in the new sipr-net::sockopt, applied to every SIP socket; -sendbuffer_warn follows SIPp’s code rather than its inverted help text. SIPP_COMPAT §6 M44.
  • pcapng input for play_pcap_* (sipr addition: tcpdump/Wireshark write pcapng by default now; SIPp rejects it — keep the -s0 advice for the classic format). Sanctioned-dependency check: an in-tree block reader, no crate. Done: sipr-media::pcapng, std only; pcap::parse dispatches on the section-header magic so no caller changed.
  • Decide and document the three “left as is” divergences in SIPP_COMPAT §6 M37 ([next_url] without rrs, [last_*] inside the matching recv’s own actions, and the M35 action-step interleaving): either match SIPp behind a --sipr-strict-sipp flag or state them as permanent in §6 with the reason. No silent status quo. Decided: all three permanent, no flag — SIPP_COMPAT §6 M44 gives the reason for each, and an e2e test pins the first two. In short: (1) matching SIPp makes [next_url] render empty for a UAC, (2) matching SIPp would put two different “current messages” in one <action> block, and (3) matching SIPp means its one-step-per-turn scheduler, which contradicts ARCHITECTURE §3.
  • -watchdog_*, -max_recv_loops, -max_sched_loops, -rtp_threadtasks, -skip_rlimit, -plugin and the SCTP socket options (-multihome etc.): accept with one loud “no effect in sipr” warning each (they tune SIPp’s scheduler and process, which sipr does not have) so wrapper scripts written for sipp keep running. This is the one sanctioned exception to “unknown flag is an error”: each is named in the table with the reason. Done: cli::no_effect_reason is the single list, a unit test keeps it in step with the flag table, and the six SCTP options moved from a hard error to a warning; SIPP_COMPAT §3.1.

M45+ — sipr’s own additions (after parity)

Candidates, to be promoted into numbered milestones once M38–M44 are done and in the order the users of the HTTP API ask for them:

  • Structured stats: --sipr-stats-json <file> (the 1 s snapshot as JSON lines) and a Prometheus /metrics on the existing HTTP API.
  • A load-comparison bench: criterion + a documented make bench-vs-sipp that runs both tools at 500/2000/5000 cps on loopback and records CPU, memory, retransmissions and max concurrent calls in docs/PERFORMANCE.md; the hot-path rules were designed but never measured against SIPp.
  • Library API: sipr-engine embedded in another Rust test harness (scenario in, stats out, no CLI, no TUI) — needs a stable EngineConfig and a documented public surface.
  • Scenario linting beyond --check: unreachable labels, optional recv ordering traps, [len] without a body — the folklore in SIPP_COMPAT §6 turned into diagnostics.

Changelog

All notable changes to sipr are documented here. The format follows Keep a Changelog, and the project adheres to Semantic Versioning.

Unreleased

Added

  • Extended 3PCC (M43): -master NAME/-slave NAME with -slave_cfg FILE (name;host:port lines), sendCmd dest= routed to the named peer and recvCmd src= checked against the command’s From: line, on SIPp’s wiring (every instance listens on its table address; the master dials its dest= peers at start-up, a slave dials back on first contact). Twin commands are now routed by their Call-ID like SIP messages, and the 3PCC server sides (classic controller B, every slave) open their calls on the commands that name them; an optional recvCmd lets a SIP message for the recv behind it pass; a closed twin connection ends the run at once with SIPp’s warning, aborting the calls still open; internal-cmd: abort_call is honored, and a classic controller sends it when it aborts a call on an unexpected message.
  • A documentation site at https://tareqmy.github.io/sipr/, built with mdBook from docs/ and deployed from master by the new Docs workflow.
  • Timer and behavior knobs at parity (M42): -max_invite_retrans, -max_non_invite_retrans (SIPp’s 5 and 9, with -max_retrans as a ceiling; an INVITE’s timer keeps doubling past T2 as SIPp’s does), -recv_timeout, -timeout_error, global -lost, -pause_msg_ign, -default_behaviors (with -nd as none) including SIPp’s abort messages (ACK/BYE/CANCEL from its own built-in templates) and its handling of unexpected BYE, CANCEL and PING, -callid_slash_ign, -sleep, -nostdin; -send_timeout and -timer_resol are accepted with a warning. The [last_Request_URI] keyword.
  • Message and error logs at parity (M41): -trace_msg frames and -trace_err lines take SIPp’s exact shapes (timestamps, the The following events occurred: header), <log> actions go to the new -trace_logs/-log_file, and -trace_shortmsg/-shortmessage_file, -trace_calldebug/-calldebug_file, -error_file, -message_file, the -<kind>_overwrite flags, -ringbuffer_files/-ringbuffer_size/ -max_log_size rotation and -deadcall_wait are implemented as SIPp’s; -trace_timeout is accepted (a no-op in SIPp too). trace logs|shortmessages on|off work on the control socket.
  • Statistics files at parity (M40): -trace_stat writes SIPp’s full column set (StartTimeWatchdogMinor, ResponseTime<rtd> mean and standard deviation per RTD, CallLength, a repartition block per RTD and for the call length, SIPp’s time formats and trailing delimiter); new -trace_rtt/-rtt_freq, -trace_counts, -trace_error_codes, -trace_screen/-screen_file, -stat_delimiter, -periodic_rtd and -f, all with SIPp’s file names and formats.
  • Keyword parity (M39): [clock_tick], [timestamp], [date], [sipp_version], [dynamic_id], [remote_host], [tdmmap], [last_message], [last_cseq_number] (with +N/-N), [fill variable= text=] and [file name=] render as in SIPp, and -key KEYWORD VALUE defines generic keywords. New flags -tdmmap, -dynamicStart/-dynamicMax/-dynamicStep and -rfc3339. [timestamp] is UTC where SIPp uses local time (SIPP_COMPAT §6).
  • Statistical pauses at SIPp parity: <pause distribution="…"> now takes SIPp’s attribute form (distribution="normal" mean="…" stdev="…") and all ten of SIPp’s kinds — fixed, uniform, normal, lognormal, exponential, weibull, pareto, gpareto, gamma, negbin — plus the old-style <pause min= max=> spellings and SIPp’s sanity_check. The <sample assign_to= distribution=…> action draws into a variable. A pause sample below 1 ms is no pause, as in SIPp. The interop CI build of sipp now includes GSL so the comparison runs both ways.

Changed

  • A 3PCC twin command must carry the call’s Call-ID: (SIPp routes by it); sipr used to hand a command to whichever call was waiting. Classic controller B no longer paces its calls with -r: as in SIPp they open when controller A’s command arrives.
  • Non-INVITE messages retransmit up to 9 times by default (SIPp’s -max_non_invite_retrans), not 5; an aborted client call now sends SIPp’s BYE/CANCEL/ACK unless -nd or -default_behaviors …,-bye; a /// prefix in an inbound Call-ID is stripped as SIPp’s 3PCC marker unless -callid_slash_ign.
  • The -trace_msg frame no longer carries the peer address, and -trace_err lines are timestamped: both are now SIPp’s formats. <log> lines no longer go to the error trace with a [log] prefix; they need -trace_logs.
  • -fd defaults to 60 s as in SIPp (it was 1 s); the final statistics row is still written at exit. The -trace_stat header changed from sipr’s earlier subset to SIPp’s columns, so parsers keyed on column position need SIPp’s layout.
  • --check and the scenario screen label a distributed pause the way SIPp’s screen does (N(60000.000,15000.000), Exp(…), Wb(…), …).
  • The scenario-side positional form distribution="uniform(200,3000)" still parses but is documented as a sipr extension; SIPp’s attributes are canonical. poisson, which SIPp never had, is now an error.

Fixed

  • On Windows an ICMP port-unreachable for a peer that is down no longer kills the UDP socket: the receive loop rides out ConnectionReset (the WSAECONNRESET quirk on unconnected sockets) instead of ending the run with “socket error”.

0.27.1 — 2026-09-20

Changed

  • The release workflow publishes crates one at a time, skipping versions already on crates.io, so a partial publish resumes instead of failing on the first already-published crate. A manually triggered “Publish crates” workflow does the same for any existing tag.

0.27.0 — 2026-09-20

Security

  • rustls updated to 0.23.45 for RUSTSEC-2026-0285 (TLS 1.3 handshake messages accepted across encryption level boundaries).

Added

  • Release automation modelled on gitwig’s: a tag-triggered CD workflow that builds static Linux (x86_64, arm64), macOS (Intel, Apple Silicon) and Windows binaries, publishes the GitHub release, and, when the matching secret is present, publishes the crates to crates.io, updates the tareqmy/homebrew-tap formula, and pushes a Chocolatey package.
  • Install and uninstall scripts for macOS/Linux (scripts/install.sh) and Windows (scripts/install.ps1), a Nix flake, a reference Homebrew formula, and docs/INSTALLATION.md covering every method.
  • cargo-deny policy (deny.toml) checked in CI, and a CI portability job building and testing on macOS and Windows.
  • SECURITY.md and CONTRIBUTING.md.

Changed

  • TLS PEM files (-tls_cert, -tls_key, -tls_ca, -tls_crl) are now parsed with rustls-pki-types, the crate rustls itself uses, replacing the unmaintained rustls-pemfile (RUSTSEC-2025-0134). Same formats, and a malformed file is now reported with its name.
  • README rewritten to describe sipr’s relationship to SIPp neutrally, state the benchmark numbers benches/BASELINES.md actually records, and list every deliberate compatibility gap.

Fixed

  • The call pacer now credits new calls from elapsed wall-clock time (rate × elapsed / rate_period, as SIPp does) instead of counting timer ticks, so a delayed or coalesced tick no longer lowers the achieved rate.

0.26.0 — 2026-09-19

Added

  • exec command= (M37): run a shell command from an action, with keywords and variables rendered into it. Fire-and-forget as in SIPp, but spawned and reaped by one runner thread, so the engine never forks or blocks and no zombies accumulate under load.
  • <setdest host= port= protocol=/> (M37): send the rest of a call to another peer — retargeting over UDP, re-dialling the call’s own connection over per-call TCP — with every check SIPp makes and its wording. A rejected setdest fails that call rather than the run. [remote_ip]/[remote_port] keep the nominal remote, as in SIPp.
  • ereg search_in="body" and search_in="var" variable="…", which SIPp’s documented setdest idiom relies on.

Notes

  • Three SIPp behaviors met on the way are recorded in docs/SIPP_COMPAT.md §6 and deliberately not copied: [next_url] needs rrs="true" to carry the Contact in SIPp, [last_*] inside a recv’s own actions still name the previous message there, and SIPp’s echo [last_From] example breaks under any shell without quoting.

0.25.0 — 2026-09-19

Added

  • Manual transactions (M36): start_txn and ack_txn on <send>, response_txn on <recv>. A recv so named matches only a response whose top Via branch is the one its request carried, which tells concurrent transactions of the same method apart. SIPp’s placement and usage errors are reported with its wording, requests naming a transaction leave the CSeq-method guard list, and late responses to a named transaction are handled as SIPp does: a provisional is ignored, a final one for an INVITE transaction gets the recorded ACK sent again, and a repeat of the final already taken is ignored. --check lists the transactions. Verified against real sipp both ways.

0.24.0 — 2026-09-19

Added

  • Dynamic users (M35): <User variables="…"/> and <Global variables="…"/> give variables a life beyond the call — one table per user id, kept for the run, and one for the whole process, shared by both scenarios — resolved at compile time into a layered store with no allocation on the hot path. set users now follows SIPp’s id bookkeeping exactly: the pool is served from the back, a call ending while more calls are live than the target retires its id, and a later growth reactivates retired ids (with their variables) before creating fresh ones. SIPp’s -set VARIABLE VALUE seeds a global; dump variables over the control socket lists the scopes; --check prints them. Verified against real sipp both ways.

Fixed

  • Variable values now render and test exactly as in SIPp: a double is written with %lf (3.000000, sipr used to print 3), a true bool as true, and a zero double, a false bool or an unset variable as nothing at all; test="var" and condexec on a message take the same “is set” view (a "0" or "false" string is set, a zero counter is not). Scenarios that compared a rendered counter against 3 should compare against 3.000000 — that is what SIPp sends.

0.23.0 — 2026-09-13

Added

  • Mixed mode (-rxsf <file> / -rxsn <name>, -rxinf <file>) — a second, server-mode scenario terminates the calls the peer originates towards us while the client-mode main scenario originates ours, as in SIPp: own statistics, rx calls never counting toward -m/-l/-users, -rxinf files joining the injection table after the -inf ones, and set display rx over the control socket. sipr enforces the role rules SIPp’s help text only promises. Verified against real sipp both ways.

Changed

  • set display ooc|main|rx now switches every screen — counters, statistics, repartitions and the scenario page — to the displayed scenario, as SIPp does; 0.22.0 swapped only the scenario page. The HTTP /stats document gains a mixed flag.

0.22.0 — 2026-09-12

Added

  • Out-of-call scenarios (-oocsf <file> / -oocsn <name>) — a second, independently compiled scenario that answers requests mapping to no known call, as in SIPp: client mode only, the embedded ooc_default and ooc_dummy (dumpable with -sd), own per-step statistics, no [fieldN]/-inf in ooc calls, and ooc calls never count toward -m/-l/-users. set display ooc|main over the control socket swaps the TUI scenario page. Verified both ways against real sipp.

Fixed

  • regexp_match="true" on <recv> was parsed but never applied by the matcher, so a request=".*" step matched nothing.

0.21.0 — 2026-09-06

Added

  • SCTP transport (-t s1|sn) behind the new off-by-default sctp cargo feature, via socket2: one SCTP message per SIP message as SIPp does, mono and per-call associations, reconnection. Needs an OS SCTP stack at run time (Linux with the sctp module); SIPp’s SCTP option flags are rejected with an explanation. Exercised in Linux CI against a SIPp built with USE_SCTP.

Fixed

  • CI now runs on pushes to master (it only ran for pull requests) and builds SIPp 3.7.7 from source for the interop suite; a few tests that raced the echo threads’ counters or assumed macOS socket timing are settled.

0.20.0 — 2026-09-05

Added

  • -t ui — one UDP socket per IP address from the injection file (-ip_field): each client call sends from its line’s IP, a server binds every listed IP and answers on the one the request hit, and the new [server_ip] keyword renders the IP a call sends from — SIPp’s per-IP mode for emulating many user agents.

0.19.0 — 2026-09-05

Added

  • TCP/TLS reconnection-max_reconnect, -reconnect_close, -reconnect_sleep: when the mono TCP/TLS connection drops, the calls on it fail (or, with -reconnect_close false, live on), the call whose send finds it dead fails, and the connection is re-dialed within the budget after the sleep — SIPp’s reset, in SIPp’s order; with no budget left the run ends with exit 255 like SIPp’s fatal error. Verified both ways against real sipp.

0.18.0 — 2026-09-05

Added

  • -rsa host[:port] — the remote sending address: a UAC sends every message there instead of to the target, a UAS answers there (from a socket of its own) instead of to the request’s source, and the keywords keep naming the nominal remote, as in SIPp. Verified both ways against real sipp.

0.17.0 — 2026-09-05

Added

  • Per-call sockets-t un, -t tn, -t ln: every call opens its own UDP socket or TCP/TLS connection at its first send (SIPp’s multisocket modes), [local_port] names it, and -max_socket caps how many are open before calls share them round-robin. <closecon/> now closes a per-call socket for real. Verified both ways against real sipp.

0.16.0 — 2026-09-05

Added

  • SIPp’s unexpected-message handler<label id="_unexp.main"/>, _unexp.retaddr, _unexp.pausedaddr, <jump variable=> and <pauserestore>: an unexpected in-call message jumps to the handler, which answers it and resumes the interrupted pause for exactly its remaining time. Verified both ways against real sipp.
  • <closecon/> is accepted (a no-op, as SIPp’s reference-count drop is on every mono-socket transport).

0.15.0 — 2026-09-05

Added

  • <verifyauth> — sipr can play a digest-checking registrar: the received Authorization: header is verified against a username and password (MD5 or SHA-256, qop auth/auth-int, -auth_uri) and the boolean verdict drives test= branching, exactly SIPp’s documented recipe. Verified in both directions against real sipp.

0.14.0 — 2026-09-05

Added

  • SRTP echo serverexec rtp_echo="startaudio|updateaudio|stopaudio| startvideo|updatevideo|stopvideo[,pt[,name]]": the call echoes (S)RTP on its advertised media port, re-keyed from the SDES negotiation with the caller’s SSRC and sequence numbers preserved. SIPp’s pfca_uas_*_crypto_*.xml scenarios now run unchanged, and real sipp’s UAC passes its own RTP check against them.

Fixed

  • ereg search_in="hdr" now hands the regexp what SIPp does: the rest of the first matching line after the header string (so header="CSeq:" works and CSeq: [$1] replays the caller’s CSeq), and an absent header fails the call under check_it.

0.13.0 — 2026-09-05

Added

  • [authentication] from an injection field — a CSV column holding [authentication username=… password=…] (or AKA parameters) is re-parsed as the keyword at send time, SIPp’s documented way to give each call its own credentials.

Fixed

  • [authentication] now renders the whole header line as SIPp does (Authorization: after a 401, Proxy-Authorization: after a 407), so SIPp scenarios that place the keyword on its own line work unchanged. sipr’s earlier Authorization: [authentication …] spelling still works.

0.12.0 — 2026-09-05

Added

  • SRTP with SDES keying — SIPp’s crypto keywords ([cryptotag1audio], [cryptosuiteaescm128sha1801audio], [cryptokeyparams1audio], the ue… unencrypted forms, secondary and video variants) render offers and answers; the peer’s a=crypto: lines are parsed; rtp_stream packets are protected with AES-CM-128 or the NULL cipher and HMAC-SHA1 80/32, and the echo check unprotects the echo before comparing. All cryptography is in-tree and verified against RFC 3711’s vectors. Unlike SIPp, the authentication tag uses the packet’s own rollover counter, so streams stay valid past sequence 65535.

Fixed

  • The CSeq-method guard on recv response= now follows SIPp exactly: a response matches when its CSeq method is any request method sent so far, not only the most recent one. A 200 to the INVITE arriving after a PRACK was wrongly treated as unexpected.

0.11.0 — 2026-09-04

Added

  • hide and display attributes, and SIPp’s screen keyshide="true" keeps a step off the scenario screen while set hide true (the default) holds; display="…" replaces its label. Both reach /stats. The 1/2/3 keys switch screens at the keyboard and over the control socket.

0.10.0 — 2026-09-04

Added

  • -auth_uri — SIPp’s flag for the digest uri=; the value gets a sip: prefix exactly as SIPp does.
  • Keywords inside [authentication] parametersusername=[field0], password=[$p], aka_K=[field2] and friends are rendered before use, as SIPp renders them, so credentials can come from injection files.

Changed

  • The default digest uri= is now SIPp’s sip:remote_ip:remote_port (no user part) instead of sip:service@remote_ip:remote_port. Servers verify against the header’s own uri=, so runs are unaffected; the wire form now matches SIPp byte for byte.

0.9.0 — 2026-09-04

Added

  • Rate ramps — SIPp’s -rate_increase N, -rate_interval TIME (seconds or ms/s/m/h), -rate_max N, and -no_rate_quit: the rate climbs every interval and, when it would pass the cap, is clamped there and the run drains (unless told not to). Also -rate_scale for the hot-key step.

0.8.0 — 2026-09-04

Added

  • AKA resynchronisation (AUTS)[authentication … aka_sqn=0x…] gives the client’s SQN_MS; a challenge whose SQN is not above it (or any challenge with aka_resync=1) is answered with auts= and an empty-password digest per RFC 3310 §3.2 / TS 33.102 §6.3.3, then the server’s fresh challenge is answered normally. SIPp’s resync code is unreachable, so this is new ground for SIPp scenarios.

0.7.0 — 2026-09-04

Added

  • RTP echo and the RTP check-rtp_echo (with -mb) echoes RTP received on the media port and media port + 2 back to its sender, with SIPp’s counters and the <rtp_echo value="0|1"/> action to toggle it; rtp_stream sockets now read back what the peer echoes and compare it to what was sent, and -audiotolerance / -videotolerance turn that into SIPp’s verdict: a failed check exits 253 (SIPp’s -3). Unlike SIPp, a stream is judged only when a tolerance flag is given. New counters on the TUI, the -bg line, and /stats.

0.6.0 — 2026-09-04

Added

  • Runtime control — SIPp’s UDP control socket (-cp, -ci: hot keys and c-prefixed set/trace/dump/reset commands with SIPp’s grammar and warning texts; default bind is loopback and -cp 0 disables it) and a new HTTP/JSON API (--sipr-http [HOST:]PORT, --sipr-http-token): /health, /stats, /control, /quit, /command, /scenario. See docs/CONTROL_API.md. New std-only crate sipr-control.
  • Hot keys now follow SIPp exactly: set rate-scale steps, user-count keys in -users mode, and a second q aborts like Q.

0.5.0 — 2026-09-04

Added

  • IMS AKA authentication (AKAv1-MD5, RFC 3310)[authentication aka_K=0x… aka_OP=0x… aka_AMF=0x…] (SIPp’s parameters, plus aka_OPc=) against an algorithm=AKAv1-MD5 challenge: the nonce’s RAND/AUTN go through an in-tree Milenage (AES-128, verified on 3GPP TS 35.208 test sets), the MAC is checked, and RES becomes the digest password. A MAC mismatch fails the call with a clear reason where SIPp aborts the whole process. No new dependency.

0.4.0 — 2026-09-04

Added

  • RTP streaming and DTMF (exec rtp_stream=, exec play_dtmf=) — SIPp’s rtpstream.cpp semantics on the M14 scheduler: raw codec files or apattern/vpattern fills with SIPp’s fixed payload table, looping, pause/resume (the clock keeps running, as in SIPp), SSRC 0xCA110000-based, plus RFC 4733 DTMF bursts with SIPp’s exact timing. [rtpstream_audio_port]/[rtpstream_video_port] keywords with per-call allocation, -rtp_payload, -max_rtp_port, -random_base_ssrc. sipr streams from the port the SDP advertised (SIPp binds an unrelated one) and numbers DTMF packets consecutively (SIPp skips every other warm-up number). Divergences in docs/SIPP_COMPAT.md §6.

0.3.0 — 2026-09-04

Added

  • pcap replay (exec play_pcap_audio|video|image=) — SIPp’s media feature, without the raw socket: a new std-only sipr-media crate reads classic pcap files (Ethernet/802.1Q, raw IP, Linux cooked, BSD loopback; IPv4/IPv6 UDP), learns the peer’s media endpoint from its SDP, and replays the UDP payloads verbatim on the capture’s timeline from one scheduler thread, through ordinary UDP sockets bound to the advertised media port — no root, no libpcap. -mi/-mp (-min_rtp_port), [auto_media_port], [media_port+N], <recv ignoresdp>. RTP counters on the TUI, -bg line, and the final summary. Divergences from SIPp in docs/SIPP_COMPAT.md §6.

0.2.0 — 2026-09-03

Added

  • TLS transport (-t l1) — SIP over TLS with SIPp’s exact semantics: same connection-per-peer model and Content-Length framing as TCP, no SIP retransmissions, port 5060, [transport] renders TLS. -tls_cert / -tls_key (defaults cacert.pem/cakey.pem), -tls_ca / -tls_crl (presence enables SIPp-style verification: chain but not hostname on the client, mandatory client cert on the server), -tls_version 1.2|1.3. Built on rustls with the ring provider — the workspace’s first external dependency, still no system OpenSSL required. Divergences from SIPp documented in docs/SIPP_COMPAT.md §6.

Changed

  • The dependencies: std-only claim is retired: sipr-net now carries rustls/rustls-pemfile for the TLS transport. Everything else remains std; the build still needs no system libraries.

  • IPv6 — targets accept bracketed ([::1], [2001:db8::1]:5060) and bare-literal (::1) IPv6, with automatic :: binding when a v6 target is given without -i. [local_ip]/[remote_ip] render bracketed inside URIs and Via (SIPp’s local_ip_w_brackets), while [media_ip] stays raw for SDP. See docs/SIPP_COMPAT.md §6.

0.1.1 — 2026-08-17

Post-v1 feature drop: TCP transport, injection files with indexed lookups, classic 3PCC, and closed-loop -users mode. Still standard-library only.

Added

  • Closed-loop -users-users N keeps N concurrent calls, each holding a stable 1-based user id; a finished call’s id is recycled into a replacement immediately. Adds the [userid]/[users] keywords and lights up USER-mode -inf injection (line = user id − 1). Mutually exclusive with -l. See docs/SIPP_COMPAT.md §6.
  • Classic 3PCC-3pcc HOST:PORT plus <sendCmd>/<recvCmd> steps let two sipr instances coordinate over a separate ESC-delimited TCP “twin” socket. The role is derived from the scenario’s first twin command (sendCmd-first dials, recvCmd-first listens); <recvCmd> blocks the call until a command arrives and runs its actions against the command text. See docs/SIPP_COMPAT.md §6.
  • TCP transport-t t1 runs SIP over TCP. A stream framer de-frames messages by Content-Length (RFC 3261 §7.5); the client keeps one connection to the target, the server accepts connections and replies on the one each request arrived on. Reliable transport, so no SIP retransmissions are scheduled. -t tn is accepted as an alias. See docs/SIPP_COMPAT.md §6.
  • Injection files-inf FILE (repeatable) loads SIPp-style injection files: a SEQUENTIAL/RANDOM/USER mode header, ;-separated fields, # comments, blank-line terminator. One line is drawn per call per file (SEQUENTIAL cycles, RANDOM picks uniformly, USER defers to -users). The [fieldN] keyword substitutes field N of the drawn line. file=NAME selects another file by its basename (SIPp’s key), or by a 0-based -inf index (sipr extension); line= overrides the per-call line and is rendered at send time, so line=[$var] works. Unknown field/file names are rejected at load. See docs/SIPP_COMPAT.md §6.
  • Indexed injection-infindex FILE FIELD builds a key→line index over one field of an -inf file (matched by basename; last line wins on duplicate keys). Actions <lookup assign_to=… file=… key=…/> (stores the matched line or -1), <insert file=… value=…/> (appends a row), and <replace file=… line=… value=…/> (swaps a row) operate on that data at runtime. The canonical use is lookup → [fieldN line=[$var]].

Fixed

  • Body-less SIP messages (180, ACK, empty 200) now always include the mandatory \r\n\r\n header/body separator. UDP tolerated its absence; TCP framing and real-SIPp interop require it.

0.1.0 — 2026-08-16

First release. A SIPp-compatible SIP testing tool and traffic generator, feature-complete for signaling over UDP. Built entirely on the Rust standard library — no external crates.

Added

  • Scenarios — SIPp-compatible XML: send, recv, pause, nop, label, timewait, Reference, and the response/call-length repartition tables. Loud diagnostics with file:line context; --check lint mode that prints the compiled IR.
  • Keywords[service], [remote_ip]/[remote_port], [local_ip]/[local_port], [transport], [call_id], [call_number], [cseq], [branch], [msg_index], [pid], [routes], [next_url], [peer_tag_param], [len], [last_*:], [$var], [authentication], and the [media_*] placeholders.
  • Actionsereg (capture groups via an in-tree POSIX-ERE engine), assign/assignstr/strcmp/test, arithmetic (add/subtract/ multiply/divide), todouble, trim, urlencode/urldecode, gettimeofday, jump, log/warning/error, exec int_cmd. Plus test/condexec branching, chance, named counters, and per-call variables.
  • Engine — UAC and UAS roles; open-loop pacer (-r/-rp/-l/-m) with rate smoothing; single event-loop over UDP, timers, and the pacer. Recv matching (optional-recv windows, backward contiguous scan, CSeq-method guard) verified against SIPp’s call.cpp.
  • Transport — UDP with RFC 3261 T1→T2 retransmission, inbound retransmission handling, simulated loss (lost), and an in-tree SIP message parser proven panic-free by a fuzz suite.
  • Authentication — digest MD5 and SHA-256 (qop=auth, proxy 407), with in-tree hash primitives verified against the RFC 1321 / FIPS 180-4 / RFC 2617 / RFC 7616 vectors.
  • Statistics — SIPp counter set with failure breakdown, response-time histograms and RTDs, repartition tables, -trace_stat/-stf/-fd CSV, and -trace_msg/-trace_err files.
  • Live TUI — main / per-step scenario / repartition screens, live rate keys (+ - * /), pause (p), screen cycling (s), and safe terminal restore on every exit path. Ferrous brand colors, honoring NO_COLOR.
  • CLI — SIPp-style single-dash flags with did-you-mean suggestions; SIPp-compatible exit codes (0 ok, 1 failures, 99 no calls, 2 usage, 255 fatal).
  • Tooling — six-crate workspace, Makefile convenience targets, CI (fmt + clippy + tests, and an interop job against real SIPp), and the Ferrous brand kit under brand/.

Known limitations

  • Signaling only over UDP. TCP/TLS, -inf injection files (and the lookup/insert/replace actions), 3PCC, RTP/pcap media, IPv6, and an HTTP control API are on the post-v1 roadmap.
  • The ereg regex engine is leftmost-first greedy (PCRE-style), not POSIX leftmost-longest — identical on the patterns real scenarios use; see docs/SIPP_COMPAT.md §6.

Security policy

Reporting a vulnerability

Please report security issues privately through GitHub’s private vulnerability reporting for this repository:

https://github.com/tareqmy/sipr/security/advisories/new

Do not open a public issue for a security problem. You should hear back within a week; fixes ship as a new release with a changelog entry crediting the reporter unless they prefer otherwise.

Supported versions

Only the latest release receives fixes. There are no long-term support branches.

What is in scope

sipr is a test tool that deliberately sends malformed and non-compliant SIP when a scenario asks it to; that is a feature, not a vulnerability. Reports that matter are about what sipr accepts and exposes:

  • Crashes, hangs, or memory exhaustion triggered by inbound SIP, RTP, or SRTP traffic from a peer.
  • Problems in scenario, injection-file, or pcap parsing that a hostile file could exploit (sipr reads these as trusted input from the operator, but a panic or unbounded allocation is still a bug).
  • The control surfaces: the SIPp-compatible UDP control socket (-cp) and the HTTP/JSON API (--sipr-http). Both bind loopback by default and carry no authentication; exposing them on other interfaces is an explicit operator choice (-ci, --sipr-http HOST:PORT) and is documented in docs/CONTROL_API.md. A way to reach them without that choice is in scope.
  • exec command= runs a shell with the rendered scenario text; the scenario author controls it by design. Keyword or variable rendering that lets a peer’s message inject into that command is in scope.

The workspace forbids unsafe and has no C dependencies; TLS and SRTP are pure Rust (rustls, in-tree AES-CM and HMAC-SHA1).