You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Hold re-INVITE (a=sendonly) is answered a=sendrecv, violating RFC 3264 §6.1 — carrier BYEs the dialog
Summary
When a carrier places a call on hold it sends a re-INVITE offering a=sendonly. Both re-INVITE
paths in pkg/sip/inbound.go answer by replaying the cached local SDP verbatim, which still
carries a=sendrecv. RFC 3264 §6.1 requires a sendonly offer to be answered recvonly, so the
answer is invalid and the carrier tears the dialog down ~60 ms later.
On a bridged call (two SIP legs in one room, e.g. after a cold transfer) the BYE collapses the
whole call: dropping the held leg drops the other party too. The user-visible symptom is pressing HOLD hangs up the call.
Reproduced on v1.8.0 and v1.9.0 against two different carrier SBCs.
Reproduction
Establish a call with two SIP participants bridged in one room (inbound caller + outbound leg).
From the outbound party's phone, press hold.
The carrier sends a re-INVITE with a=sendonly; livekit-sip answers a=sendrecv; the carrier
sends BYE and the call ends.
Packet capture
tcpdump -ni any udp port 5060 on the SIP pod. Phone numbers, IPs and call IDs are redacted;
SDP bodies and timings are verbatim.
06:19:05.464 IN INVITE sip:203.0.113.10:5060 SIP/2.0 <- carrier: HOLD
06:19:05.465 OUT SIP/2.0 200 OK <- our answer
06:19:05.521 IN ACK
06:19:05.527 IN BYE <- carrier gives up (63 ms)
06:19:05.527 OUT SIP/2.0 200 OK
06:19:05.552 OUT BYE -> other leg <- bridged call collapses
AcceptAsKeepAlive (inbound.go:2000) responds 200 OK with that body unmodified. The cached
SDP is the previously negotiated sendrecv answer, so the direction attribute is never adjusted
to the new offer.
Consistent with that, the repository contains no occurrences of sendonly, recvonly, sendrecv or inactive in non-test Go source — RFC 3264 direction handling appears simply not to
be implemented for re-INVITEs.
Expected behaviour (RFC 3264 §6.1)
Offer
Correct answer
a=sendonly
a=recvonly
a=recvonly
a=sendonly
a=inactive
a=inactive
a=sendrecv / absent
a=sendrecv (implied, RFC 4566 §6)
Patch
We have a working fix running in production, plus tests. It adds two helpers and uses them in both
re-INVITE paths:
answerDirectionFor(offer []byte) string — maps the offer's direction to the RFC-correct answer,
returning "" when the offer is sendrecv or states no direction.
withSDPDirection(local []byte, dir string) []byte — replaces the single a= direction line in
the cached SDP (appending one if absent), preserving CRLF.
Two deliberate choices, both open to discussion:
Text-level rewrite rather than parse/re-serialize. The cached SDP is an already-negotiated,
working body; reserializing risks perturbing codec/ptime/connection lines the carrier has
accepted. Only the direction attribute changes. If you'd rather this lived in the SDP layer
(media-sdk) where parsing already happens, that's very likely the better long-term home and
we're happy to redo it there.
No-op when the offer has no direction, so every non-hold re-INVITE (codec renegotiation,
port change per sip: redirect RTP destination on re-INVITE port change #728, session-timer refresh) behaves exactly as today. This is the common case
and we didn't want to change it.
Test coverage added (pkg/sip/sdp_hold_direction_test.go): the captured carrier SDP above, the
full direction mapping, the untouched-when-no-direction case, unhold, appending a missing direction
line, and collapsing duplicate direction lines. The existing pkg/sip suite passes unchanged.
Not verified: we've confirmed the SDP answer is now correct and that hold no longer ends the
call, but we have not characterised media behaviour across repeated hold/unhold cycles (whether
RTP flow is torn down and re-established as an implementation might expect). Worth a maintainer's
eye.
Happy to open a PR — wanted to check the preferred layer first.
Environment
livekit-sip v1.8.0 and v1.9.0 (both reproduce; digests ad8dafcb… and a906ae85…)
Self-hosted on Kubernetes, hostNetwork: true, single replica
Two carriers reproduce it; the offering SBC in the capture above identifies as genband
Hold re-INVITE (
a=sendonly) is answereda=sendrecv, violating RFC 3264 §6.1 — carrier BYEs the dialogSummary
When a carrier places a call on hold it sends a re-INVITE offering
a=sendonly. Both re-INVITEpaths in
pkg/sip/inbound.goanswer by replaying the cached local SDP verbatim, which stillcarries
a=sendrecv. RFC 3264 §6.1 requires asendonlyoffer to be answeredrecvonly, so theanswer is invalid and the carrier tears the dialog down ~60 ms later.
On a bridged call (two SIP legs in one room, e.g. after a cold transfer) the BYE collapses the
whole call: dropping the held leg drops the other party too. The user-visible symptom is
pressing HOLD hangs up the call.
Reproduced on v1.8.0 and v1.9.0 against two different carrier SBCs.
Reproduction
a=sendonly; livekit-sip answersa=sendrecv; the carriersends BYE and the call ends.
Packet capture
tcpdump -ni any udp port 5060on the SIP pod. Phone numbers, IPs and call IDs are redacted;SDP bodies and timings are verbatim.
Offer (carrier → us) — note
a=sendonly:Answer (us → carrier) —
a=sendrecv, which is what triggers the BYE:Server-side log for the same moment:
Cause
Both re-INVITE paths pass the cached local SDP straight through
(line numbers from
main@4259552):pkg/sip/inbound.go:422— inbound path:cc.AcceptAsKeepAlive(existing.cc.OwnSDP())pkg/sip/inbound.go:434— outbound path:cc.AcceptAsKeepAlive(localSDP)AcceptAsKeepAlive(inbound.go:2000) responds200 OKwith that body unmodified. The cachedSDP is the previously negotiated
sendrecvanswer, so the direction attribute is never adjustedto the new offer.
Consistent with that, the repository contains no occurrences of
sendonly,recvonly,sendrecvorinactivein non-test Go source — RFC 3264 direction handling appears simply not tobe implemented for re-INVITEs.
Expected behaviour (RFC 3264 §6.1)
a=sendonlya=recvonlya=recvonlya=sendonlya=inactivea=inactivea=sendrecv/ absenta=sendrecv(implied, RFC 4566 §6)Patch
We have a working fix running in production, plus tests. It adds two helpers and uses them in both
re-INVITE paths:
answerDirectionFor(offer []byte) string— maps the offer's direction to the RFC-correct answer,returning
""when the offer issendrecvor states no direction.withSDPDirection(local []byte, dir string) []byte— replaces the singlea=direction line inthe cached SDP (appending one if absent), preserving CRLF.
Two deliberate choices, both open to discussion:
working body; reserializing risks perturbing codec/
ptime/connection lines the carrier hasaccepted. Only the direction attribute changes. If you'd rather this lived in the SDP layer
(
media-sdk) where parsing already happens, that's very likely the better long-term home andwe're happy to redo it there.
port change per sip: redirect RTP destination on re-INVITE port change #728, session-timer refresh) behaves exactly as today. This is the common case
and we didn't want to change it.
Test coverage added (
pkg/sip/sdp_hold_direction_test.go): the captured carrier SDP above, thefull direction mapping, the untouched-when-no-direction case, unhold, appending a missing direction
line, and collapsing duplicate direction lines. The existing
pkg/sipsuite passes unchanged.Not verified: we've confirmed the SDP answer is now correct and that hold no longer ends the
call, but we have not characterised media behaviour across repeated hold/unhold cycles (whether
RTP flow is torn down and re-established as an implementation might expect). Worth a maintainer's
eye.
Happy to open a PR — wanted to check the preferred layer first.
Environment
ad8dafcb…anda906ae85…)hostNetwork: true, single replicagenband