feat!: in-house SIP engine, full call lifecycle#43
Merged
Conversation
Add plan doc 08 for a clean-room SIP transaction/dialog/transport engine that replaces the external stack dependency. Kept as an internal pub(crate) `stack` module inside the single `wavekat-sip` crate (no second crate), keeping `rsip` for message types and migrating flow-by-flow behind a patched-fork bridge. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01BBYdYGwVkEUhUfeAAUoA3p
First slice of the clean-room SIP engine planned in docs/08: the four RFC 3261 §17 transaction state machines (client/server INVITE and non-INVITE), written sans-IO so the timer-heavy core is unit-tested against the spec's timer tables with no sockets or wall-clock. Each machine maps an input event (received message, fired timer, or a TU response) to an ordered list of TxActions (send, arm/stop timer, deliver up, timed-out, terminated). Shared layer adds Timers (T1/T2/T4), Reliability, TransactionKey demux, branch generation, and the non-2xx ACK builder that reuses the INVITE's Via/branch/route set — structurally avoiding the re-INVITE ACK bug that motivated the rewrite. Entirely pub(crate): nothing appears in wavekat_sip::* signatures. 41 unit tests; fmt/clippy/test/doc all clean. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01SJyg8i9YjsgdEMp77PvRyq
Phase 2 of the clean-room stack (docs/10): the transport layer and the async runner that drives the sans-IO §17 machines over a real socket. - transport.rs: rsip-backed (de)serialization + a bound tokio UDP socket that drops malformed datagrams instead of stalling. - engine.rs: one lock-free task owning the socket, transaction table and timers. Demuxes inbound by TransactionKey, opens server transactions, applies TxActions (send / arm-stop timer / publish Event), and routes an unmatched ACK up as the 2xx-ACK (dialog) concern. Timers use a generation counter instead of cancellable handles, so StopTimer and re-arm are just increments. - Transaction dispatch enum + Reliability::from(Transport). 8 new loopback-UDP tests with shrunk timers (49 stack tests total); fmt/clippy/test/doc all clean. Still entirely pub(crate). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01SJyg8i9YjsgdEMp77PvRyq
Phase 3 of the clean-room stack (docs/11): src/stack/auth.rs. Reuses rsip's DigestGenerator for the HA1/HA2 math and owns only the orchestration: build_retry() reads a 401/407 challenge and produces the retried request — fresh Via branch, incremented CSeq, and the right Authorization / Proxy-Authorization header, with qop=auth cnonce/nc support. cnonce uses the same no-rand seeded-hash trick as branches. 7 unit tests including a DigestGenerator self-verify of the computed response (qop and no-qop). Known limitation documented: rsip spells the algorithm token "SHA256" not RFC 7616 "SHA-256"; MD5 is fully correct. Still entirely pub(crate). fmt/clippy/test/doc all clean (205 tests). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01SJyg8i9YjsgdEMp77PvRyq
Phase 4 of the clean-room stack (docs/12): src/stack/dialog.rs. Dialog::uac / Dialog::uas build the caller/callee dialog state; new_request produces in-dialog requests (BYE/re-INVITE/INFO) with incremented CSeq, the remote target as Request-URI, correct From/To tags, a fresh Via branch, and the captured route set replayed as Route headers. DialogId (Call-ID + tags) routes inbound in-dialog requests. The motivating route-set bug is fixed by construction: the route set is captured once at establishment (UAC reversed, UAS in order) and replayed on every in-dialog request, never recomputed from a Contact. A regression test guards it. 5 unit tests; still entirely pub(crate). fmt/clippy/test/doc clean (210 tests). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01SJyg8i9YjsgdEMp77PvRyq
Phase 5 of the clean-room stack (docs/13): src/stack/registration.rs. First composed flow — stitches the non-INVITE client transaction, the UDP engine, and the digest auth orchestration into a working REGISTER. build_register() composes the request; drive_register() sends it, answers one 401/407 via auth::build_retry, and reports a RegisterOutcome. Two of the three tests are full loopback round-trips against a fake registrar (real rsip-parsed SIP over real UDP): success after a digest challenge, and Unauthorized when credentials are always rejected. Still entirely pub(crate). fmt/clippy/test/doc clean (213 tests). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01SJyg8i9YjsgdEMp77PvRyq
Phase 6 of the clean-room stack (docs/14): src/stack/call.rs. Second composed flow — place_call() sends an INVITE through the client INVITE transaction, follows provisional responses, answers one 401/407, and on a 2xx builds the dialog and sends the 2xx ACK out-of-dialog (reusing the INVITE CSeq per RFC 3261 §13.2.2.4). hangup() tears the call down with an in-dialog BYE. build_invite() carries the SDP offer. Two of three tests are full loopback round-trips against a fake callee: a complete INVITE→180→200→ACK→BYE lifecycle, and a 486 rejection where the transaction ACKs the non-2xx itself. Both UA signaling flows (REGISTER + INVITE) now run end-to-end on the clean-room engine. Still entirely pub(crate). fmt/clippy/test/doc clean (216 tests). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01SJyg8i9YjsgdEMp77PvRyq
Phase 7 toward dropping rsipstack: src/stack/ua.rs. The composed flows each assumed they owned the engine's single event stream. Ua owns that stream and routes each Event to the flow that owns its transaction (a TransactionKey -> Sender table, with a subscribe-ack handshake that closes the race against a fast reply); unmatched inbound requests (new INVITEs, the 2xx ACK) go to an `incoming` stream for the callee/dialog layer. Ua::register / call / hangup / answer / next_incoming are now the single canonical drivers, so the standalone drive_register/place_call/hangup are removed (registration.rs/call.rs keep the request builders + types). Two loopback tests prove a register + a call share one engine, and an inbound INVITE reaches `incoming` and is answered on the wire. This is the layer the public wrappers will sit on. Still pub(crate); public API unchanged. fmt/clippy/test/doc clean (216 tests). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01SJyg8i9YjsgdEMp77PvRyq
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01SJyg8i9YjsgdEMp77PvRyq
Adds src/stack/response.rs: build_response() composes a UAS response that echoes the request's Via/From/Call-ID/CSeq, copies To (adding a local tag when absent), and optionally carries a Contact and body. Needed by the callee (accept/reject) and the endpoint's in-dialog auto-answer in the upcoming public-wrapper cutover. Additive, pub(crate), 2 tests. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01SJyg8i9YjsgdEMp77PvRyq
Re-points every public wrapper onto the clean-room stack and removes the rsipstack dependency entirely. rsip (SIP message types) is the only SIP crate left; rsipstack is gone from Cargo.toml and the dependency tree. Public API (redesigned, engine-backed): - SipEndpoint::new -> Arc<Self>; owns the Ua (engine + router) and a background task that routes inbound requests: a new INVITE becomes an IncomingCall, in-dialog requests are auto-answered, the 2xx ACK is absorbed. next_incoming_call() yields inbound calls. - Caller::dial -> Call (binds RTP, offers SDP, answers a digest challenge, parses the SDP answer). - IncomingCall::accept -> Call / reject(status). - Call::hangup() sends an in-dialog BYE. - Registrar over Ua::register with keepalive + diagnostics. - re_exports now expose only rsip types. Deferred to follow-ups on the new API (modules removed for now): session timers (RFC 4028), DTMF INFO send, hold/resume re-INVITE, the DialogState stream / cancel-while-ringing UX, and the User-Agent header. The SDP/RTP layers and resolve are unchanged. fmt/clippy/test/doc all clean; rsipstack not in the dependency tree. BREAKING CHANGE: public types and signatures changed; the rsipstack re-exports are removed. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01SJyg8i9YjsgdEMp77PvRyq
Adds tests/end_to_end_call.rs: drives the public API (SipEndpoint / Caller / IncomingCall / Call) over loopback on the in-house engine — one endpoint dials another, the callee accepts with an SDP answer, the caller confirms negotiated media (PCMU), then hangs up with a BYE the callee's router auto-answers. No external SIP server required. Docs: adds docs/16-drop-rsipstack.md (the cutover record, incl. deferred feature deltas) and indexes it; rewrites the living RFC-COVERAGE.md to match the engine-backed API (removes rsipstack framing and the dropped CANCEL / User-Agent / session-timer / INFO-DTMF rows; notes UDP-only transport and rport/TCP gaps). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01SJyg8i9YjsgdEMp77PvRyq
Drops an explicit intra-doc link target to the pub(crate) Ua type that rustdoc flagged as redundant, restoring a zero-warning cargo doc. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01SJyg8i9YjsgdEMp77PvRyq
Classify the features dropped by the rsipstack cutover into genuinely -missing engine capabilities (in scope: re-INVITE seam, User-Agent, DTMF INFO, hold/resume, session timers, inbound surfacing + CANCEL + provisional states) versus API-rename-only items (out of scope), and lay out a six-phase, one-commit-per-phase implementation grounded in the existing engine seams. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01SJyg8i9YjsgdEMp77PvRyq
Add the engine seam for in-dialog requests carrying a body and extra headers (Dialog::new_request_with, setting Content-Length from the body) and Ua::info, the non-INVITE in-dialog send used for DTMF. Restore dtmf_info as a pure module (build_info_body, content_type_header, InfoOutcome, classify) over the engine instead of rsipstack, and expose Call::send_dtmf_info for the application/dtmf-relay fallback used when the remote does not negotiate RFC 4733 telephone-event. Tested: dialog composer attaches body/headers/Content-Length; dtmf_info unit tests port unchanged; the loopback e2e call now also sends an INFO press and asserts the auto-answered 200 classifies as accepted. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01SJyg8i9YjsgdEMp77PvRyq
Add Ua::reinvite, the in-dialog re-INVITE path that ACKs the 2xx per RFC 3261 §13.2.2.4 (the existing send_in_dialog never ACKs, so it cannot carry an INVITE transaction). Generalize the SDP builder into build_sdp_with(direction, version): a MediaDirection (sendrecv / sendonly / inactive) plus an o= version that increments on every re-offer per RFC 3264 §5. build_sdp keeps its signature (sendrecv, version 0). Expose Call::set_hold(on) / Call::is_held(): set_hold sends a fresh SDP re-offer via re-INVITE, only flips local hold state on a 2xx, and surfaces the server's reason on a non-2xx final. Tested: SDP direction/version unit tests; a stack::ua loopback test asserts the engine ACKs the re-INVITE 2xx with an advanced CSeq; the loopback e2e call now holds and resumes. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01SJyg8i9YjsgdEMp77PvRyq
Carry an optional product token on the Ua and inject it as User-Agent at the single client-transaction chokepoint (start_client), so every outbound INVITE / REGISTER / BYE / re-INVITE / INFO advertises it; the header does not affect the transaction key. Restore the public entry point as SipEndpoint::new_with_app(account, product, cancel), with new() delegating with None (byte-identical when unset). Tested: the token rides an outbound request when configured, is not duplicated on re-application, and is absent by default. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01SJyg8i9YjsgdEMp77PvRyq
Restore the session_timer module (Session-Expires/Min-SE parsing, Supported/Require: timer, UAC/UAS negotiation, the §10 interval math and the refresh/watchdog session_timer_loop with its full paused-clock test suite). The pure logic is unchanged; only the dialog seam is re-pointed off rsipstack onto the engine via the SessionDialogOps trait. Wire negotiation into the call path: Caller::dial advertises Supported: timer + Session-Expires (CallConfig gains extra_headers on the INVITE) and negotiates from the 2xx; IncomingCall::accept negotiates from the INVITE and echoes the agreed Session-Expires (+ Require: timer) in its 200 OK. Call now exposes session_timer() and a cloneable session_handle() (CallSession: SessionDialogOps) so the loop can refresh / BYE on the shared dialog (Arc<Mutex<Dialog>>) alongside the audio path. Tested: the full session_timer unit suite; build_invite carries extra headers; the loopback e2e asserts both sides negotiate the 1800 s timer with the caller as refresher and the callee as watchdog. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01SJyg8i9YjsgdEMp77PvRyq
Move RFC 4028 (session timers) and RFC 6086 (SIP INFO DTMF) into the implemented sections, record §14 re-INVITE (hold/resume) and §20.41 User-Agent, and re-rank the remaining gaps so the docs/17 inbound surfacing + CANCEL + provisional work leads. Mark phases 1-5 done in the plan doc. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01SJyg8i9YjsgdEMp77PvRyq
By default the endpoint still auto-answers every in-dialog request 200 OK. A Call can now opt in via Call::inbound_requests() to handle its dialog's peer re-INVITEs (e.g. answer an RFC 4028 session refresh or a peer hold with a fresh SDP answer) and INFOs (e.g. SIP-INFO DTMF) itself: the endpoint keys a dialog registry and routes those requests to an InboundRequests stream of InboundRequest (respond/ok), reverting to auto-answer when the stream is dropped. BYE / OPTIONS stay auto-answered. This completes the RFC 4028 watchdog path (the peer's refresh re-INVITE is now reachable so the consumer can reset the deadline) and enables inbound SIP-INFO DTMF. Tested: a loopback e2e where the caller opts in and answers a callee -initiated INFO and hold re-INVITE — with opt-in the endpoint no longer auto-answers, so the peer's 200 proves the stream delivered both, and the test asserts the received methods. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01SJyg8i9YjsgdEMp77PvRyq
Add build_cancel (RFC 3261 §9.1: same top Via branch, Request-URI, From/To/Call-ID, CSeq number with method CANCEL) and an engine call_cancellable path that watches a CancellationToken: once a provisional has arrived, firing the token sends the CANCEL as its own non-INVITE transaction and the INVITE resolves on the 487 Request Terminated. Expose Caller::dial_cancellable(target, cancel); dial() delegates with a token that never fires, so its behavior is unchanged. Tested: build_cancel reuses the INVITE branch + CSeq number; a stack::ua loopback test rings, cancels, observes the engine send CANCEL, replies 487, and asserts the call resolves Rejected(487) with the engine ACKing the 487. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01SJyg8i9YjsgdEMp77PvRyq
Forward each provisional response status from the in-flight INVITE to an optional progress channel and expose Caller::dial_with_progress(target, cancel, progress), so a consumer can drive a "ringing" UI. dial / dial_cancellable pass no channel (unchanged). Tested: the cancel-while-ringing loopback now also asserts the 180 Ringing arrives on the progress channel before the CANCEL/487. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01SJyg8i9YjsgdEMp77PvRyq
Record CANCEL (§9), provisional observation, and opt-in inbound in-dialog surfacing as implemented; note the RFC 4028 watchdog path is now complete; drop the closed items from the known-gaps ranking. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01SJyg8i9YjsgdEMp77PvRyq
The in-house engine auto-answered in-dialog BYE and pre-answer CANCEL but never told the consumer, so a softphone couldn't tear a call down on a remote hangup, and a CANCELled inbound INVITE rang forever (no 487). - Call::terminated() returns a CancellationToken that fires on a peer BYE (still auto-answered 200 OK), registered per-Call at construction and released on Drop. - IncomingCall::cancelled() fires if the caller CANCELs before accept or reject; the endpoint now 200s the CANCEL and 487s the matching INVITE transaction (RFC 3261 section 9.2), correlated via the shared Via branch. - IncomingCall::caller() exposes the From header for caller-ID. Tested at the public surface: remote BYE -> terminated(); inbound CANCEL -> cancelled() + 487 to the INVITE. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01GKGMQeftv6ztjoq1zXCL7g
Pin the RFC 3261 §9.1/§9.2 CANCEL handling the prior commit added: - invite_target() unit test: a CANCEL's key folds onto the INVITE server transaction (branch + sent-by preserved, method -> Invite), and stays a distinct transaction otherwise. - cancel_after_accept_does_not_487_the_established_call: a CANCEL racing in after accept() is answered 200 but must not 487 the call or fire cancelled() — the unregister-on-decision contract. - stray_cancel_is_answered_200_without_487: a CANCEL with no matching INVITE is a 200-only no-op, never a 487. Factors the raw INVITE/CANCEL construction into raw_invite/raw_cancel helpers shared across the three CANCEL e2e tests. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01GKGMQeftv6ztjoq1zXCL7g
Calls placed through a NAT-fronting SIP proxy (e.g. the 2talk gateway) never tore down when the far end hung up: the peer's BYE never reached us, so the consumer stayed stuck on the call screen. Two RFC gaps, both about getting in-dialog requests routed back to a UA behind NAT: - RFC 3581 (rport): every outgoing request's Via now advertises a bare `;rport`, so the proxy stamps the packet's real source and routes replies and in-dialog requests there instead of to our private Contact. Centralized in `via_value()` so REGISTER, INVITE, and the in-dialog paths (BYE/re-INVITE/INFO) can't drift; CANCEL inherits it from the INVITE Via. - RFC 3261 §12.1.1 (Record-Route): the UAS 2xx now mirrors every Record-Route from the INVITE, verbatim and in order, so the peer's reversed route set (§12.1.2) sends its BYE back through the proxy rather than straight to our unroutable Contact. Tests: rport assertions on each Via path; `ok_echoes_record_route_in_order`; a proxy-topology e2e (`proxy_record_route_is_echoed_and_inbound_bye_terminates`) that fails without the echo and passes with it — the loopback teardown tests can't catch this, since with no proxy there is no Record-Route to drop and a directly-reachable Contact masks the empty route set. Also adds `callee_bye_fires_caller_terminated` for the outbound-call direction. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01GKGMQeftv6ztjoq1zXCL7g
Make call-teardown failures debuggable from the logs alone: - transport.rs emits every datagram at debug — `>>> SEND` / `<<< RECV` with the full message — so the Via/Record-Route/Contact routing can be read off the wire (filter with `RUST_LOG=wavekat_sip=debug`). - the inbound-BYE handler logs the matched dialog at info, and a BYE that matched no live dialog at warn (with the registered termination keys), so the "stuck on the call screen after the far end hangs up" failure is loud rather than silent and the key mismatch is visible. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01GKGMQeftv6ztjoq1zXCL7g
The README still described the crate as a wrapper over an external SIP stack and marked the outbound/inbound call wrappers and RTP send as unshipped. Bring it in line with what the crate actually exposes now: self-contained signaling engine, `Caller`/`IncomingCall`, hold/resume, DTMF, session timers, and the RTP send loop. Fixes the stale Quick Start (`SipEndpoint::new` returns the endpoint directly), refreshes the status table and architecture sketch, and adds outbound/inbound call snippets mirrored from the compile-tested crate docs. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01GKGMQeftv6ztjoq1zXCL7g
This was referenced Jun 28, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Consolidates the in-house SIP engine work — previously the stacked PRs
#41 → #42 → #43 — into one change against
main.Breaking (
feat!): the public wrappers now run on a from-scratch SIPtransaction / dialog / transport engine instead of an external stack. The
re_exportssurface narrows torsipmessage types; engine internals are nolonger exposed. release-plz cuts this as 0.1.0 (a breaking change under 1.0
bumps the minor), keeping us in the
0.xseries.What's in it
transport, and REGISTER / INVITE / BYE / CANCEL / OPTIONS routing, all
crate-owned.
Caller::dial+ theCallhandle; inboundIncomingCallaccept / reject; in-dialog hold/resume (RFC 3264); DTMF(RFC 4733 + INFO fallback); RFC 4028 session timers.
the engine;
rport(RFC 3581) on every outgoing Via and a Record-Route echoin 2xx responses (RFC 3261 §12.1.1), so calls placed through a NAT-fronting
proxy tear down on their own when the far end hangs up.
stackdesign notes(
docs/08–docs/17) land with it.Supersedes / closes
in; this PR carries their commits.
Tests
Full unit + end-to-end suite green — including the proxy-topology teardown test
(
proxy_record_route_is_echoed_and_inbound_bye_terminates), therportassertions, and the Record-Route echo.
cargo fmt+clippyclean.