Skip to content

Hold re-INVITE (a=sendonly) is answered a=sendrecv, violating RFC 3264 §6.1 — carrier BYEs the dialog #779

Description

@prasanth-33460

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

  1. Establish a call with two SIP participants bridged in one room (inbound caller + outbound leg).
  2. From the outbound party's phone, press hold.
  3. 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

Offer (carrier → us) — note a=sendonly:

v=0
o=genband 1642851412 1894065156 IN IP4 203.0.113.20
s=-
c=IN IP4 203.0.113.20
t=0 0
m=audio 29076 RTP/AVP 0 8 18 9 13 101
c=IN IP4 203.0.113.20
a=rtpmap:0 PCMU/8000
a=rtpmap:8 PCMA/8000
a=rtpmap:101 telephone-event/8000
a=fmtp:101 0-15
a=sendonly
a=ptime:20

Answer (us → carrier)a=sendrecv, which is what triggers the BYE:

v=0
o=- 9157931411869268290 9157931411869268294 IN IP4 203.0.113.10
s=LiveKit
c=IN IP4 203.0.113.10
t=0 0
m=audio 17830 RTP/AVP 0 101
a=rtpmap:0 PCMU/8000
a=rtpmap:101 telephone-event/8000
a=fmtp:101 0-16
a=ptime:20
a=sendrecv

Server-side log for the same moment:

06:19:05.464  processing invite
06:19:05.464  accepting reinvite      participant=<outbound-leg>
06:19:05.527  BYE from remote         participant=<outbound-leg>
06:19:05.528  Closing outbound call   participant=<outbound-leg>  reason=bye

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) 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:

  1. 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.
  2. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions