Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
97abeb4
fix: keep confirmed dialogs confirmed on 1xx to in-dialog requests
tgeorge06 Sep 24, 2026
aa5e0e1
fix: notify dialog state only for transitions that are applied
tgeorge06 Sep 24, 2026
5ef8ea6
fix(dialog): re-surface Confirmed after an in-dialog REFER is answere…
shenjinti Oct 7, 2026
7d9060d
fix(transaction): keep a server INVITE's final response after the ACK…
tgeorge06 Oct 7, 2026
a8d9aa1
fix(dialog): get_client_dialog_by_call_id returns only UAC dialogs
tgeorge06 Oct 7, 2026
964ca66
fix: keep whole SIP messages out of WARN logs
tgeorge06 Oct 7, 2026
e640905
fix(dialog): a BYE ends the dialog even when its transaction fails
tgeorge06 Oct 7, 2026
faab351
feat(tls): injectable TLS client seam — rustls stays the host default
shenjinti Oct 7, 2026
5572380
Merge pull request #147 from tgeorge06/fix/no-confirmed-to-early-regr…
shenjinti Oct 7, 2026
f91d78c
Merge pull request #148 from tgeorge06/fix/no-notification-after-term…
shenjinti Oct 7, 2026
f3494e3
Merge pull request #174 from tgeorge06/fix/confirmed-carries-the-fina…
shenjinti Oct 7, 2026
cdef8c3
Merge pull request #176 from tgeorge06/fix/dialog-lookups-respect-role
shenjinti Oct 7, 2026
e3afed3
Merge pull request #178 from tgeorge06/fix/warn-logs-without-sip-message
shenjinti Oct 7, 2026
3bdd74c
Merge pull request #180 from tgeorge06/fix/bye-ends-the-dialog-whatev…
shenjinti Oct 7, 2026
d89c32c
fix(dialog): BYE a forked 2xx's dialog (RFC 3261 §13.2.2.4)
shenjinti Oct 7, 2026
a506185
Merge pull request #181 from restsend/fix/forked-2xx-bye
shenjinti Oct 7, 2026
da79551
fix(dialog): end a dropped INVITE whose dialog was already removed
tgeorge06 Oct 7, 2026
e524cc3
fix(dialog): close two review nits in the recent state/notification f…
shenjinti Oct 7, 2026
c051af9
Merge pull request #183 from tgeorge06/fix/dropped-invite-guard-after…
shenjinti Oct 7, 2026
92cec74
test(dialog): pin the removed-dialog guard in the remaining CANCEL races
shenjinti Oct 7, 2026
ea33e20
fix(dialog): end the session on a never-ACKed re-INVITE 2xx in Client…
tgeorge06 Oct 7, 2026
c678342
fix(dialog): a server in-dialog request with no route and no dial-bac…
tgeorge06 Oct 7, 2026
3bf17f9
fix(transaction): Timer F ends a non-INVITE client transaction in Pro…
tgeorge06 Oct 7, 2026
7978f2c
Merge pull request #185 from tgeorge06/fix/legacy-client-dialog-unack…
shenjinti Oct 8, 2026
b45bb53
Merge pull request #187 from tgeorge06/fix/in-dialog-request-without-…
shenjinti Oct 8, 2026
8189ef4
Merge pull request #189 from tgeorge06/fix/timer-f-in-proceeding
shenjinti Oct 8, 2026
2dfa0d1
chore: bump version to 0.7.3
shenjinti Oct 8, 2026
4ac98fc
Merge upstream 0.7.3 (2dfa0d1)
tgeorge06 Oct 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "rsipstack"
version = "0.7.1"
version = "0.7.3"
edition = "2021"
description = "SIP Stack Rust library for building SIP applications"
license = "MIT"
Expand Down
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,44 @@ A RFC 3261/3262 compliant SIP stack written in Rust. The goal of this project is
## Features

- **RFC 3261/3262 Compliant**: Full compliance with SIP specification
- **Multiple Transport Support**: UDP, TCP, TLS, WebSocket (TLS/WebSocket require the `rustls` and `websocket` features, enabled by default)
- **Multiple Transport Support**: UDP, TCP, TLS, WebSocket (TLS/WebSocket require the `rustls` and `websocket` features, enabled by default). The TLS client goes through a backend seam (`platform::tls`) — host default is the built-in rustls; embedded stacks can register their own connector
- **Transaction Layer**: Complete SIP transaction state machine
- **Dialog Layer**: SIP dialog management
- **Reliable Provisionals**: PRACK (RFC 3262 / 100rel) support
- **Digest Authentication**: Built-in authentication support
- **High Performance**: Built with Rust for maximum performance
- **Easy to Use**: Simple and intuitive API design

