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)
| Element | Attributes (v1) | Notes |
|---|---|---|
scenario | name | |
send | common⁺, retrans, lost, crlf, start_txn, ack_txn | CDATA body = message template; the _txn attrs name a transaction (M36, §6) |
recv | common⁺, response, request, optional, timeout, ontimeout, rrs, auth, lost, regexp_match, response_txn | |
pause | common⁺, milliseconds, variable, distribution + its parameters, sanity_check | all ten SIPp distributions, SIPp’s attribute names and old-style min/max (M38, §6) |
nop | common⁺, display | carries actions |
label | id | jump target; validated at compile |
timewait | milliseconds | end-of-call linger |
Reference | variables | suppress unused-var warnings |
Global | variables | comma list of run-wide variables (M35, §6) |
User | variables | comma list of per-user-id variables (M35, §6) |
ResponseTimeRepartition | value | ms bucket list |
CallLengthRepartition | value | ms 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.mainlabel,_unexp.retaddrand_unexp.pausedaddrrecipe),execwithint_cmd(stop_now,stop_gracefully,stop_call) orcommand=(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, andmatches_scenario); implemented insipr-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);
contigis 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 inscenario.cppand tested withstrstr) — 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 incall.cppmatches_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. Sorequest=".*"takes any request andresponse="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 (theooc_defaultfallback is commented out), ~l.2147-2149 (server-mode fatal),socket.cpp~l.1160-1240 (process_messagedispatch),call.cpp~l.6641 (-inffatal),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_dummythen fails it as unexpected, on the ooc stats). An unmapped response is only counted (E_OUT_OF_CALL_MSGS= sipr’sunexpected) 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”);-oocsfand-oocsnare mutually exclusive. SIPp’sopen_callscounts main-scenario calls only, so ooc calls never count toward-l,-usersor-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|mainswaps every screen — the main counters, the statistics and repartition screens and the scenario page — to that scenario, as SIPp’sscreen.cppreadsdisplay_scenario->statsthroughout (v0.22.0 had only the scenario page follow; corrected with M34);-trace_statnever writes an ooc CSV (SIPp’sstattask::reportdumpsmain_scenario->statsonly) 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-mbudget — where a sipr UAS keeps discarding unmapped responses. Mixed mode (-rxsf) is the next note. - Mixed mode
-rxsf <file>/-rxsn <name>+-rxinf(M34; verified insipp.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-rxsfworks — the option table spells the embedded variantrxrnwhile the parser expectsrxsn, so-rxsnis an unknown option and-rxrnan “Internal error” (the help text’s-snrx/-sfrxexist nowhere); sipr accepts-rxsnas the parser intends and-rxrnnot at all. (2)-rxinfregisters the CSV in the shared file map under its basename, but therx_default_fileit sets is never read: a bare[fieldN]in the rx scenario means the first-inffile (“No injection file was specified!” without one) and[fieldN file=name.csv]reaches a-rxinffile by name — sipr does the same, loading-rxinffiles after the-infones 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_messagetakes theMODE_MIXEDarm 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.cppand the main loop look atmain_scenario), so the run ends with the main calls and lingering rx calls are dropped: atimewaitat 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|mainswitches 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_statstays 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 withtimeout/ontimeouton the mandatory recv. - Pacing: SIPp smooths call starts within the rate period rather than
bursting
-rcalls 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
timewaitthe call absorbs traffic without failing (SIPp deadcall).-aaanswers 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 ascall_peerand never reads Viareceived/rport(checked in M42; no divergence). -trace_statCSV (M4, at parity since M40 — see the M40 note): SIPp’s columns, names, order,(P)/(C)naming and;delimiter.-lcap: 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;
recvwithtimeout+ontimeoutjump 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.-ndis-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).--checktreats 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 againstscenario.cppmessage 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 useignoresdp. sipr recognizes both spellings (and rejects them until media). - Regex engine (M6,
sipr-scenario/src/regex.rs):eregandregexp_matchrecv 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=authwith cnonce/nc, opaque echo, 401 (Authorization) and 407 (Proxy-Authorization). The[authentication]keyword computes the value from the lastrecv auth="true"challenge, using-au/-apor the keyword’s ownusername=/password=params. The digest URI is currently thesip:[service]@remoteshape; a proxy keying strictly on the request-URI may need that widened (tracked for post-v1). Stale-nonce: the challenge exposesstale; scenarios re-auth by looping back to the send. - Action executor (M6): variables are loosely typed (string/num/bool) with
SIPp-style coercion;
strcmpyields 0 on equality (C semantics);test/condexectruthiness = set and not zero/false/empty;divideby zero leaves the value unchanged.exec int_cmdmaps to fail-call / graceful-stop / immediate-stop. - Injection files
-inf(M7, verified ininfile.cpp/call.cppgetFieldFromInputFile): line 1 is the mode, matched by SUBSTRING —SEQUENTIAL,RANDOM, orUSER, optionally withPRINTF=(below). Data lines follow; a line beginning#is a comment, trailing\ris 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-usersthe 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-infpath (sipp.cppSIPP_OPTION_INPUT_FILEstrips the directory); sipr also accepts a 0-based-infindex there as an extension.line=overrides the per-call line and, per SIPp (message.cppbuilds it as aSendingMessage, resolved ingetFieldFromInputFile), is rendered at send time — soline=[$var]works and a value past the end / negative renders empty (SIPp sets line = -1). - Indexed injection,
lookup/insert/replace(M7, verified ininfile.cppindex/lookup/insert/replace/reIndex/deIndexandcall.cppaction execution):-infindex FILE FIELDbuilds a key→line map over one field; on duplicate keys the LAST line wins (reIndexerases then inserts).<lookup assign_to="v" file="F" key="K"/>stores the matched line number inv, or -1 on a miss (looking up a file with no-infindexis 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,lineare all rendered templates. The typical chain islookup → [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 ininfile.cpp— the header parse,getField’s printf branch,numLines,insert/replace): a headerPRINTF=<n>(plus optionalPRINTFOFFSET=<o>, default 0, andPRINTFMULTIPLE=<m>, default 1) makes the data lines templates. The file then hasnvirtual lines; virtual linelreads real linel % rowsand every%dconversion in the field is filled witho + l * m,%%being a literal%. So one row,SEQUENTIAL,PRINTF=10000\nuser%05d;[...], is ten thousand users. Only%[0-9.-]*dis a legal conversion;insert/replaceon such a file are refused, as in SIPp. Two deliberate divergences: sipr splits the header into,/whitespace tokens, soPRINTFOFFSET=may precedePRINTF=(SIPp finds each withstrstr, and that order makes itsPRINTFmatch land insidePRINTFOFFSET— 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 fromContent-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\nruns (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), soretrans=/-max_retransare ignored undert1. 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 inscenario.cpprole detection,call.cppsendCmdMessage/sendCmdBuffer,sipp.cppSIPP_OPTION_3PCC): two instances coordinate over a separate TCP “twin” socket, exchanging command messages each terminated by a single ESC byte (0x1B — SIPp’sdelimitor[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-3pccaddress.<sendCmd>renders its CDATA (keywords/variables) and writes it plus ESC;<recvCmd>blocks the call until a command arrives, then runs its<action>s witheregsearching 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-recvCmdfall-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 FILEwithsendCmd dest=andrecvCmd src=(M43, verified insipp.cppSIPP_OPTION_3PCC_EXTENDED/SIPP_OPTION_SLAVE_CFG,scenario.cppparse_slave_cfg/computeSippMode/thesendCmd/recvCmdparse,socket.cppopen_connections/connect_to_all_peers/pollset_process/read_error/process_message,call.cppsendCmdMessage/process_twinSippCom/check_peer_src/checkInternalCmd,docs/3PCC_extended.rst):- The table is one
name;host:portper line — the first two;-fields, anything after them ignored, a line without;skipped (sipr warns).-slave_cfgneeds-masteror-slave, which exclude each other and-3pcc; the own name and everydest=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 reachessendCmdbefore anyrecvCmd, a slave the other way round. In extended mode everysendCmdneedsdest=and everyrecvCmdsrc=(“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 owndest=peers only when the first peer connects to it (connect_to_all_peersfrom the accept path); a slave that neversendCmds 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”, thenquitting += 20, which is past the main loop’s>= 11hard-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 setsquitting = 1and drains. Hence the docs’ rule that slaves run without-mand 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 arecvCmd(computeSippMode→MODE_SERVERcreation) — so[call_id]on a slave is the master’s and the pacer plays no part there (-mstill 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 ownFrom: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 firstrecvCmdtakes 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 inprocess_incomingis the optional-recvCmdfall-through: a SIP message for the recv behind an optionalrecvCmdpasses over it, so sipr keeps that recv window open while it waits.internal-cmd: abort_call(SIPp’s3pcc_abortdefault 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
recvCmdand 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_msgdoes not log twin commands (SIPp logs them tagged “control”).
- The table is one
-users Nclosed loop (M11, verified incall_generation_task.cpprun/free_user/set_users,call.cppinitline assignment and[userid]/[users]keywords,sipp.cppSIPP_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-mtotal is reached.-usersand-lare mutually exclusive. USER-mode-inffiles resolve line = userId-1 (SIPpnextLine(userId));[userid]renders the id,[users]the count. The count changes at runtime throughset 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.cppE_Message_Local_IP/E_Message_Remote_IP→local_ip_w_brackets/remote_ip_w_bracketsvsE_Message_Media_IP→ rawmedia_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 SDPc=/o=lines (SIPp brackets[local_ip]even in the SDPo=line — sipr matches that verbatim). Targets accept bracketed ([::1],[2001:db8::1]:5060) and bare-literal (::1) IPv6; a v6 target with no-iauto-binds the::family.[local_ip_type]/[media_ip_type]render6for a colon-bearing address.-itakes a v6 local address directly. Not exercised in the build sandbox (no v6 loopback); the e2e self-skips there and runs where::1binds. - TLS
-t l1(M13, verified insslsocket.cppTLS_init_context/SSL_new_client/SSL_new_server,socket.cpphandshake/read/write paths,sipp.cppoption table): TLS is exactly the TCP path with a TLS layer — same Content-Length framing, same connection-per-peer model (lncollapses onto it liketn), no SIP retransmissions, default port stays 5060 (SIPp has no 5061 constant), nosips:scheme anywhere,[transport]rendersTLS. Cert/key default tocacert.pem/cakey.pemin 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_caor-tls_crlis given; when on, the client validates the chain but never the hostname (noX509_check_hostin 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 onSSL_acceptfailure; (2)-tls_version 1.0/1.1are rejected (rustls starts at 1.2; SIPp’s floor is 1.0); (3) encrypted keys are rejected — SIPp silently decrypts with the hardcoded passphraseksgr(sslsocket.cpppasswd_call_back_routine); (4)setdestto 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 inprepare_pcap.cprepare_pkts,send_packets.csend_packets/do_sleep,call.cppget_remote_media_addr(~l.349), themedia_port/auto_media_portkeyword handler (~l.2789),E_AT_PLAY_PCAP_*execution (~l.6196),sipp.cppsetup_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 (didsleepvs 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 firstc=IN IP4/IP6+m=audio|video|imageof any response with a body or any INVITE/ACK/PRACK request, unless the recv hasignoresdp; streams absent from a later SDP keep their old address.[media_port]ismin_rtp_port(6000) for every call unless-rtp_echobumps 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 notconnected 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’spcap_open_offlinerefuses — its-s0advice 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 atplay_pcap_audio=.-keyshipped in M39 (§6). exec rtp_stream=/exec play_dtmf=(M15; verified inrtpstream.cpprtpstream_playrtptask(~l.603),rtpstream_get_localport(~l.1789),rtpstream_cache_file/get_wav_header_size(~l.1619/2240),actions.cppsetRTPStreamActInfo(~l.677),prepare_pcap.cprepare_dtmf(~l.556),call.cppE_Message_RTPStream_Audio_Port(~l.2827)): the value isname,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, dynamicH264/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, SSRC0xCA110000+ 2 per call (-random_base_ssrcrandomizes the base), payload spliced across the file end when looping,-1loops forever.pausedoes NOT stop the clock — the timestamp is fast-forwarded so the stream “appears up to date” on resume.[rtpstream_audio_port]allocates a port frommin_rtp_portin steps of two (wrapping atmax_rtp_port) with a trial bind;+Nnever allocates. SIPp streams from that allocated port even when the SDP advertised[media_port](its ownpfca_uac.xmldoes 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) at400 + (k+1)*2*tonems 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 outside0-9*#A-Dskipped; 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 thatm=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-audiotoleranceverdict/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_threadtasksis not needed (one scheduler thread) and not accepted.- IMS AKA
AKAv1-MD5(M16; verified inauth.cppcreateAuthHeader(~l.158) /createAuthHeaderAKAv1MD5(~l.600),milenage.c,message.cppparseAuthenticationKeyword(~l.547) /getHexStringParam(~l.498),docs/scenarios/sipauth.rst): SIPp matchesalgorithm=by case-insensitive prefix (MD5-sess→ MD5;AKAv2-MD5is 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 configuredaka_AMF(AUTN’s AMF is read and discarded), and on MAC ≠ XMAC callsERROR(), 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-MD5is echoed. OP only, OPc derived asE_K(OP)⊕OPon every call; no OPc input. AUTS/resync is dead code (if (1/*…*/)) — SIPp never emitsauts=. Keyword params:aka_K,aka_OP,aka_AMFas0xhex (nibble pairs, no length validation, not NUL-terminated) or quoted/bare strings;aka_Kabsent → the first 16 bytes of the password (documented),aka_OP/aka_AMFabsent → 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) whenaka_AMFis 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.cppsetup_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-cponce (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-9screens,+ - * /rate or — in-usersmode — user count, stepped byrate-scale;ppause;qadds 10 toquitting,Q20; ≥1 drains, ≥11 aborts, soq q=Q) and the rest is discarded, unless byte 0 isc: then the rest is a command line split on the first space only (tabs do not separate), verbsset|trace|dump|reset, numbers viastrtol(…, 0)(hex/octal accepted) with strict trailing-garbage rejection, booleanstrue|falseforset hidebuton|off|true|falsefortrace. No reply is ever sent (recv()without a peer); errors go to the error log with the wordings reproduced insipr-control::command.set rate/set limitare refused in users mode andset usersin rate mode;set limitlatches the cap so laterset ratestops auto-sizing it.reset stats,set display rx,dump variablesexist but are undocumented; theskey is dead code (screenfis never set). No HTTP anything. sipr matches the protocol, grammar, wordings, refusals and quit ladder, with these divergences: (1) default bind is loopback,-ciopts into more; (2)-cp 0disables the socket; (3) the bound address is printed; (4) screen digits are ignored (sipr’s TUI cycles withs); (5)set display rxanddump variableswarn that they are unsupported instead of silently succeeding (set display ooc|mainworks as SIPp’s since M33;trace logs|shortmessages on|offwork since M41); (6)set limitin 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.cpprtp_echo_thread(~l.650),setup_media_sockets(~l.1292),sipp_exit(~l.1146),rtpstream.cppthe post-sendselect/recv/compare block (~l.754) and the exit verdict (~l.1300),call.cppE_AT_RTP_ECHO(~l.6253)):-rtp_echobinds global sockets onmedia_portandmedia_port+2(probing in steps of two only when-rtp_echois on — otherwisemedia_portnever moves), each threadrecvfroms with a 100 ms timeout andsendtos the bytes back unless the process-widertp_echo_state(default true, toggled by the<rtp_echo>action from any call) is false; countersrtp_pckts/rtp_bytes(1st stream) andrtp2_*(2nd). The RTP check lives inside thertp_streamsender: after every successful send itselects +recvs on the same socket andmemcmps 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 judgedfailed/sent >= tolerance(-audiotolerance/-videotolerance, default 1.0) and a failure sets a bit inrtpresult, which makessipp_exitreturnEXIT_RTPCHECK_FAILED(-3, shell 253) ahead of the call-failure code. Consequence: with the defaults, anrtp_streamrun 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/-videotolerancewas given; (2)<rtp_echo variable="v"/>(M44) readsv, where SIPp parses the attribute throughhandle_rhsand then callsgetDoubleValue()rather thanget_rhs()— its literal slot, whichvariable=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.cppopen_connections~l.2372-2560 —bind_specific, the connect-probe, thebind_local || peripsocketre-resolve —sipp_customize_socket~l.1735-1815,SIPpSocket::bind_to_device~l.1645,call.cppsendBuffer~l.1627): SIPp keeps two addresses apart. The advertised one is-i; without-iit isgethostname()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 isINADDR_ANYunless-iwas given (which setsbind_specific), or-bind_local/-t uiasks for the advertised address. sipr now matches that split — before M44 it bound-iand rendered[local_ip]as0.0.0.0when-iwas 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 resolvinggethostname(), which SIPp’s own comment calls “actually buggy”.-bind_localis therefore a no-op alongside-i, exactly as in SIPp.-buff_sizesetsSO_SNDBUFandSO_RCVBUFon every SIP socket (socket2, since std exposes neither andunsafeis 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_deviceisSO_BINDTODEVICE, which exists on Linux alone and needsCAP_NET_RAW; SIPp compiles the call out elsewhere and binds nothing silently, where sipr refuses the flag at argument parsing.-sendbuffer_warngoverns a failed send of a default (non-scenario) message: despite its help text (“Produce warnings instead of errors”), SIPp’s code readsif (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.cpphas an AUTS branch guarded byif (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 emitsauts=; 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): withaka_sqn=the challenge’s SQN must be greater than SQN_MS, otherwise (or withaka_resync=1) the response carriesauts="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.cppand the option table insipp.cpp~l.347-356): the ramp task is created only when-rate_increaseis non-zero; it wakes everyrate_increase_freq(-rate_interval, aSIPP_OPTION_TIME_SECvalue; when 0 it takes-fd’s value, whose SIPp default is 60 s), doesrate += rate_increase, and ifrate_maxis set and the new rate exceeds it, clamps torate_maxand — withrate_quit(default true;-no_rate_quitclears it) —quitting += 10(drain). The task deletes itself oncequitting >= 10. It callsset_rate, which users mode ignores. sipr matches this; the only difference is the default interval, since sipr’s-fddefaults to 1 s (recorded in M4). - Digest
uri=and rendered auth parameters (M21; verified incall.cpp~l.4149-4170 andmessage.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:xproducesuri="sip:sip:x"(its own gtest expects that). Each[authentication]parameter is stored as aSendingMessageand rendered at send time, so keywords work inside them. sipr matched the wire form from M21 on (it previously signedsip:service@ip:port, which servers accepted since they verify against the header’s ownuri=, but which differed on the wire) and renders the parameters the same way; thesip:sip:quirk is kept, with a startup warning. hide/display(M22; verified inscenario.cpp~l.1852 andscreen.cpp~l.282/493):hideis a boolean on every message command (xp_get_bool("hide", …)),displaya free-text attribute read for every command even thoughsipp.dtddeclares it only onnop; the scenario screen skips a hidden row only while the globaldo_hide(default true,set hide true|false) holds. sipr matches this. Screen keys: sipr maps1/2/3like SIPp and ignores4..9(no variables/TDM screens; secondary repartitions are not drawn separately).- SRTP (M23; verified in
jlsrtp.cpp—pseudorandomFunction~l.66,computePacketIV~l.416,issueAuthenticationTag~l.639,processOutgoingPacket~l.2055 /processIncomingPacket~l.2158,encodeMasterKeySalt~l.2518;call.cppkeyword handlers ~l.2860-3300,extract_srtp_remote_info~l.564;rtpstream.cppecho ~l.2519): JLSRTP is AES-CM-128 or NULL cipher × HMAC-SHA1 80/32, master key 16 + salt 14 always, kdr 0 (key idslabel || 0), no MKI, no replay list, no SRTCP, a fixed 12-byte header and a configured payload length.[cryptokeyparams…]generates a freshRAND_byteskey on every render (negative offset = reuse);[cryptosuite…]selects the local suite;[ue…]rendersUNENCRYPTED_SRTPand switches the local cipher to NULL while still advertising the AES suite. Received SDP: the firsta=crypto:in the media section is PRIMARY, the second SECONDARY (at most two,sscanf-parsed); only the primary attribute is ever active —selectActiveCryptois never called — andswapCryptoswaps 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 thanRAND_bytes; (3) an unsupported peer suite or undecodable key logs and falls back to plain RTP instead ofrejectCall(); (4) payload length is taken from the datagram, not configured. Interop verified with sipp’s own-srtpcheck_debuglog: it authenticates and decrypts sipr’s packets (processIncomingPacket() rc == 0). Also found: sipp’s per-call SRTP echo doessendto()with an explicit address on a socket it hasconnect()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 incall.cpp~l.4022-4045E_Message_Injectionand ~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[authenticationis 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 acceptsAuthorization: [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 inactions.cppsetRTPEchoActInfo,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_payloadand the name to SIPp’s table for 0/8/9/18 — an unknown codec is a parse-time error. The echo threadrecvfroms on the call’s[rtpstream_*_port], and when the answer carrieda=cryptoitprocessIncomingPackets under the peer’s key, rebuilds the packet,setSSRCs the incoming SSRC, re-protects it under the local key with the incoming sequence number, andsendtos the packet’s source; an authentication failure is only logged and the bytes go out anyway. Both threads are process singletons — a second call’sstartaudiore-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)updaterestarts 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: itspfca_uac_apattern_crypto_simple.xmlpasses its own RTP check (exit 0) against sipr playingpfca_uas_audio_crypto_simple.xmlunchanged.ereg search_in="hdr"(M25; verified incall.cppextractSubMessage): the haystack is the text after the first occurrence of the header string as a plain substring (header="CSeq:"gives1 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_indepselects case-insensitive matching; and an absent header undercheck_itfails 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 inscenario.cpp~l.1572,call.cpp~l.5946E_AT_VERIFY_AUTH,auth.cppverifyAuthHeader):usernameandpasswordare 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” isSIP/2.0, so it never verifies); the credential is the firstAuthorization:header only (Proxy-Authorization:is never consulted); every digest parameter —realm,uri,nonce,cnonce,nc,qop,algorithm(default MD5; matched by prefix, soMD5-sesscomputes 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-inthashes the request body; the RFC 2617 form withnc:cnonce:qopis selected bycnoncebeing present, not byqop;-auth_urireplaces the header’suri=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: theresponsehex is compared case-insensitively (SIPp’sstrcmprejects 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 inscenario.cpp~l.1065 andcall.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.retaddrand 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.retaddris 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"/>:pauserestoresetspaused_untilto the operand ((int), absolute), andrun()serves a pendingpaused_untilbefore executing the current message and thennext()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 ishandle_rhs(value=orvariable=,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.mainjump is tried before-aaauto-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 incall.cpp~l.5836E_AT_CLOSE_CON,socket.cppSIPpSocket::close~l.1045, ~l.1155-1168,call.cpp~l.1089, ~l.1481): it iscall_socket->close(); call_socket = nullptr, andclose()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)closeconnever closes anything: it drops the call’s reference, after which a further<send>on that call has no socket (send_rawasserts 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 insipp.cpp~l.1660 (multisocket),call.cppconnect_socket_if_needed~l.1419 and its call site at the top ofcreateSendingMessage~l.1737,E_Message_Local_Port~l.2753,socket.cppnew_sipp_call_socket~l.1340 and the call-creation branches ~l.1148-1185):multisocketonly 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 underun, the accepted connection undertn/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 countclosecondecrements). 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,-rsaand-t uilanded later, in M29–M31 — see their notes below); each per-call socket has its own receive thread rather than SIPp’s singlepollloop, so very large-max_socketvalues cost threads. -rsa host[:port](M29; verified insipp.cpp~l.1827 (parse, default port 5060),call_generation_task.cpp~l.152 andsocket.cpp~l.1146-1230 (the call’scall_peer),socket.cpp~l.2588 andcall.cpp~l.1489 (TCP dials it),call.cppsend_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 digesturi=— 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 sharedmain_remote_socketunless 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 itsremote_ipglobal (the command-line remote host, if any). Verified against real sipp in both roles, including sipr accepting the responses a-rsasipp UAS sends from its extra socket.- TCP/TLS reconnection
-max_reconnect/-reconnect_close/-reconnect_sleep(M30; verified insocket.cppreconnect_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 anddocs/transport.rst):reset_number(default 0: no reconnection; -1 unlimited) is a process-wide budget. A clean close (read returns 0) onlyinvalidate()s the socket and, withreset_close(default true),close_calls()— every call on it fails withE_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 anEPIPE, which queues areset_connection— if no budget is left that is a fatalERROR("Max number of reconnections reached")(exit -1), else the budget is spent, calls are closed again underreset_close, the main loop sleepsreset_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 (EPIPEon send, a recv error) queues the reset immediately. The order matters:send_rawdeletes 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 falseonly 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/l1as 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 countersfailed_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_closeor, 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.cppset_rate~l.228,run~l.90-110,wake~l.60): SIPp anchorslast_rate_change_timeat start-up and openselapsed × rate / rate_period − calls_sincecalls per run, so with-r 1 -rp 1000the 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 creditsrate × elapsed / rate_periodper 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 insipp.cpp~l.316 and ~l.1996 (peripfielddefault 0;-infrequired; UDP only), ~l.1572 (ip_file= the first-inf),socket.cppopen_connections~l.2466-2560 andcall.cppconnect_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_fieldcolumn (“on some machines it fails to bind to the self computed local IP”), andmap_perip_fdmaps 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 toip:local_portif 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]isgetsocknameon the call’s socket — the IP the call sends from — which is how auiscenario 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 insipp.cpp~l.209-243,socket.cpp~l.806-850, ~l.888-905, ~l.1575-1590, ~l.1694-1775, ~l.2076): SIPp uses one-to-oneSOCK_STREAMSCTP sockets, receives withsctp_recvmsg— one SCTP message is one SIP message, no Content-Length framing — holds sends untilSCTP_COMM_UParrives as anSCTP_EVENTSnotification, setsSCTP_NODELAY, and applies-heartbeat,-pathmaxret,-pmtu,-assocmaxretper peer address (SCTP_PEER_ADDR_PARAMS),-multihomeviasctp_bindx,-gracefulcloseas SHUTDOWN vs ABORT. A SIPp built withoutUSE_SCTPerrors “SCTP support is not enabled!”. sipr (cargo featuresctp, off by default;socket2) matches the socket type, the message-per-message model,s1/sn, the association-up gating (a blockingconnect), reliability (no retransmissions), reconnection and the clear error without support, with these divergences: noSCTP_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 thesctpmodule has a stack; macOS and Windows report “SCTP is not supported on this host”. Verified in Linux CI against a sipp built withUSE_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 byset_usersgrowth, never freed) and oneglobalVariables— 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 itsallocVarsoff the sameuserVariables, 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 VALUEseeds 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-sfon the command line, since the scenario loads as its flag is parsed).dump variablesprints 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 thanset usersnow allows, toretiredUsers; 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 variablesinto 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::findchecks 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--checkfails) 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 ofgin 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 usesusers + 1counting 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-setor 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::runreturns after a<nop>’snext()), 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-46CCallVariable::isSet,call.cpp~l.3968-3978E_Message_Variable, ~l.1933call::next, ~l.2241condexec): 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 astrue; so a zero counter and a false<test>result render empty.test="var"on a message andcondexecask the sameisSet. sipr now matches all of it (it used to print3,falseand0, and treated a"0"/"false"string as not set). Not matched on purpose: SIPp’sgetString()of a double is""(the source calls it a bug), sostrcmp/trim/urlencodeon a numeric variable see nothing there; sipr gives them the%lftext. - Manual transactions (M36; verified in
scenario.cpp~l.343-400get_txn, ~l.878-931, ~l.588-602validate_txn_usage;call.cpp~l.1128, ~l.2110-2116, ~l.4431-4450extract_transaction, ~l.4581-4587matches_scenario, ~l.5395-5430, ~l.5502-5504;docs/scenarios/ownscenarios.rst):start_txn="n"on a sent request stores the top Viabranchof the message as sent (up to;,,or whitespace) undern;ack_txn="n"on a sent ACK records that ACK’s step;response_txn="n"on arecv 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.” (onrecv 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 recordedack_txnACK 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): anack_txnACK 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 bothstart_txnandack_txnis an error (SIPp silently takesstart_txn). Verified against real sipp both ways. exec command=and<setdest>(M37; verified inscenario.cpp~l.265-270xp_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 throughsystem()in a double-forked grandchild — SIPp never waits for it and never sees its status, stdio is inherited (the>> fileidiom; output lands on the curses screen too) and asystem()failure is the grandchild’s WARNING “system call error for %s”. sipr matches the contract from one runner thread that spawnssh -c(cmd /Con 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 protocoludp|tcp|tls|sctpin 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 blockinggetaddrinfo(“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_reconnectcredit (“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-rsafor that call (SIPp copies the sending address intoremote_sockaddrat start-up and setdest overwrites the peer). sipr matches every check and its wording, with two deliberate differences: (1) eachsetdesterror fails the call, not the run (the same choice as for “Jump statement out of range”), logged ascall … 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"andsearch_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 needsvar;case_indep,occurrenceandcheck_it_inverseoneregare still not. (b)[next_url](call.cpp~l.5570-5580): SIPp copies the Contact intonext_req_urlonly for a recv withrrs="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 onrrs="true"on therecv response="200". sipr renders the last received Contact regardless ofrrs(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.5517executeActionbefore ~l.5641last_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-sippflag, and the first two are pinned bynext_url_and_last_headers_follow_siprs_reading_not_sippsintests/e2e.rs. (1)[next_url]withoutrrs(call.cpp~l.5570-5580): SIPp fillsnext_req_urlonly on a recv carryingrrs="true"and otherwise falls back to the last received request’s URI, which a UAC never has — so its own documentedsetdestexample depends on anrrsnobody 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.5517executeActionruns before ~l.5641last_recv_msg = …): SIPp’s keywords still name the previous received message, empty on a call’s first recv — which is why SIPp’s ownecho [last_From]example logs blank lines. Matching it would mean rendering[last_*]from the previous message whileeregin 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.cppcall::runreturns after a<nop>’snext()): 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, thedefault:arm ofwrite_primitive). After the far end closes a-t t1connection, sipp 3.7.7 reaches aSIPpSocketwhosess_transportreads 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 — soreal_sipp_tcp_uac_reconnects_to_siprskips 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 inscenario.cpp~l.1112parse_distribution, ~l.965-985 the<pause>branch and itssanity_check, ~l.1522sample;stat.cpp~l.1530-1880 theCSampleclasses;call.cpp~l.1956 the pause branch ofcall::run, ~l.6125E_AT_ASSIGN_FROM_SAMPLE): the distribution isdistribution="<kind>"and its parameters are separate attributes with SIPp’s names —fixedvalue;uniformmin/max;normalandlognormalmean/stdev(a lognormal’s are the log-space parameters, GSL’szeta/sigma);exponentialmean;weibulllambda(scale) /k(shape);paretok(shape) /x_m(minimum);gparetoshape/scale/location;gammak(shape) /theta(scale);negbinp/n. There is nopoisson. 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/maxalone meanuniform, and a barenormal="…"/exponential="…"/lognormal/weibull/pareto/gammaflag 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 exceedsINT_MAXms, as SIPp does; a negative binomial has no percentile in SIPp and is not checked. Sampling (enginesample.rs, one draw per pause or action from the seeded generator): SIPp uses GSL and is built with these only underUSE_GSL— a GSL-less sipp errors “The distribution ‘…’ is only available with GSL” for everything butfixedanduniform; sipr always has them. A pause sample below 1 (the negative tail of a normal) is no pause, as SIPp’sif (actualpause < 1) pause = 0.<sample>stores a double. Two deliberate divergences: (1) SIPp passes negbin’snandpto GSL swapped (gsl_ran_negative_binomial(rng, n, p)against GSL’s(rng, p, n)), so with its own documentedp="0.1" n="2"GSL gets a “probability” of 2 and the pauses are garbage — sipr draws the documented meaning, failures beforensuccesses at probabilityp; (2) the generalized Pareto’sshape="0"divides by zero in SIPp — sipr uses theshape → 0limit,location + Exp(scale). Poisson draws past a mean of 30 (inside negbin) use the normal approximation. The screen/--checklabel is SIPp’stextDescr: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,
-keyand-tdmmap(M39; verified inmessage.cpp~l.50-120 the keyword table and ~l.236-372SendingMessage’s dispatch,call.cppcreateSendingMessagetheE_Message_*arms,sipp.cppSIPP_OPTION_KEY/SIPP_OPTION_TDMMAP,call.cpp~l.113 the dynamic-id defaults and ~l.281get_tdm_map_number,stat.cppCStat::formatTime,time.cppgetmicroseconds): SIPp looks a bracketed name up in its keyword table before thelast_<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/-Nsuffix) are keywords, not header copies.[clock_tick]is milliseconds since the process started (SIPp’sclock_tick, a steady clock).[date]isgmtimein 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.uuuuuuor, with-rfc3339,YYYY-MM-DDTHH:MM:SS.uuuuuu<offset>; SIPp renders it in local time — sipr in UTC (offsetZ), the one deliberate divergence, so it needs no timezone dependency.[sipp_version]is the bare version number (SIPp drops itsv; 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="…"]repeatstext(defaultX) 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 VALUEdefines[KEYWORD]as the literal VALUE (SIPp’sgenericmap, 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 asX.h.Y/Z(SIPp’s formula,Zcycling 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-tdmmapis 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 circuitn-1busy and frees circuitn(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/--checkdump names each keyword;[file]shows as[file name=…]. - Statistics files at parity (M40; verified in
stat.cppCStat::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.cppprint_count_file,print_error_codes_file,print_screens;reporttask.cpp(-fdstattaskdumps the CSV, the counts and the error codes then resets the PL counters;-fscreentaskrefreshes the screen and resets the PD counters);sipp.hppthe defaults):-trace_statwrites<scenario>_<pid>_.csv(or-stf) with SIPp’s header —StartTime,LastResetTime,CurrentTime(theformatTimeform,-rfc3339aware),ElapsedTime(P|C)ashh:mm:ss,TargetRate(the-userscount in users mode),CallRate(P|C)with three decimals, the fixed counter pairs throughWatchdogMinor, thenResponseTime<rtd>(P|C)and…StDev(P|C)per RTD ashh: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) plusName_<bper bound andName_>=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*;OutOfCallMsgscounts messages for no call,DeadCallMsgsthose absorbed in timewait. SIPp’s genericcounter=columns are not written (sipr’s counters are per call, M44).-fddefaults 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-bgline.-trace_rttwrites<scenario>_<pid>_rtt.csv:Date_ms;response_time_ms;rtd_no, then perrtd=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++ostreamdefault number form (six significant digits), buffered-rtt_freq(200) rows between flushes.-trace_countswrites<scenario>_<pid>_counts.csv:CurrentTime;ElapsedTime(the latterhh:mm:ss:uuuuuu) then per visible step<index>_<name>_Sent,_Retransand, for a send withretrans=,_Timeout; for a recv_Recv,_Retrans,_Timeout,_Unexp; for a pause or timewait<index>_Pause_Sessions(times entered) and_Pause_Unexp; for a 3PCCsendCmd<index>_SendCmd, for arecvCmd<index>_RecvCmdand_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_Lostcolumns appear only with-lost(M42).-trace_error_codeswrites<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’sprint_screensorder; sipr’s screens are its own layout, not a copy of SIPp’s curses text.-periodic_rtdzeroes every repartition table (per RTD and call length) at each dump.-stat_delimiterapplies to all four CSV files. Found on the way: anrtd=with no matchingstart_rtd=measures from the call’s creation — SIPp initialises every RTD’s start time incall::init, and its own default UAC has onlyrtd="true"on the 200 — where sipr used to record nothing (so itsResponseTime1stayed 0 for the embedded UAC);repeat_rtdthen 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.cpptheTRACE_MSG/TRACE_SHORTMSGcalls inprocess_message(receive) andwrite_primitive(send),call.cppcallDebug/_callDebug,abort(the dump) andterminate(new deadcall),deadcall.cpp,sipp.cpptheSIPP_OPTION_LFNAME/LFOVERWRITEcases and the startuprotate_*fcalls,sipp.hppDEFAULT_DEADCALL_WAIT): every log is<scenario>_<pid>_<kind>.log—messages,errors,logs,shortmessages,calldebug,screens— or the-<kind>_filename;-<kind>_overwrite falseappends instead of truncating (SIPp also setsfixednamethere, which empties the name when no-<kind>_filewas given — a SIPp bug sipr does not copy).-trace_msgframes 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_errstarts withThe following events occurred:and each line is<time>: <text>(-rfc3339aware);<warning>actions land there.<log>actions go to-trace_logs(LOG_MSG, one line each, keyword-expanded) and nowhere without it.-trace_shortmsgwrites 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_calldebugbuffers per call SIPp’scallDebugentries (<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 abortAborting call <id> (index <n>).; an aborted call’s buffer is written underCall debugging information for call <id>:and its rule — a successful call writes nothing, as SIPp’sabortis the only dumper. The hash is sipr’s, not SIPp’s. Rotation (_trace):-ringbuffer_sizebytes written rotates the file — with-ringbuffer_filesN the current file is renamed<scenario>_<pid>_<kind>_<start seconds>.log(.<n>.logwhen the same second repeats) and the oldest beyond N is deleted; without it the file is truncated in place — and-max_log_sizecloses 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, oraborted at index <n>— and a late message for it is not out-of-call: SIPp’sdeadcallcountsDeadCallMsgs, warnsDead call <id> (<reason>), received '<msg>', writesDead 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 asDeadCallMsgs, M40).-trace_timeoutis 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-2520process_unexpected, ~l.2534-2600abortCall, ~l.6665-6830checkAutomaticResponseMode/automaticResponseMode, ~l.1242matches_cseq, ~l.1527lost, ~l.4628 the-pause_msg_igncheck,default_message_strings~l.2335;sipp.cppSIPP_OPTION_DEFAULTS,timeout_alarm, thesleeptime/nostdinsetup;socket.cppget_trimmed_call_id;call.hpp/sipp.hppthe defaults): SIPp retransmits an INVITE up to-max_invite_retrans(5) times and any other message up to-max_non_invite_retrans(9),-max_retransbeing a ceiling on both; the interval doubles from the send’sretrans=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 owntimeout=; the timeout fires the same way (ontimeoutlabel or a failed call).-timeout_errormakes reaching-timeoutan error — SIPp’s<scenario> timed out after '<s>' seconds, exit 255.-lost <percent>is the loss of every send and every recv whose ownlost=is absent; a received message that “loses” is dropped after matching, with amessage lost (recv)call-debug entry.-pause_msg_igndrops whatever arrives while the call is in a pause before anything is counted.-default_behaviorsis SIPp’s list (all,none,bye,abortunexp,pingreply,cseq;-xremoves,+x/xadds, left to right from none;-nd=none):abortunexpoff counts an unexpected message and continues the call (SIPp’s “Continuing call on unexpected message”);byeon ends an aborted client-side call the way SIPp’sabortCalldoes — 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);byealso answers an unexpected BYE or CANCEL with a 200 before aborting;pingreplyanswers an unexpectedPINGrequest with a 200 and drops the call, neither successful nor failed, as SIPp does;cseqmakes an ACK match only when its CSeq number is the last received INVITE’s (SIPpmatches_cseq). A server-side or secondary call never sends abort messages (SIPpcreationMode != 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,-nostdindisables the keyboard watcher.-send_timeoutand-timer_resolare 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
cis a hot key and the rest of the datagram is ignored:+ - * /step the rate (or the user count in-usersmode) byrate-scale,ptoggles pause,qdrains and a secondqaborts,Qaborts,1/2/3switch the TUI to the scenario / statistics / repartition screen (4..9are ignored). -
c+ a command line, split on the first space (tabs do not separate), exactly SIPp’s grammar and warning texts:Command Effect set rate Ncall rate (rate mode only) set rate-scale Nstep multiplier for the rate keys (default 1) set users Nuser count ( -usersmode 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 ( oocneeds-oocsf/-oocsn,rxneeds-rxsf/-rxsn; SIPp’sdisplay_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 PORTis 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 0disables 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.0opts 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.
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /health | — | {"status":"ok","version":"0.5.0"} (never needs the token) |
| GET | /stats | — | the statistics snapshot (below), refreshed about once a second |
| GET | /control | — | the control state (below) |
| POST | /control | any of rate, rate_scale, paused, users, limit | the control state after applying them, in that order; 400 with SIPp’s warning on the first refusal |
| POST | /quit | {"force":false} (default) drains, true aborts | 202 + control state |
| POST | /command | {"command":"set rate 10"} — any control-socket command line | the 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 cookiez9hG4bK) 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;
-aaauto-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/recvCmdbetween 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:
-rnew calls per period regardless of completions (models real traffic, can overload DUT).-usersmode is closed loop: fixed population, new call only when one ends. - cps — calls per second (the
-rrate). - RTD (response time duration) — stopwatch between
start_rtdandrtdmarkers 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
retransattribute overriding its base interval. Context matters. - Timewait — post-scenario linger absorbing late retransmissions before the
call slot is freed (SIPp
timewaitelement / 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).
| Date | Machine | Command | Result |
|---|---|---|---|
| 2026-08-16 | cloud sandbox (Linux, shared vCPUs) | -r 500 -m 5000 | 5000/5000 ok, 0 retrans, 10.1s |
| 2026-08-16 | cloud sandbox (Linux, shared vCPUs) | -r 2000 -m 20000 | 20000/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)
| Date | Machine | Command | Result |
|---|---|---|---|
| 2026-08-16 | cloud sandbox (Linux, shared vCPUs) | uac -r 2000 -m 20000 -d 10 vs uas | both sides 20000/20000 ok, 0 retrans, 10.1s |
| 2026-08-16 | cloud sandbox (Linux, shared vCPUs) | uac -r 5000 -m 50000 -d 10 vs uas | both 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-tui → sipr-stats → (nothing internal);
sipr-engine → sipr-scenario, sipr-net, sipr-auth, sipr-stats, sipr-media,
sipr-control; sipr-control → sipr-stats;
sipr-media → sipr-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
mpscchannel drained by the engine’s event loop thread. The only external dependency isrustls(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 insipr-netchange. Multi-core scaling comes from sharding engine loops (N loops × 1 socket each withSO_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 countOutOfCall. - 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) startratenew 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/-oocsnload an out-of-call scenario and-rxsf/-rxsna mixed-mode receive scenario next to the main one — at most one of the two, held as the engine’sSecondaryScenariowith aSecondaryKind. 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’sopen_calls/main_scenariodo. 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.-rxinffiles join the one injection table after the-infones. The snapshot the TUI and HTTP API read followsset display main|ooc|rxwholesale (Snapshot::display), as SIPp’s screens readdisplay_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:
- No heap allocation except the outbound buffer fill (reuse per-call buffers).
- 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. - No regex execution unless a scenario step explicitly uses
ereg(that cost is the user’s choice); regexes are compiled once at scenario load. - No lock held across
.await; prefer message passing to shared mutation. - Inbound parse uses rsip’s lazy header parsing — extract only the headers the
current
recvstep and dialog bookkeeping need (Call-ID, CSeq, Via branch, To/From tags, andrrs-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.cppis 300KB. Configuration is built once and passed asArc<Config>. - No protocol logic in
sipr-tuiorsipr-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-versionin the root Cargo.toml). Unsafe code is forbidden workspace-wide ([workspace.lints.rust] unsafe_code = "forbid"; every crate sets[lints] workspace = true). rustfmtwith default settings — no local style debates.cargo clippy --workspace --all-targets -- -D warningsmust pass;#[allow]requires an adjacent comment justifying it.- Public items in library crates get doc comments. Doc examples must compile
(
cargo testruns 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:
anyhowat 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
tracingeverywhere; noprintln!/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 withtracing::enabled!where formatting is expensive).- SIPp-style file outputs (
-trace_msg,-trace_err,-trace_stat) are product features, implemented as dedicated writers — not routed throughtracing.
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’sawait_holding_lockis 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/chancedistributions), dialog bookkeeping (tags, CSeq, route set fromrrs).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 Ncompleted = 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:
versionin the rootCargo.toml[workspace.package], and the pinned internal-dependency versions in[workspace.dependencies](they are pinned so the crates are publishable)..version— the plain version withoutv, no trailing newline. The install scripts read it frommasterto find the latest release.Formula/sipr.rbanddist/chocolatey/sipr.nuspec+tools/chocolateyinstall.ps1— reference copies; CD rewrites the live ones with real checksums, but keep these on the same version.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
- Creates a draft GitHub release.
- Builds
siprforx86_64-unknown-linux-musl,aarch64-unknown-linux-musl(static),x86_64-apple-darwin,aarch64-apple-darwin, andx86_64-pc-windows-msvc, and uploadssipr-vX.Y.Z-<target>.tar.gz(.zipon Windows) to the draft. - Publishes the release once every asset is up.
- In parallel,
scripts/publish-crates.shpushes the nine crates to crates.io in dependency order, skipping any version already there — only whenCARGO_REGISTRY_TOKENis set. - After the release is public, rewrites
Formula/sipr.rbintareqmy/homebrew-tapwith the new version and checksums — only whenTAP_GITHUB_TOKENis set. - Packs and pushes the Chocolatey package — only when
CHOCO_API_KEYis 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:
| Secret | Used for | Where to get it |
|---|---|---|
CARGO_REGISTRY_TOKEN | crates.io publish | crates.io → Account Settings → API Tokens, scope publish-new + publish-update |
TAP_GITHUB_TOKEN | pushing the formula to tareqmy/homebrew-tap | a fine-grained PAT with Contents: write on the tap repo only |
CHOCO_API_KEY | Chocolatey push | chocolatey.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 sipron 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.mdanddocs/SIPP_COMPAT.mdbefore 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
PLAN.md— architecture decisions, milestones, rationale. Do not contradict it; if a decision needs revisiting, say so explicitly and ask rather than silently diverging.docs/MILESTONES.md— what to build next and the acceptance criteria for “done”.- The doc relevant to your task:
docs/ARCHITECTURE.md— crate map, runtime model, data flow, hot-path rulesdocs/SIPP_COMPAT.md— the exact SIPp XML/keyword/CLI surface we implementdocs/CONVENTIONS.md— code style, error handling, logging, commit rulesdocs/TESTING.md— test layers and how to run them, incl. interop with real SIPpdocs/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/*.xmlanddocs/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.mdmust 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;
--checktreats 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-readdocs/ARCHITECTURE.md§3. - The TUI never touches the engine. It reads 1-second stat snapshots only.
unsafeis 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) — seedocs/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.mdfor 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 reachmain. - 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
ifpyramids. - 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.cppabsorbed most concerns over the years; the crate boundaries exist so that does not happen here.
Workflow
- Work in small, compilable increments; keep
maingreen. - Update
docs/MILESTONES.mdcheckboxes in the same change that completes them. - A new file under
docs/must be added todocs/SUMMARY.md: that is the table of contents of the documentation site (https://tareqmy.github.io/sipr/, built by mdBook frombook.toml).make book-buildchecks 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. Seedocs/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
| Role | Hex |
|---|---|
| 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 source | Responsibility | sipr home (crate/module) |
|---|---|---|
sipp.cpp | main loop, CLI parsing, global options | sipr bin, cli.rs |
xp_parser.cpp, scenario.cpp | XML parsing → scenario model, keyword substitution | sipr-scenario |
call.cpp (300KB!) | per-call state machine executing scenario steps | sipr-engine::call |
call_generation_task.cpp, ratetask.cpp | open-loop call arrival at rate -r/-rp | sipr-engine::pacer |
socket.cpp | transport, socket mgmt, retransmissions | sipr-net |
sip_parser.cpp, message.cpp | SIP message parse/build | sipr-net::message (in-tree lazy parser) + sipr-scenario templates |
auth.cpp, milenage.c | digest + AKA authentication | sipr-auth (digest only in v1) |
actions.cpp, variables.cpp | <action> exec: ereg/assign/test/…, call variables | sipr-scenario::actions |
stat.cpp | counters, RTDs, repartitions, CSV dumps | sipr-stats |
screen.cpp | ncurses live UI | sipr-tui (hand-rolled ANSI) |
infile.cpp | -inf CSV injection files | sipr-scenario::infile |
rtpstream.cpp, jlsrtp.cpp, prepare_pcap.c, send_packets.c | media | out of v1 scope |
watchdog.cpp, logger.cpp | health, trace files | sipr-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:
| Need | Planned crate | Shipped instead |
|---|---|---|
| CLI parsing | clap | table-driven parser in src/cli.rs (SIPp’s single-dash multi-char flags don’t fit clap) |
| Scenario XML | quick-xml | sipr-scenario/src/xml.rs, a subset parser with exact line tracking (like SIPp’s xp_parser.cpp) |
| Inbound SIP parse | rsip | sipr-net/src/message.rs, lazy and panic-free on arbitrary bytes; templates are raw bytes with slot filling |
| Runtime, UDP, timers | tokio, tokio-util | std threads feeding one mpsc event channel; pure TimerQueue + condvar driver |
ereg regex | regex | sipr-scenario/src/regex.rs, a backtracking ERE engine with a step budget |
| RTD histograms | hdrhistogram | sipr-stats/src/histogram.rs, 1 ms buckets |
| TUI | ratatui + crossterm | hand-rolled ANSI + stty raw mode, rendering as pure Snapshot → Vec<String> |
| Trace files | tracing | plain writers in sipr-stats with SIPp-style framing |
| Randomness | rand | seeded xorshift (deterministic, reproducible lost/chance) |
| Digest hashes | md-5, sha2 | sipr-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 asOutOfCallmessages. - 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-rnew 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),recvtimeouts, 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/rtdattrs) 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)
| Tier | Surface | When |
|---|---|---|
| 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 variants | done through M38 except -key (second backlog, M39) |
| later | exec play_pcap*, rtp_stream, rtp_echo, verifyauth, closecon, pauserestore, TCP/TLS-dependent attrs | with 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|uasselects embedded default scenarios (clean-room ports of SIPp’s defaults,crates/sipr-scenario/assets/);-sdprints 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 ownxp_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,
Referencesuppresses -
--checkmode: lint + print compiled IR; exit 1 on any diagnostic (warnings included — check mode is strict);-sfrole 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,retransoverride (incl.retrans="0"= off, base > T2 respected), max-retrans cap,-nrkill switch -
lostsimulation 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.cppand 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 /-mdrain) and hard quit (Q); exit codes 0/1/99 per SIPp’s documented table (+2 usage, 255 fatal; 97 lands withexec int_cmdat M6); global-timeoutfails 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) -
-aaauto-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/-fdCSV (pragmatic subset of SIPp’s columns with (P)/(C) naming — SIPP_COMPAT §6);-trace_msg/-trace_errfiles with SIPp-style framing - Periodic stat line in
-bgmode 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 +
sttyraw mode instead of ratatui/crossterm (unreachable registry, same as every dependency decision) — rendering is pureSnapshot → Vec<String>functions insipr-tui/src/render.rsand 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,ppause (pacer skips),qsoft quit,Qhard quit,sscreen 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 insipr-engine/tests/ui_bridge.rs - Terminal restored on every path: RawGuard drop (
stty -gsave / 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-bgis 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/condexecbranching,chance, namedcounters, andpause 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/lookupstay 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 exposesstale; 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 FIELDbuilds a key→line index over one field of an-inffile (matched by basename, SIPp-style); duplicate keys resolve to the last line. Index/lookup/insert/replace live insipr-scenario/src/inject.rs(pure) withRefCell-wrapped files in the engine so[fieldN]reads andinsert/replacemutations 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 insipr-engine/src/actions.rs). -
[fieldN]gained SIPp-faithful selectors:file=NAME(basename key, or a numeric-infindex as a sipr extension) andline=EXPRrendered at send time —line=[$var]is what makeslookupusable. The tokenizer now balances nested brackets to parseline=[$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: aTcpFramerthat de-frames a byte stream into SIP messages byContent-Length(RFC 3261 §7.5), skipping keep-alive CRLFs, and aTcpTransportwith 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/Tcpenum;-t t1binds TCP by role (connect for UAC, listen for UAS),[transport]rendersTCP, and SIP retransmissions are gated off for reliable transports (RFC 3261 §18.2). CLI acceptst1/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\nheader/body separator (UDP hid it). SIPP_COMPAT §6.
M10 — Classic 3PCC (sendCmd/recvCmd) ✅
-
sipr-net/src/twin.rs: anEscFramer(0x1B-delimited) and aTwinChannelover 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’eregsearches the raw command text). Compiler + model, with extended-3PCCdest=/src=rejected.-3pcc HOST:PORTCLI. - Engine wiring: the twin role is derived from the scenario’s first twin
command (sendCmd→dial, recvCmd→listen);
Event::TwinCmdwakes 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_commanddrives 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 andrefill_usersopens a replacement immediately, so the population stays constant until-mtotal. The rate pacer is disabled in users mode;-usersand-lare mutually exclusive. -
[userid]/[users]keywords, and USER-mode-inffiles 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_targethandles all forms, and a v6 target with no-iauto-binds the::family.-ialready took a v6 local address. -
[local_ip]/[remote_ip]render bracketed for IPv6 (SIPplocal_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::1binds 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:TlsTransportmirroringTcpTransport(connect/listen/local_addr/send_to), reusingTcpFramer. 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 onSSL_acceptfailure (SIPP_COMPAT §6). - Config/CLI, SIPp names:
-t l1(andln, collapsing onto connection-per-peer liketn);-tls_cert[cacert.pem],-tls_key[cakey.pem],-tls_ca,-tls_crl,-tls_version. Verification matches SIPp: OFF unless-tls_ca/-tls_crlgiven; 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.1errors (rustls has no TLS ≤1.1; SIPp’s floor is 1.0), encrypted keys rejected (SIPp uses a hardcoded passphraseksgr). - Engine:
TransportKind::TlsMono,Transport::Tlsarm,[transport]rendersTLS(ViaSIP/2.0/TLS,transport=TLSin Contact),reliable = trueso 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 l1both 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-s0hint), the SDP endpoint scan (sdp.rs: session/media-levelc=, first livem=<kind>, port 0 skipped), and the replay scheduler (replay.rs: onesipr-mediathread, min-heap of due streams, frames sent atstart + offseton the capture’s absolute timeline with burst catch-up, one UDP socket per destination-port offset preserving SIPp’sport_diffmapping, 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_dtmfare clear “M15” errors);<recv ignoresdp>(and the DTD’signosesdp) accepted;[auto_media_port]and[media_port+N]/[auto_media_port+N]keyword forms. Corpus:negative/media_pcap.xmlbecamepositive/pcap_play.xml;negative/media_rtp_stream.xmladded. - Engine:
-mi/-mp(-min_rtp_portalias) →[media_ip],[media_port](default 6000;auto=+ 4*(call-1) % 10000), pcaps resolved next to the-sffile 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’sm=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_sentsampled once a second into the stat set, TUI main screen line,-bgline, 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, e2eplay_pcap_audio_replays_capture_to_the_sdp_endpoint(two calls, distinctauto_media_portblocks, every payload verbatim) andplay_pcap_with_a_missing_file_is_fatal_at_startup, interopuac_pcap_against_real_sipp_uas(sipp-rtp_echoUAS 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,-keylookups inplay_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, dynamicH264/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/vpattern1..=6 fills, andRtpSource: 12-byte header (V=2, no marker, seq from 0, wall-clock-derived timestamp advancing byticks_per_packet, SSRC0xCA110000 + 2*(call-1) + video), payload spliced across the file end when looping, loop count-1= forever, pause fast-forwards the clock (SIPpTI_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 at400 + (k+1)*2*tone + curwith marker on the first andduration = cur*8, three end packets 1 ms apart, one RTP timestamp per event, digits0-9*#A-D, tone clamped to 50..=2000 else 200). Generated as a syntheticPcapStreamand 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 packetnis due atstart + n*interval(burst catch-up, no drift);pause/resumecommands per call or perrtp-audio/rtp-videotag. - 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. Corpuspositive/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-mpin 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 thatm=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, interopuac_rtp_stream_against_real_sipp_uas(endless stream vssipp -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),-keylookups 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 onalgorithm=, as SIPp);aka_challenge_responsedecodes the nonce as base64(RAND ‖ SQN⊕AK ‖ AMF ‖ MAC-A), recovers SQN, verifies MAC-A against f1, and yields RES/CK/IK;digest_responseuses the 8 raw RES bytes as the password (NUL bytes survive — SIPp passes RESLEN explicitly for the same reason).authorization_headernow returnsResult. -
[authentication ... aka_K= aka_OP= aka_AMF=]with SIPp’s0xhex 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 usesaka_AMFwhen 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), e2eaka_v1_md5_registration_round_trips(a registrar built from Test Set 1 verifies the response with RES) andaka_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-renderedaka_*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 vsc+set rate| rate-scale|users|limit|display|hide/trace error|messages|logs| shortmessages on|off/dump tasks|variables/reset stats, first-space tokenization,strtolbase-0 numbers), the UDP socket (udp.rs:-cptried 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 byrate-scaleand act on the user count in-usersmode,qtwice =Q.set usersgrows the id pool or lets excess calls finish;set limitupdates the cap;trace messages|error on|offopens/closes trace files at runtime with SIPp’s names;dump taskslists active calls in the error trace;reset statszeroes counters and histograms (newStatSet::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,_msdurations),/controlGET/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 0disables it, the chosen port is printed, screen digits are ignored,set display ooc|rxandtrace logs|shortmessageswarn 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(acset ratedatagram finishes a slow run,qdrains early, a bad command warns),http_api_reports_stats_and_controls_the_run, and the token gate. - Deferred:
set hide/displaysemantics 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::echobinds 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; countersrtp_echo_packets/rtp_echo2_packets(SIPp’s 1st/2nd stream) on the TUI,-bgline, and/stats. -
<rtp_echo value="0|1"/>action →Action::RtpEchoState: flips the process-wide switch (SIPprtp_echo_state); a scenario using it without-rtp_echogets 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::CheckResultwhen the stream ends.-audiotolerance/-videotolerance(0.0..=1.0):failed/sent ≥ tolerancefails the check.rtp_check_ok/rtp_check_failed/rtp_bytes_receivedstats; a failed check makes the exit code 253 (SIPp’sEXIT_RTPCHECK_FAILED= -3 as the shell sees it) and the summary saysrtpcheck 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_streamrun 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_echowarning, interoprtpcheck_against_real_sipp_echo(sipp -rtp_echoechoes, sipr passes 1/1). - Deferred:
exec rtp_echo=startaudio|…(SIPp’s per-call SRTP echo threads — SRTP is out of scope),-rtpcheck_debughex 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:AkaKeysgainssqn_ms(the client’s highest accepted SQN) andforce_resync;aka_challenge_responsestill verifies MAC-A first, then — when the challenge’s SQN is not above SQN_MS, or when forced — computesAUTS = (SQN_MS ⊕ AK*) ‖ MAC-SwithAK* = f5*(RAND)andMAC-S = f1*(K, RAND, SQN_MS, AMF* = 0x0000).authorization_headerthen carriesauts="base64(AUTS)"and a digest computed with the empty password, as RFC 3310 requires. A forced resync withoutsqn_msechoes the challenge’s own SQN. - Keyword params (sipr additions):
aka_sqn=0x<12 hex>(SQN_MS) andaka_resync=1(force AUTS on every challenge, to exercise a server’s resync path). Corpuspositive/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, orms/s/m/hsuffixes; default the-fdinterval)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 (SIPpquitting += 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-usersmode, as in SIPp.-rate_scale N(SIPp’s CLI flag for the+ - * /step) added too. - Tests: unit
ramp_step_follows_sipp_ratetask, e2erate_increase_ramps_the_rate_up(a 1 cps run finishes 40 calls in seconds after the ramp) andrate_max_quits_when_exceeded_unless_no_rate_quit(both branches). - Note: sipr’s
-fddefault is 1 s, so an unqualified ramp ticks every second; SIPp’s-fddefault is 60 s. Give-rate_intervalexplicitly 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, elseremote_ip:remote_port). sipr used to signsip:service@ip:port— a visible-on-the-wire difference, now gone. SIPp’ssip: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-inffile verify against the digest registrar),auth_uri_flag_and_default_follow_sipp(the message trace showsuri="sip:ip:port"by default anduri="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"anddisplay="…"on any message command land inStepCommon;displayreplaces the derived scenario-screen label,hidemarks 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(hiddenper step,hideoverall) so headless runs can see them too. - Screen keys:
1scenario,2statistics,3repartition work at the TUI keyboard and over the control socket (forwarded through the snapshot as a sequenced request);4/5(variables, TDM map) and6..9(secondary repartitions) have no sipr screen and are ignored.sstill cycles. - Tests: compiler (
hide/displayon recv, nop, pause; blank display is none), TUI render (hidden rows follow the switch; digit mapping), corpuspositive/hide_display.xml, e2ehidden_steps_and_display_labels_reach_the_stats_api(displaylabel andhiddenflag in/stats;set hide falseover/commandflipshide). - Deferred:
-hideCLI default. (set display oocshipped with M33,set display rxwith 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, SDESinline:encode/decode (40 base64 chars,|lifetime|MKIignored),SrtpContext::protect/unprotectwith RFC 3711 §3.3.1 ROC estimation,UNENCRYPTED_SRTP(authenticate only).sipr-medianow depends onsipr-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-Noffset that reuses the key on re-INVITE),[ue{aescm128sha180,aescm128sha132}{1,2}{audio,video}]→UNENCRYPTED_SRTP. Keys are generated before rendering (aprepare_cryptopass, like rtpstream ports), from the seeded RNG. - SDP: the first two
a=crypto:lines of the livem=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_debugaccepted 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), e2esrtp_stream_passes_the_echo_check_against_an_srtp_echo_peer(a scripted peer that re-keys the echo, as SIPp’s does), interopsrtp_against_real_sipp_echo(sipr’s SDES offer + PRACK againstpfca_uas_audio_crypto_simple.xml; self-skips without the SIPp tree). - Interop finding: sipp’s
-srtpcheck_debuglog proves it accepts sipr’s SRTP (rc == 0on every packet), but its echosendtofails 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 olderAuthorization: [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) andbare_authentication_keyword_renders_the_full_header_line; the existingAuthorization: [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 toAction::RtpEcho(RtpEchoCmd{verb, video, payload_type, payload_name}); unknown verbs, a payload type > 127 and a codec SIPp would not know (viaRtpParams::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 fromCallCrypto::negotiate— receive under the peer’s SDES key, re-protect under ours keeping the caller’s SSRC and sequence numbers,send_tothe 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 feedrtp_echo_packets/rtp_echo2_packetsalongside the global-rtp_echoecho. - Found on the way and fixed:
ereg search_in="hdr"handed the regexp wholeName: valuelines and could not match SIPp’sheader="CSeq:"spelling at all; SIPp’sextractSubMessageyields the rest of the first line after the header string (leading space included) and fails the call undercheck_itwhen the header is absent. SIPp’s UAS scenarios replayCSeq: [$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; corpuspositive/srtp_echo_uas.xml; e2esrtp_echo_server_passes_a_peers_echo_check(sipr UAC’s rtpcheck against a sipr echo server) andrtp_echo_with_an_unknown_codec_fails_at_load; interopreal_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 toAction::VerifyAuthwith both credentials as message templates (rendered at execution,[$var]/[fieldN]allowed). -
sipr_auth::verify_authorization: MD5 and SHA-256, with or without qop (cnoncepresent selects the RFC 2617 form, as SIPp),auth-intbody hashing,-auth_urioverride of the header’suri=; 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 firstAuthorization:header. - Tests: auth unit (SIPp’s own MD5 and SHA-256 vectors, sipr’s qop=auth
header,
-auth_uri, auth-int, scheme/algorithm errors); compileverifyauth_compiles_with_templated_credentials; e2everifyauth_accepts_the_right_password_and_branches_to_200/…rejects_a_wrong_password_and_branches_to_403(SIPp’s registrar recipe verbatim, branching withtest=/next=); interopsipr_verifyauth_judges_real_sipp_credentialsandreal_sipp_verifyauth_judges_sipr_credentials— both directions, right and wrong password. - Deferred: SIPp’s
TRACE_CALLDEBUGline 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.retaddris non-zero (SIPp’s “already in a jump”). -
<jump value=|variable=>(SIPp’shandle_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+ corpuspositive/unexp_handler.xml(SIPp-loadable); e2eunexp_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) andclosecon_is_accepted_over_tcp; interopsipr_unexp_handler_against_real_sipp_uacandreal_sipp_unexp_handler_against_sipr_uac— the same corpus scenario played by each tool against the other’s INFO. - Deferred:
un/tn/lnper-call sockets (the only mode wherecloseconcloses 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_viaon 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 uistays a clear error. - Tests: net unit (
per_call_socket_round_trips_and_closes,per_call_connections_are_distinct_and_close_on_drop); e2eudp_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; interopsipr_per_call_sockets_against_real_sipp_uas(un,tn) andreal_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 underun/tn/ln), and[remote_ip]/[remote_port]/digesturi=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--pport, the caller gets nothing),rsa_tcp_uas_dials_the_sending_address; interoprsa_both_ways_against_real_sipp(sipr UAC-rsa→ sipp, sipp UAC-rsa→ sipr, sipp UAS-rsaanswering sipr from its extra socket). - Deferred:
[remote_ip]on a UAS follows SIPp’sremote_ipglobal.
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::reconnectandTlsTransport::reconnectre-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_reconnectbudget 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. Countersfailed_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); interopsipr_tcp_uac_reconnects_to_real_sippandreal_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::localcarries the receiving address for every transport). - Tests: net unit
call_socket_at_binds_the_given_address_and_packets_carry_local; CLI parse; e2eui_client_sends_each_call_from_its_lines_ip(source IP alternates with the file,[server_ip]in the Via matches it) andui_server_answers_on_the_ip_the_request_hit; interop with real sipp’s-t uiin 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(featuresctp): one-to-oneSOCK_STREAM/IPPROTO_SCTPsockets via socket2; blockingconnectreturns at association-up (SIPp’sSCTP_COMM_UPgating); each read is one SCTP message = one SIP message (no Content-Length framing);s1mono andsnper-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 s1is a clear start-up error (SIPp: “SCTP support is not enabled!”). - CLI:
-t s1|sn; SIPp’s-multihome,-heartbeat,-assocmaxret,-pathmaxret,-pmtu,-gracefulcloseare 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), e2esctp_mono_and_per_call_calls_complete(skips) andsctp_without_a_stack_or_feature_is_a_clear_error, interopsctp_both_ways_against_real_sipp(skips unless sipp banners-SCTP). CI jobsctpon ubuntu:modprobe sctp, sipp built from source withUSE_SCTP,cargo test --features sctp, the interop test. - Verified in Linux CI (run 33977957760, 2026-09-05):
sctpjob green —messages_keep_their_boundaries_and_round_trip,sctp_mono_and_per_call_calls_complete, andsctp_both_ways_against_real_sippall ran (not skipped) against a SIPp 3.7.7 built withUSE_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 —OocScenarioin 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 likeuac/uas) andooc_dummy;-sd ooc_default|ooc_dummydumps them and-snaccepts 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_packet→spawn_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’sopen_callsignores 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|mainover the control socket (SIPp has no screen key for it —sipp.cppkey switch verified) swaps the scenario page to the ooc scenario’s steps (Snapshot::display_ooc, HTTP/statsdisplayfield); the statistics stay the main scenario’s. Correcting the entry above: SIPp never dumps ooc stats to CSV (reporttask.cppstattask::reportdumpsmain_scenarioonly), so neither does sipr. -
--checklints 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_matchescompared literally), soooc_default’srequest=".*"matched nothing. Now the regex runs over the method / decimal status code as in SIPp’smatches_scenario(unit testregexp_match_searches_the_method_and_the_status_code). - Tests: scenario unit (embedded ooc scenarios parse;
[fieldN]detection); CLI unit + binary (-oocsf/-oocsnparse and conflict, server-mode fatal, injection fatal, unknown name,-sd,--check); e2euac_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) andset_display_ooc_swaps_the_scenario_screen; interopreal_sipp_ooc_scenario_answers_siprs_out_of_call_optionsandsipr_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/-rxinfmixed-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’sOocScenariointo one secondary-scenario type carrying a role (ooc | rx) so the two share compile, stats, display and--checkplumbing.-rxsnacceptsuasand the other embedded names as SIPp’s parser intends (record SIPp’s table typo in SIPP_COMPAT §6; do not add-rxrn).-rxsf/-rxsnare 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 tospawn_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-inffile, 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|mainover the control socket (HTTP/statsdisplaygainsrx); the screen header reads SIPp’s mixed-mode lines. Align the display semantics with SIPp: its main counters, statistics screen and repartition screens all followdisplay_scenario, with server-style columns when rx is displayed. This corrects M33’s “the statistics stay the main scenario’s” — fixSnapshot::display_ooc(adisplay: Main|Ooc|Rxenum 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/-stfstay main-only (SIPpstattask::report). -
--checklints 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); e2euac_terminates_incoming_calls_with_rx_scenario(sipr UAC on the main scenario against a peer that originates an INVITE mid-run; the rxuasscenario 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-rxinffield 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); interopreal_sipp_and_sipr_terminate_each_others_calls_in_mixed_mode(both sidesuac+-rxsf/-rxsn uas, three calls each way, a timewait on the main scenario keeping each side up for the peer’s last call) andsipr_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/-rxrnbreakage,-rxinffiles 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 M22set display rxdeferral 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 (variablesrequired; 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--checkcatches 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 (-setor 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’suserVarMap) and one global store shared by every call of both scenarios (the secondary scenario’sallocVarshangs off the sameuserVariables). 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::getreturns aVarRefborrowing the layer; the shared layers areRc<RefCell<…>>). AVarSpaceunions the user and global names of both scenarios so one name is one slot across them (SIPp’s shareduserVariables/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’susers + 1that 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). -
--checkprints 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); e2euser_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 printed3) 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-stepstart_txn: Option<TxnId>/ack_txn: Option<TxnId>onSendStep,response_txn: Option<TxnId>onRecvStep, ids resolved at compile time. All of SIPp’s placement errors above with its wording;validate_txn_usageatfinish(). A request step withstart_txn/ack_txnstays out of the CSeq-method guard list (precompute_cseq_methodsfollows suit). The IR dump showsstart_txn=name/ack_txn=name/response_txn=nameand atransactions:line. sipr addition:start_txnandack_txnon 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: astart_txnstep stores the rendered message’s top-Via branch, anack_txnstep its index. On receive (scan_for_match/recv_matches): aresponse_txnrecv 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 withoutresponse_txnbehaves exactly as today (Scan::OldTxn,on_old_transaction_response,resend_stepover the extractedrender_send; the backward scan only walks past the contiguous optional block when the scenario names transactions). -
--checkprints the transaction table; embedded scenarios untouched. - Tests: scenario unit (the attributes compile and resolve; each
placement error and each
validate_txn_usageerror with SIPp’s wording; astart_txnrequest leaves the method list); engine unit (branch extraction from a rendered Via with parameters and commas;recv_matcheswith aresponse_txnaccepts the branch and rejects a same-method response from another branch); e2eresponse_txn_matches_the_right_invite_of_two_overlapping_ones(a UAC sends INVITEstart_txn="a", then a re-INVITEstart_txn="b"beforea’s 200 arrives; the scripted UAS answersbfirst — the scenario’srecv 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) andlate_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); interopmanual_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 answeringfirstfirst, 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 toAction::ExecCommand (MsgTemplate)(mutually exclusive with the otherexecattributes, as today);<setdest host= port= protocol=/>toAction::SetDest { host, port, protocol: MsgTemplate }with the three attributes required (xp_get_stringis fatal without them; SIPp’s wording) and unknown attributes warning. Both run from<recv>,<nop>,<send>actions like any other;--checkdumps them. The DTD’ssampleand the standaloneindexstay 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 spawnssh -c <cmd>(cmd /Con 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 modestn/sn— the call’s own connection is closed and re-dialled to the new peer, a failure counting against-max_reconnectand 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’sremoteonly:[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 intoremote_sockaddrat start-up andsetdestoverwrites the call’s peer, so setdest wins; sipr overwritescall.remotethe 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
setdestattributes error;exec command=with a media attribute still errors); engine unit (setdest validation messages; protocol parsing incl. case); e2eexec_command_runs_a_shell_per_matching_ message(a UAS scenarioecho [last_From] >> from_list.logon each INVITE against 3 sipr UAC calls: the file holds the three From headers, sipr exits 0, no zombie — checkpsshows 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 scenarioeregs 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) andsetdest_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]→ereg→setdest) 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 anexec command=scenario on both, each side’s>> fileoutput compared. Deviations from the plan, all recorded in SIPP_COMPAT §6: the zombie check is onepsafter 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 scenariossetdestin 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"andsearch_in="var" variable=were missing (SIPp’s setdest idiom needsvar) — added;[next_url]in SIPp needsrrs="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 exampleecho [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::processsuffices).
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, thesanity_check99th-percentile guard;<sample>compiles toAction::Sample { assign_to, distribution };--checkand the scenario screen show SIPp’stextDescr. 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; corpusstatistical_pauses.xml(+ a negativepoisson); an e2e run of that scenario with its--checkdump; interopstatistical_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=1so both halves run there. - Docs: SIPP_COMPAT §1 (pause attributes,
sampleaction, 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-rfc3339for[timestamp]. A-keyname that is also a built-in keyword loses: SIPp checks its table first, so does sipr. - Renderer: eleven new
Keywordvariants (the ten plus[file name=], SIPp’s prefix-handled keyword the table sweep missed) andGenericfor-key; aRunInfoon the render context carries the run-wide inputs (clock,-keypairs, the[dynamic_id]counter, the TDM table, the[file]cache).[sipp_version]renders the bare version number like SIPp’s;[timestamp]is UTC (documented).-tdmmapcircuits 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,-keynames viaCompileOptions); corpuskeywords_m39.xml; CLI tests for the two-argument-keyand-tdmmap’s wording; e2e runs asserting the rendered headers a responder receives (-key,[remote_host],[dynamic_id],[fill],[last_cseq_number+1],[tdmmap]); interopm39_keywords_both_ways_against_real_sipp(sipr and sipp each run the same-keyscenario 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-statscsv_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’shh:mm:ss/hh:mm:ss:uuuuuu/ three-decimal formats,(P)as a per-dump period;-stat_delimiter;-periodic_rtd;-fddefault 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 aStepKindper 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/-bgline period). File names<scenario>_<pid>_{,rtt,counts,error_codes}.csvand_screens.logas SIPp’s. Rows are built off the per-message path, in the-fddump 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_rttand-trace_countsheaders 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
-fddefault 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’sLOG_MSG;<warning>stays in the error trace);-trace_shortmsg/-shortmessage_filewith SIPp’s tab layout and its receive-side time quirk;-trace_calldebug/-calldebug_filewith SIPp’s entries, dumped on abort only;-trace_timeoutaccepted as the no-op it is in SIPp;-error_file,-message_file; the message frame and error line rewritten to SIPp’s exact shapes (-rfc3339aware). 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_sizewith SIPp’s rotated names and the-<kind>_overwriteflags, in the stats crate’sTraceFile(the writes stay on the engine thread as before — a bufferedwrite_allper 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 asDeadCallMsgs, warns and traces as SIPp’sdeadcalldoes, 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) andcalldebug_dumps_aborted_calls; interopshort_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
fixednamebug not copied); the M17 note ontrace 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_timeshared; aparse_time_msvariant for SIPp’sTIME_MSoptions whose bare number is milliseconds). -
-default_behaviorsas aBehaviorsbitset (-nd=none) driving SIPp’sabortCallmessages from its own built-in templates, the unexpected BYE/CANCEL/PING answers, the continue-on-unexpected mode and the ACK CSeq guard;-lostas the send and recv default;RetransCapssplit INVITE/non-INVITE with-max_retransas the ceiling and the T2 cap only for non-INVITE. Found on the way: sipr parsed-ndbut 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; e2edefault_behaviors_abort_or_continue_on_an_unexpected_message(default abort + abort BYE,-ndcontinue,all,-bye,-pause_msg_ign) andtimeout_retrans_and_loss_knobs(-recv_timeout,-max_invite_retrans 1,-timeout_error,-lost 100); interopmax_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
-ndsentence; 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 optionalrecvCmd. - 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 linePRINTF=<n>and aprintf-style template expanded to n lines — verify the exact substitution) — the last injection-file mode missing. Done:PRINTF=/PRINTFOFFSET=/PRINTFMULTIPLE=,%[0-9.-]*dand%%, virtual lines over cycling rows, indexing and-usersover the virtual count,insert/replacerefused; the two divergences are in SIPP_COMPAT §1. -
<rtp_echo variable="…">(toggle from a variable,call.cppE_AT_RTP_ECHO). Done: the action takes SIPp’shandle_rhspair (value=xorvariable=) 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-ionly, not all interfaces),-buff_size,-sendbuffer_warn;-bind_to_deviceon 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 render0.0.0.0without-i) and-bind_localbinds that address.-buff_size/-bind_to_devicearesocket2calls in the newsipr-net::sockopt, applied to every SIP socket;-sendbuffer_warnfollows 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-s0advice for the classic format). Sanctioned-dependency check: an in-tree block reader, no crate. Done:sipr-media::pcapng, std only;pcap::parsedispatches 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]withoutrrs,[last_*]inside the matching recv’s own actions, and the M35 action-step interleaving): either match SIPp behind a--sipr-strict-sippflag 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,-pluginand the SCTP socket options (-multihomeetc.): 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_reasonis 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/metricson the existing HTTP API. - A load-comparison bench: criterion + a documented
make bench-vs-sippthat runs both tools at 500/2000/5000 cps on loopback and records CPU, memory, retransmissions and max concurrent calls indocs/PERFORMANCE.md; the hot-path rules were designed but never measured against SIPp. - Library API:
sipr-engineembedded in another Rust test harness (scenario in, stats out, no CLI, no TUI) — needs a stableEngineConfigand a documented public surface. - Scenario linting beyond
--check: unreachable labels,optionalrecv 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 NAMEwith-slave_cfg FILE(name;host:portlines),sendCmd dest=routed to the named peer andrecvCmd src=checked against the command’sFrom:line, on SIPp’s wiring (every instance listens on its table address; the master dials itsdest=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 optionalrecvCmdlets 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_callis 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 frommasterby 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_retransas 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-ndasnone) 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_timeoutand-timer_resolare accepted with a warning. The[last_Request_URI]keyword. - Message and error logs at parity (M41):
-trace_msgframes and-trace_errlines take SIPp’s exact shapes (timestamps, theThe 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>_overwriteflags,-ringbuffer_files/-ringbuffer_size/-max_log_sizerotation and-deadcall_waitare implemented as SIPp’s;-trace_timeoutis accepted (a no-op in SIPp too).trace logs|shortmessages on|offwork on the control socket. - Statistics files at parity (M40):
-trace_statwrites SIPp’s full column set (StartTime…WatchdogMinor,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_rtdand-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 VALUEdefines generic keywords. New flags-tdmmap,-dynamicStart/-dynamicMax/-dynamicStepand-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’ssanity_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-ndor-default_behaviors …,-bye; a///prefix in an inbound Call-ID is stripped as SIPp’s 3PCC marker unless-callid_slash_ign. - The
-trace_msgframe no longer carries the peer address, and-trace_errlines 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. -fddefaults to 60 s as in SIPp (it was 1 s); the final statistics row is still written at exit. The-trace_statheader changed from sipr’s earlier subset to SIPp’s columns, so parsers keyed on column position need SIPp’s layout.--checkand 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(theWSAECONNRESETquirk 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
CDworkflow 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 thetareqmy/homebrew-tapformula, 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, anddocs/INSTALLATION.mdcovering every method. cargo-denypolicy (deny.toml) checked in CI, and a CI portability job building and testing on macOS and Windows.SECURITY.mdandCONTRIBUTING.md.
Changed
- TLS PEM files (
-tls_cert,-tls_key,-tls_ca,-tls_crl) are now parsed withrustls-pki-types, the crate rustls itself uses, replacing the unmaintainedrustls-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.mdactually 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 rejectedsetdestfails that call rather than the run.[remote_ip]/[remote_port]keep the nominal remote, as in SIPp.ereg search_in="body"andsearch_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]needsrrs="true"to carry the Contact in SIPp,[last_*]inside a recv’s own actions still name the previous message there, and SIPp’secho [last_From]example breaks under any shell without quoting.
0.25.0 — 2026-09-19
Added
- Manual transactions (M36):
start_txnandack_txnon<send>,response_txnon<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.--checklists 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 usersnow 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 VALUEseeds a global;dump variablesover the control socket lists the scopes;--checkprints 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 print3), a true bool astrue, and a zero double, a false bool or an unset variable as nothing at all;test="var"andcondexecon 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 against3should compare against3.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,-rxinffiles joining the injection table after the-infones, andset display rxover 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|rxnow 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/statsdocument gains amixedflag.
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 embeddedooc_defaultandooc_dummy(dumpable with-sd), own per-step statistics, no[fieldN]/-infin ooc calls, and ooc calls never count toward-m/-l/-users.set display ooc|mainover 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 arequest=".*"step matched nothing.
0.21.0 — 2026-09-06
Added
- SCTP transport (
-t s1|sn) behind the new off-by-defaultsctpcargo feature, viasocket2: 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 thesctpmodule); SIPp’s SCTP option flags are rejected with an explanation. Exercised in Linux CI against a SIPp built withUSE_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_socketcaps 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 receivedAuthorization:header is verified against a username and password (MD5 or SHA-256, qop auth/auth-int,-auth_uri) and the boolean verdict drivestest=branching, exactly SIPp’s documented recipe. Verified in both directions against real sipp.
0.14.0 — 2026-09-05
Added
- SRTP echo server —
exec 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’spfca_uas_*_crypto_*.xmlscenarios 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 (soheader="CSeq:"works andCSeq: [$1]replays the caller’s CSeq), and an absent header fails the call undercheck_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 earlierAuthorization: [authentication …]spelling still works.
0.12.0 — 2026-09-05
Added
- SRTP with SDES keying — SIPp’s crypto keywords (
[cryptotag1audio],[cryptosuiteaescm128sha1801audio],[cryptokeyparams1audio], theue…unencrypted forms, secondary and video variants) render offers and answers; the peer’sa=crypto:lines are parsed;rtp_streampackets 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
hideanddisplayattributes, and SIPp’s screen keys —hide="true"keeps a step off the scenario screen whileset hide true(the default) holds;display="…"replaces its label. Both reach/stats. The1/2/3keys switch screens at the keyboard and over the control socket.
0.10.0 — 2026-09-04
Added
-auth_uri— SIPp’s flag for the digesturi=; the value gets asip:prefix exactly as SIPp does.- Keywords inside
[authentication]parameters —username=[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’ssip:remote_ip:remote_port(no user part) instead ofsip:service@remote_ip:remote_port. Servers verify against the header’s ownuri=, 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 orms/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_scalefor 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 withaka_resync=1) is answered withauts=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_streamsockets now read back what the peer echoes and compare it to what was sent, and-audiotolerance/-videotoleranceturn 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-bgline, and/stats.
0.6.0 — 2026-09-04
Added
- Runtime control — SIPp’s UDP control socket (
-cp,-ci: hot keys andc-prefixedset/trace/dump/resetcommands with SIPp’s grammar and warning texts; default bind is loopback and-cp 0disables it) and a new HTTP/JSON API (--sipr-http [HOST:]PORT,--sipr-http-token):/health,/stats,/control,/quit,/command,/scenario. Seedocs/CONTROL_API.md. New std-only cratesipr-control. - Hot keys now follow SIPp exactly:
set rate-scalesteps, user-count keys in-usersmode, and a secondqaborts likeQ.
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, plusaka_OPc=) against analgorithm=AKAv1-MD5challenge: 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’srtpstream.cppsemantics on the M14 scheduler: raw codec files orapattern/vpatternfills with SIPp’s fixed payload table, looping,pause/resume(the clock keeps running, as in SIPp), SSRC0xCA110000-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 indocs/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-onlysipr-mediacrate 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,-bgline, and the final summary. Divergences from SIPp indocs/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]rendersTLS.-tls_cert/-tls_key(defaultscacert.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 onrustlswith theringprovider — the workspace’s first external dependency, still no system OpenSSL required. Divergences from SIPp documented indocs/SIPP_COMPAT.md§6.
Changed
-
The
dependencies: std-onlyclaim is retired:sipr-netnow carriesrustls/rustls-pemfilefor 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’slocal_ip_w_brackets), while[media_ip]stays raw for SDP. Seedocs/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 Nkeeps 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-infinjection (line = user id − 1). Mutually exclusive with-l. Seedocs/SIPP_COMPAT.md§6. - Classic 3PCC —
-3pcc HOST:PORTplus<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. Seedocs/SIPP_COMPAT.md§6. - TCP transport —
-t t1runs SIP over TCP. A stream framer de-frames messages byContent-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 tnis accepted as an alias. Seedocs/SIPP_COMPAT.md§6. - Injection files —
-inf FILE(repeatable) loads SIPp-style injection files: aSEQUENTIAL/RANDOM/USERmode 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=NAMEselects another file by its basename (SIPp’s key), or by a 0-based-infindex (sipr extension);line=overrides the per-call line and is rendered at send time, soline=[$var]works. Unknown field/file names are rejected at load. Seedocs/SIPP_COMPAT.md§6. - Indexed injection —
-infindex FILE FIELDbuilds a key→line index over one field of an-inffile (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 islookup → [fieldN line=[$var]].
Fixed
- Body-less SIP messages (180, ACK, empty 200) now always include the
mandatory
\r\n\r\nheader/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;--checklint 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. - Actions —
ereg(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. Plustest/condexecbranching,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’scall.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/-fdCSV, and-trace_msg/-trace_errfiles. - 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, honoringNO_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,
Makefileconvenience targets, CI (fmt + clippy + tests, and an interop job against real SIPp), and the Ferrous brand kit underbrand/.
Known limitations
- Signaling only over UDP. TCP/TLS,
-infinjection files (and thelookup/insert/replaceactions), 3PCC, RTP/pcap media, IPv6, and an HTTP control API are on the post-v1 roadmap. - The
eregregex engine is leftmost-first greedy (PCRE-style), not POSIX leftmost-longest — identical on the patterns real scenarios use; seedocs/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 indocs/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).