Skip to content

feat: add RFC 3264 call hold / resume - #39

Closed
wavekat-eason wants to merge 4 commits into
mainfrom
feat/call-hold
Closed

feat: add RFC 3264 call hold / resume#39
wavekat-eason wants to merge 4 commits into
mainfrom
feat/call-hold

Conversation

@wavekat-eason

@wavekat-eason wavekat-eason commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Adds a first-class signaled call-hold primitive so consumers can put a confirmed call on hold (and resume it) via an RFC 3264 re-INVITE, instead of hand-rolling SDP re-INVITEs or faking hold with a local audio gate the far end never learns about. Per the crate's placement rules, SIP signaling belongs here.

What's added

  • sdp::MediaDirectionSendRecv / SendOnly / RecvOnly / Inactive, with attr() (wire token), parse(), and responding() (the direction to answer a peer's offer with). Defaults to SendRecv.
  • sdp::build_sdp_with_direction() — the existing build_sdp body with an explicit direction line and an o= session version; build_sdp delegates to it (SendRecv, version 0), so its output is unchanged.
  • sdp::RemoteMedia::direction — the parsed direction of a remote SDP (default SendRecv when absent), so a consumer can also detect a peer-initiated hold.
  • hold::reoffer_media() — sends the directional re-INVITE over any SessionDialogOps dialog (reusing the session-refresh seam) and returns the re-parsed answer.
  • hold::HoldHandle — a cheap, cloneable handle (both inner dialog types are Clone) so a consumer can snapshot it, release whatever lock guards its call table, and only then await the re-INVITE round-trip. It carries a call-scoped Arc<AtomicU64> o= version counter so successive hold/resume re-offers advance one monotonic series.
  • AcceptedCall::set_hold / hold_handle, AcceptedDial::set_hold / hold_handle — convenience wrappers. They own the version counter (seeded to 0) and clone it into every handle.

The re-offer keeps the same connection address, port, and codecs — only the a= direction moves — so the dialog and any session timer survive the transition.

RFC 3264 §8 o= version (hold→resume interop)

The o= origin line keeps a fixed session id but its version must increment on every re-offer that changes the session. An early revision hardcoded o=wavekat 0 0, so hold and resume both carried version 0 — and a carrier SBC (2talk) accepted the hold but rejected the resume with 500 Server Internal Error, then BYE'd the call. The version is now threaded through build_sdp_with_direction and sourced from the per-call counter: initial offer/answer 0, first re-offer 1, next 2, … Because hold_handle() mints a fresh handle per request, the counter lives on AcceptedCall/AcceptedDial and is cloned in — a per-handle counter would reset and reintroduce the bug.

Out of scope (consumer's job)

What plays on the RTP stream while held (silence, music-on-hold) is audio, not signaling. Answering a peer-initiated hold re-INVITE is left to the consumer too — this crate supplies RemoteMedia::direction + MediaDirection::responding() but does not auto-answer.

Tests

sdp.rs covers the direction round-trip (all four directions), the absent-attribute default, responding(), and that the o= version tracks the fed session_version. hold.rs unit-tests reoffer_media against a scripted SessionDialogOps mock: asserts the offer's direction/port and o= version, parses the answer, maps a bare 2xx to Ok(None), errors on a non-2xx / unconfirmed dialog, and checks the version counter yields a strictly increasing 1, 2, 3, … series. No live SIP server needed. cargo fmt/clippy/test/doc all clean.

Design note: docs/08-call-hold.md; coverage updated in docs/RFC-COVERAGE.md.

🤖 Generated with Claude Code

wavekat-eason and others added 4 commits June 27, 2026 15:51
Add a first-class signaled hold primitive so consumers don't hand-roll
SDP re-INVITEs or fake hold with a local audio gate the far end never
learns about.

- sdp: MediaDirection (sendrecv/sendonly/recvonly/inactive) with attr/
  parse/responding; build_sdp_with_direction; RemoteMedia::direction
  parsed from the answer (default sendrecv).
- hold: reoffer_media() sends a directional re-INVITE over any
  SessionDialogOps dialog and returns the parsed answer; AcceptedCall/
  AcceptedDial::set_hold() convenience wrappers.

What plays on RTP while held (silence, music-on-hold) and answering a
peer-initiated hold stay with the consumer. Unit-tested against a
scripted dialog mock; docs/08 + RFC-COVERAGE updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CsYLx8QaSspoApH5A8s9KL
Hold/resume re-INVITEs reused a hardcoded `o=wavekat 0 0` origin line, so
every re-offer carried session version 0. RFC 3264 §8 requires the
version to increment on each re-offer that changes the session. Carrier
SBCs accept the first re-offer (hold) but reject the second (resume) with
500 Server Internal Error, then BYE the call.

Thread a session version into build_sdp_with_direction and give
HoldHandle a call-scoped Arc<AtomicU64> counter (seeded to 0 to match the
initial offer/answer) so successive holds/resumes advance one monotonic
series — hold=1, resume=2, … The Arc lives on AcceptedCall/AcceptedDial
and is cloned into every handle, since hold_handle() mints a fresh handle
per request and a per-handle counter would reset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKGMQeftv6ztjoq1zXCL7g
A refused hold/resume re-INVITE logged only the bare status code
("rejected: 500 ServerInternalError"), which says nothing about why the
server refused. Pull the Warning (RFC 3261 §20.43) and Reason (RFC 3326)
headers out of the non-2xx response and append them, so a rejection logs
something a person can act on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKGMQeftv6ztjoq1zXCL7g
local_rtp_addr was the RTP socket's bind address (0.0.0.0:port), and the
hold/resume re-offer built its SDP connection line from it — so every
re-INVITE carried c=IN IP4 0.0.0.0. That is the legacy RFC 2543 hold
signal: the peer (2talk) could not send media to 0.0.0.0 and answered
recvonly even to a sendrecv resume, leaving the call with broken/one-way
audio after resume. The initial answer/offer already used the real local
IP; re-offers now match it.

Store local_rtp_addr with the real local IP (the address actually
advertised), not the wildcard bind. Adds a regression assertion in
caller_dial that the advertised RTP address is the endpoint's local IP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKGMQeftv6ztjoq1zXCL7g
@wavekat-eason

Copy link
Copy Markdown
Contributor Author

Superseded by #43 — hold/resume is reimplemented on the in-house engine there (Call::set_hold / is_held). Closing in favor of the consolidated 0.1.0 PR.

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