## no_std / Embedded Support

rsipstack compiles without `std` (`alloc`-only) for embassy-based targets —
verified on `xtensa-esp32s3-none-elf` (ESP32-S3):

```bash
cargo check --no-default-features --features platform-embassy
```

- Core SIP codec, transaction, dialog, and **UDP transport** layers are
`no_std` + `alloc`.
- The `platform-embassy` backend maps the runtime seams onto
`embassy-time` / `embassy-sync` (critical-section based); task spawning is
injected once at startup:

```rust,ignore
rsipstack::platform::set_spawn_fn(|fut| spawner.spawn(fut).ok());
```

- Concurrent waits use the backend-agnostic `select2` / `select3` helpers
instead of `tokio::select!`.
- **SIPS (SIP over TLS, client role)** goes through the
`platform::tls::TlsConnector` seam: register a backend with
`platform::tls::set_client_connector` and every
`TlsConnection::connect` rides it. Host builds without a registered
connector keep using the built-in rustls path.
- TCP / WebSocket transports still require `platform-tokio`; on embedded,
use UDP or SIPS today.
- On no_std, register a `DomainResolver` instead of the tokio DNS resolver.

## TODO
- [x] Transport support
- [x] UDP
Expand All @@ -25,6 +55,7 @@ A RFC 3261/3262 compliant SIP stack written in Rust. The goal of this project is
- [x] Transaction Layer
- [x] Dialog Layer
- [ ] WASM target
- [x] no_std embassy-based targets

## Use Cases

Expand Down
111 changes: 42 additions & 69 deletions RECURSIVECX.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,40 +6,32 @@ below is either offered upstream or removed once rcx stops depending on it.
The row ids (R2, R3, ...) are those of the convergence ledger
(`rsipstack-contrib/convergence-ledger.md`).

Base: upstream **0.7.1** (3286e8c). Everything the fork used to carry that
0.7.1 contains (R1, R4-R11, R13, the R12 teardown) is upstream's version now.
`ReinviteAck` / `take_reinvite_ack()` (R14) is gone: rcx reads
`InviteDialog::last_remote_ack()` and matches its CSeq.
Base: upstream **0.7.3** (2dfa0d1). Everything the fork used to carry that
0.7.3 contains is upstream's version now: R1, R4-R11, R13, the R12 teardown
(0.7.1), and R2, R3, R21, R22b, R23 (client lookup), the "BYE ends the dialog
whatever the outcome" part of R24, and R27 (0.7.3). `ReinviteAck` /
`take_reinvite_ack()` (R14) is gone: rcx reads
`InviteDialog::last_remote_ack()` and matches its CSeq. The R23 server-side
role check on `get_or_create_server_invite` was dropped: rcx routes
in-dialog requests through `match_dialog` / `get_dialog`.

The fork has one role-agnostic `InviteDialog` (`Dialog::Invite`), as upstream.
The deprecated `ClientInviteDialog` / `ServerInviteDialog` wrappers carry the
same patches where they apply.

## Patches

### R2 + R19: a 1xx to an in-dialog request is not `Early`
### R19: a 1xx to an in-dialog request is not notified as `Early`

