Skip to content

feat!: drop rsipstack, run on in-house engine - #41

Closed
wavekat-eason wants to merge 13 commits into
mainfrom
feat/drop-rsipstack
Closed

feat!: drop rsipstack, run on in-house engine#41
wavekat-eason wants to merge 13 commits into
mainfrom
feat/drop-rsipstack

Conversation

@wavekat-eason

Copy link
Copy Markdown
Contributor

Summary

Replaces the external rsipstack dependency with a from-scratch, in-house SIP engine (built across docs/08docs/15) and re-points the entire public API onto it. rsipstack is removed from Cargo.toml and no longer appears in the dependency tree; rsip (SIP message types) is the only SIP crate left.

This is the final phase (8) of the own-SIP-stack plan. See docs/16-drop-rsipstack.md for the full record.

New public API (engine-backed)

  • SipEndpoint::new → Arc<Self> — binds transport, starts the engine, resolves the next-hop server (RFC 3263 subset), and runs an inbound router: a fresh INVITE becomes an IncomingCall; in-dialog BYE/OPTIONS/INFO/re-INVITE are auto-answered 200 OK; the 2xx ACK is absorbed.
  • Caller::dial → Call — binds RTP, offers G.711 SDP, answers one digest challenge, parses the SDP answer.
  • IncomingCall::accept → Call / reject(status).
  • Call::hangup — in-dialog BYE.
  • Registrar over Ua::register with keepalive + preserved RegistrarDiagnostics.
  • re_exports now exposes only rsip types.

Tests

  • New tests/end_to_end_call.rs drives the public API over loopback on the in-house engine (dial → accept-with-SDP → confirm media → hangup), no external SIP server required.
  • The engine's own stack::ua loopback tests cover REGISTER-with-digest and the full INVITE/200/ACK/BYE flow.
  • Old rsipstack-based integration tests removed.

Gates

cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace, and cargo doc --no-deps -p wavekat-sip --all-features all pass with zero warnings. rsipstack is absent from Cargo.lock.

⚠️ BREAKING CHANGE

Public types and signatures changed and the rsipstack re-exports are removed. Consumers using Caller::dial/PendingDial/Callee/DialogState must move to the new Call/IncomingCall shapes.

Deferred (modules removed; reintroducible on the engine without further breaking changes)

  • Session timers (RFC 4028)
  • DTMF over SIP INFO
  • Hold / resume re-INVITE
  • DialogState streaming / cancel-while-ringing UX
  • User-Agent header
  • TCP transport (Transport::Tcp is currently inert; engine is UDP-only)

SDP, RTP, DTMF-over-RTP, and SRV resolution are unchanged.

🤖 Generated with Claude Code

wavekat-eason and others added 13 commits June 27, 2026 19:50
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJyg8i9YjsgdEMp77PvRyq
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJyg8i9YjsgdEMp77PvRyq
@wavekat-eason

Copy link
Copy Markdown
Contributor Author

Folded into #43, which now targets main directly and carries these commits. Consolidating the stack into one PR for the 0.1.0 release.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant