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.