Skip to content

feat: reinstate deferred call features on the engine - #42

Closed
wavekat-eason wants to merge 10 commits into
feat/drop-rsipstackfrom
feat/reinstate-deferred-features
Closed

feat: reinstate deferred call features on the engine#42
wavekat-eason wants to merge 10 commits into
feat/drop-rsipstackfrom
feat/reinstate-deferred-features

Conversation

@wavekat-eason

Copy link
Copy Markdown
Contributor

Reinstates the call features the rsipstack cutover (#41) deferred, rebuilt on the in-house engine. Stacked on feat/drop-rsipstack (#41) — review/merge that first; this PR's diff is just the reinstatement work (10 commits).

Implements docs/17, which first classifies every deferred item as an in-scope missing capability vs an out-of-scope API-rename only (the latter — Callee/PendingCallIncomingCall, PendingDialdial()->Call, the old DialogState/TransactionHandle names — are deliberately left for the consumer to adapt to).

Capabilities restored

Area Public API
DTMF over SIP INFO Call::send_dtmf_info, dtmf_info::{build_info_body, content_type_header, InfoOutcome}
Hold / resume re-INVITE (RFC 3264, ACKs the 2xx) Call::set_hold/is_held, build_sdp_with, MediaDirection
User-Agent header SipEndpoint::new_with_app
Session timers (RFC 4028) — both refresher & watchdog Call::session_timer/session_handle, session_timer_loop, negotiate_uac/negotiate_uas, …
Inbound in-dialog surfacing (opt-in; auto-answer stays the default) Call::inbound_requestsInboundRequests/InboundRequest
CANCEL a ringing INVITE (RFC 3261 §9) Caller::dial_cancellable
Provisional (180 Ringing) observation Caller::dial_with_progress

Foundational engine seams added: Ua::reinvite (ACKs the 2xx per §13.2.2.4), Ua::info, Dialog::new_request_with, build_cancel, a per-endpoint dialog registry.

Notes

  • Inbound surfacing is opt-in via Call::inbound_requests(), so the auto-answer-200 default is preserved and nothing changes for callers that don't opt in. This also completes the RFC 4028 watchdog path (the peer's refresh re-INVITE is now reachable).
  • No new dependencies; rsipstack stays absent.

Tests & gates

201 unit tests + 2 loopback e2e + doctests. Every commit is green on fmt, clippy -D warnings, test, and doc. The public e2e exercises INFO, hold/resume, both-sides timer negotiation, opt-in surfacing, and cancel-while-ringing.

🤖 Generated with Claude Code

wavekat-eason and others added 10 commits June 28, 2026 10:06
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <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