- **Where:** `DialogInner::send_dialog_request`, `Provisional` arm.
- **What:** `Early` is applied and notified only while the dialog can still be
cancelled (Calling / Trying / Early). A 1xx to a re-INVITE or UPDATE on a
confirmed dialog neither regresses it (R2, upstream PR #147) nor notifies
`Early` (R19), which rcx subscribers read as ringing.
cancelled (Calling / Trying / Early). Upstream (#147, R2) no longer
regresses a confirmed dialog but still notifies the 1xx as `Early`; the
fork does not notify it, since rcx subscribers read `Early` as ringing.
- **rcx:** the callee-state handlers that match `Early`; the lease-expiry
fence.
- **Test:** `dialog::tests::test_in_dialog_provisional`.

### R3: one notification per applied dialog transition

- **Where:** `DialogInner::transition`.
- **What:** the transition is decided and notified under the state lock.
After `Terminated`, nothing more is applied or notified, a second
`Terminated` included; a `WaitAck` ignored after `Confirmed` is not
notified. Event-only states (`Updated`, `Notify`, `Info`, `Options`,
`Refer`) are notified whatever the lifecycle state. Upstream PR #148.
- **rcx:** the callee-state `Terminated` arm (`sip_session/dialog_events.rs`).
- **Tests:** `dialog::tests::test_state_after_terminated`, the notification
tests at the end of `dialog::tests::test_dialog_states`.

### R15: response provenance (`Response::synthetic`, `Response::received_from`)

- **Where:** `sip::message::{Response, ReceivedFrom}`. `synthetic` is `true` on
Expand All @@ -50,7 +42,7 @@ same patches where they apply.
serialized; `None` / `false` after a reparse.
- **rcx:** `callrecord/carrier_response.rs`, `callrecord/diagnostics.rs`,
`rcx-call/src/sip.rs`. Struct literals must set
`synthetic: false, received_from: None`.
`synthetic: false, received_from: None` (and `wire_reason: None`, R28).
- **Tests:** `transaction::tests::test_response_provenance`,
`test_stream_reconnect::test_send_failure_on_stream_is_reported_at_once`.

Expand Down Expand Up @@ -100,43 +92,21 @@ same patches where they apply.
`crates/rcx-call/src/sip.rs`.
- **Test:** `dialog_layer::test_take_dialog_hands_the_dialog_to_exactly_one_caller`.

### R21: a 2xx crossing a taken dialog's CANCEL is BYE'd

- **Where:** `DialogGuardForUnconfirmed` (`src/dialog/invitation.rs`): the
`dialog` / `finished` fields and `watch_taken_dialog`.
- **What:** when another owner took the dialog out of the layer
(`take_dialog`) and the `do_invite` future is dropped, the guard keeps the
INVITE transaction: in Trying / Early it watches for a 2xx (up to 64*T1)
and BYEs it, without a second CANCEL; still in Calling (the owner's hangup
could send no CANCEL), it abandons the INVITE as upstream does for a dialog
still in the layer.
- **rcx:** RWI originate `Hangup` arm, `rwi_originate_trunk_e2e_test`.
- **Tests:** `test_cancel_2xx_race::test_taken_dialog_*`.

### R22a + R22b: raw SIP messages at DEBUG only
### R22a: raw SIP messages at DEBUG only

- **What:** UDP, stream and WebSocket raw send/receive logs are DEBUG
(upstream: INFO). The WebSocket parse failure and the dialog layer's
"failed to send request" WARNs carry only the length or the method; the
message itself is logged at DEBUG.
- **Test:** `transport::tests::test_raw_message_log_level` (`bench` feature).

### R23: role-typed dialog lookups

- **What:** `DialogLayer::get_client_dialog_by_call_id` returns UAC dialogs
only, and `get_or_create_server_invite` matches existing UAS dialogs only.
With a transparent Call-ID the inbound (UAS) and outbound (UAC) legs of a
proxied call share it.
- **rcx:** `rwi/processor/originate.rs` (Hangup, media timeout, transfer, DTMF).
- **Test:** `dialog_layer::test_lookups_keep_uac_and_uas_dialogs_apart`.

### R24: BYE lifecycle per role

- **Where:** `DialogInner::send_bye`, used by `InviteDialog` and both wrappers.
- **What:** a UAS notifies `Terminated(UasBye)` before sending the BYE and
returns the send result; a UAC notifies `Terminated(UacBye)` after the BYE
transaction whatever its outcome. Upstream terminates only after an `Ok`
BYE.
(upstream: INFO). The WARNs without the message (R22b) are upstream's.
- **Tests:** `transport::tests::test_raw_message_log_level` (`bench` feature);
upstream's `test_warn_logs` checks the WebSocket receive log at DEBUG.

### R24: a UAS notifies `Terminated` before sending its BYE

- **Where:** `DialogInner::send_bye`.
- **What:** a UAS notifies `Terminated(UasBye)` before the BYE is sent, so
subscribers never wait for the BYE's response, and returns the send
result. Upstream (0.7.3) notifies after the BYE transaction for both roles;
the UAC side is upstream's (`Terminated(UacBye)` whatever the outcome, the
error still returned).
- **rcx:** the `Terminated` arms in `sip_session/dialog_events.rs`.
- **Test:** `dialog::tests::test_bye_lifecycle`.

Expand All @@ -146,21 +116,16 @@ same patches where they apply.
`@`, then `callid_suffix`. rcx sets it in `proxy/server.rs`.
- **Test:** `transaction::tests::tests::test_make_call_id_random22`.

### R27: `Confirmed` carries the 2xx its ACK confirms
### R28: `Response::wire_reason`

- **Where:** `Transaction::cleanup`: a server INVITE transaction keeps
`last_response` (it still hands a copy to `finished_transactions`).
- **What:** since 0.7.1 the matching ACK ends an `Accepted` server INVITE
transaction (RFC 6026 §7.1, upstream #169) before the dialog reads
`tx.last_response` for `DialogState::Confirmed`. Upstream then notifies
`Confirmed` with `Response::default()` (no CSeq, no headers), for the
initial INVITE and every re-INVITE. rcx correlates `Confirmed` by the
response's CSeq (`confirms_initial_invite`, `is_reconfirmation`, the
re-offer settle).
- **Test:** `dialog::tests::test_late_reinvite_ack` (CSeq of each
`Confirmed`).
- **Where:** `sip::message::Response`, `sip::parser`.
- **What:** a known status code whose Status-Line carries a phrase other than
the standard one keeps it in `wire_reason` (e.g. `403 Caller Origination
Number is Invalid`). Set only by the parser; `Display` is unchanged.
- **rcx:** `crates/rcx-callrecord/src/carrier_response.rs` (rcx #1135).
- **Test:** `sip::parser` tests for the custom phrase.

## Upstream 0.7.1 behavior rcx observes (not fork patches)
## Upstream behavior rcx observes (not fork patches)

- RFC 6026 `Accepted` state. Server: the 2xx is retransmitted by Timer G
(T1 doubling to T2, every transport) until the matching ACK, which ends the
Expand All @@ -172,3 +137,11 @@ same patches where they apply.
- A dropped INVITE sends no CANCEL before a provisional response; it CANCELs
on the first provisional, or ACKs and BYEs a 2xx (#162, #171).
- A forked 2xx is ACKed with its own To tag and remote target (#172).
- 0.7.3: a BYE ends the dialog whatever its transaction returns (#180); a
forked 2xx's dialog is BYE'd (#181); an in-dialog REFER returns the dialog
to `Confirmed` once answered; Timer F ends a non-INVITE client
transaction in Proceeding (#189); a server in-dialog request with no route
and no dial-back fails at once (#187); the deprecated `ClientInviteDialog`
ends the session on a never-ACKed re-INVITE 2xx (#185); a dropped INVITE
whose dialog was already removed still ends (#183); injectable TLS client
seam (rustls stays the default).
25 changes: 21 additions & 4 deletions src/dialog/client_dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,16 +157,19 @@ impl ClientInviteDialog {
/// # Returns
/// * `Ok(())` - BYE was sent successfully or dialog is already terminated.
/// * `Err(Error)` - Failed to build/send BYE request, or dialog is in a state where BYE does not apply.
///
/// Once the BYE is handed to its transaction the dialog is `Terminated`,
/// even when an error is returned (RFC 3261 §15.1.1).
pub async fn bye_with_headers(&self, headers: Option<Vec<crate::sip::Header>>) -> Result<()> {
if !self.inner.is_confirmed() {
if !self.inner.is_terminated() {
warn!(
dialog_id = %self.id(),
state = ?self.state(),
state = %self.state(),
"bye skipped: dialog not confirmed"
);
return Err(crate::Error::Error(format!(
"dialog {} cannot send BYE in state {:?}",
"dialog {} cannot send BYE in state {}",
self.id(),
self.state()
)));
Expand All @@ -178,7 +181,7 @@ impl ClientInviteDialog {
self.inner
.make_request(crate::sip::Method::Bye, None, None, None, headers, None)?;

self.inner.send_bye(request).await
self.inner.send_bye(request, TerminatedReason::UacBye).await
}

/// Send a BYE request with a SIP `Reason` header.
Expand Down Expand Up @@ -675,18 +678,27 @@ impl ClientInviteDialog {
.transition(DialogState::Updated(self.id(), tx.original.clone(), handle))?;

self.inner.process_transaction_handle(tx, rx).await?;
let answered_2xx = tx
.last_response
.as_ref()
.is_some_and(|resp| resp.status_code.kind() == crate::sip::StatusCodeKind::Successful);
let mut acked = false;

// wait for ACK
while let Some(msg) = tx.receive().await {
match msg {
SipMessage::Request(req) if req.method == crate::sip::Method::Ack => {
debug!(id = %self.id(), "received ACK for re-INVITE");
self.inner.remote_ack.lock().replace(req);
acked = true;
break;
}
_ => {}
}
}
self.inner
.end_session_without_ack(tx, answered_2xx && !acked)
.await;
Ok(())
}

Expand All @@ -696,7 +708,12 @@ impl ClientInviteDialog {
self.inner
.transition(DialogState::Refer(self.id(), tx.original.clone(), handle))?;

self.inner.process_transaction_handle(tx, rx).await
// RFC 3515: the REFER was answered (usually 202) and the dialog must
// go back to Confirmed — the implicit subscription's NOTIFYs are
// in-dialog requests that need the confirmed dialog.
let result = self.inner.process_transaction_handle(tx, rx).await;
let confirmed = self.return_to_confirmed(tx);
result.and(confirmed)
}

async fn handle_message(&mut self, tx: &mut Transaction) -> Result<()> {
Expand Down
Loading