Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5c93bc0
Fix re-INVITE dialog matching using RFC 3261 compliant To tag lookup
briankwest Feb 6, 2026
77ed4e8
Fix nonce collision in digest authentication for simultaneous calls
briankwest Feb 6, 2026
1f3bbee
Add unit tests for re-INVITE dialog matching
briankwest Feb 6, 2026
946cc63
Address PR feedback: refactor re-INVITE handling
briankwest Feb 7, 2026
c4bc293
Fix compilation errors in handleReinvite
briankwest Feb 7, 2026
b03a6cf
Fix test panic by using direct response creation in handleReinvite
briankwest Feb 7, 2026
3fb2b56
Add nil-safety checks in handleReinvite for test compatibility
briankwest Feb 7, 2026
12f7b49
Move SDP response generation to the session type.
dennwc Feb 10, 2026
375e01b
feat: implement graceful draining and outbound call control for SIP
biswajitpain Feb 26, 2026
e389068
Merge branch 'biglysales-v1' into biglysales-v1-reinvite-dialog-matching
rtnpro Feb 27, 2026
36d6d0c
Merge pull request #2 from Bigly-Sales/biglysales-v1-reinvite-dialog-…
rtnpro Feb 27, 2026
1ffeb8b
test: add integration test reproducing outbound re-INVITE bug
rtnpro Feb 28, 2026
2f9f883
test: add integration test reproducing 486 rejection on outbound re-I…
rtnpro Feb 28, 2026
2483f00
fix: handle re-INVITE for outbound SIP calls via Client delegation
rtnpro Feb 27, 2026
ce0f2b2
test: add integration test for mid-dialog re-INVITE handling
rtnpro Feb 28, 2026
3c9dd5e
fix: complete participant assertions in TestSIPReInvite post-re-INVIT…
rtnpro Feb 28, 2026
2c16377
fix: store outbound SDP independently to prevent echoing carrier's SD…
rtnpro Mar 2, 2026
7fb8e11
feat: add SDP direction negotiation for outbound re-INVITE responses
rtnpro Mar 2, 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: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ cmd/server/server

test/config.yaml
test/*/*.mkv

.claude
2 changes: 1 addition & 1 deletion cmd/livekit-sip/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ func runService(ctx context.Context, c *cli.Command) error {
if err != nil {
return err
}
svc := service.NewService(conf, log, sipsrv, sipsrv.Stop, sipsrv.ActiveCalls, psrpcClient, bus, mon)
svc := service.NewService(conf, log, sipsrv, sipsrv.Stop, sipsrv.StartDrain, sipsrv.ActiveCalls, psrpcClient, bus, mon)
sipsrv.SetHandler(svc)

if err = sipsrv.Start(); err != nil {
Expand Down
4 changes: 4 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ type Config struct {
HideInboundPort bool `yaml:"hide_inbound_port"`
// AddRecordRoute forces SIP to add Record-Route headers to the responses.
AddRecordRoute bool `yaml:"add_record_route"`
// DisableOutboundCalls prevents creation of new outbound SIP calls.
// When enabled, CreateSIPParticipant requests will be rejected.
// The client component still runs to handle responses (e.g., BYE) for inbound calls.
DisableOutboundCalls bool `yaml:"disable_outbound_calls"`

// AudioDTMF forces SIP to generate audio DTMF tones in addition to digital.
AudioDTMF bool `yaml:"audio_dtmf"`
Expand Down
26 changes: 23 additions & 3 deletions pkg/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import (
)

type sipServiceStopFunc func()
type sipServiceStartDrainFunc func()
type sipServiceActiveCallsFunc func() sip.ActiveCalls

type Service struct {
Expand All @@ -58,6 +59,7 @@ type Service struct {
rpcSIPServer rpc.SIPInternalServer

sipServiceStop sipServiceStopFunc
sipServiceStartDrain sipServiceStartDrainFunc
sipServiceActiveCalls sipServiceActiveCallsFunc

mon *stats.Monitor
Expand All @@ -67,7 +69,7 @@ type Service struct {

func NewService(
conf *config.Config, log logger.Logger, srv rpc.SIPInternalServerImpl, sipServiceStop sipServiceStopFunc,
sipServiceActiveCalls sipServiceActiveCallsFunc, cli rpc.IOInfoClient, bus psrpc.MessageBus, mon *stats.Monitor,
sipServiceStartDrain sipServiceStartDrainFunc, sipServiceActiveCalls sipServiceActiveCallsFunc, cli rpc.IOInfoClient, bus psrpc.MessageBus, mon *stats.Monitor,
) *Service {
s := &Service{
conf: conf,
Expand All @@ -78,6 +80,7 @@ func NewService(
bus: bus,

sipServiceStop: sipServiceStop,
sipServiceStartDrain: sipServiceStartDrain,
sipServiceActiveCalls: sipServiceActiveCalls,

mon: mon,
Expand Down Expand Up @@ -107,7 +110,7 @@ func NewService(
Handler: mux,
}

mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
healthHandler := func(w http.ResponseWriter, r *http.Request) {
st := s.Health()
var code int
switch st {
Expand All @@ -121,7 +124,9 @@ func NewService(
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(code)
_, _ = w.Write([]byte(st.String()))
})
}
mux.HandleFunc("/", healthHandler)
mux.HandleFunc("/healthz", healthHandler)
}
return s
}
Expand Down Expand Up @@ -188,6 +193,13 @@ func (s *Service) Run() error {
s.log.Infow("shutting down")
s.DeregisterCreateSIPParticipantTopic()

// Start draining: stop accepting new SIP calls immediately
// This ensures load balancers stop routing new traffic while existing calls finish
if s.sipServiceStartDrain != nil {
s.log.Infow("starting drain: rejecting new SIP calls")
s.sipServiceStartDrain()
}

if !s.killed.Load() {
shutdownTicker := time.NewTicker(5 * time.Second)
defer shutdownTicker.Stop()
Expand All @@ -208,6 +220,14 @@ func (s *Service) Run() error {
}

s.sipServiceStop()

// Keep health server running for a grace period to allow load balancer
// to detect unhealthy status. Health endpoint already returns 503.
if s.healthServer != nil {
s.log.Infow("waiting for load balancer to detect unhealthy status", "grace_period", "30s")
time.Sleep(30 * time.Second)
}

return nil
}
}
Expand Down
33 changes: 33 additions & 0 deletions pkg/sip/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ type Client struct {
cmu sync.Mutex
activeCalls map[LocalTag]*outboundCall
byRemote map[RemoteTag]*outboundCall
byCallID map[string]*outboundCall

handler Handler
getIOClient GetIOInfoClient
Expand Down Expand Up @@ -104,6 +105,7 @@ func NewClient(region string, conf *config.Config, log logger.Logger, mon *stats
getRoom: DefaultGetRoomFunc,
activeCalls: make(map[LocalTag]*outboundCall),
byRemote: make(map[RemoteTag]*outboundCall),
byCallID: make(map[string]*outboundCall),
}
for _, option := range options {
option(c)
Expand Down Expand Up @@ -138,6 +140,12 @@ func (c *Client) Start(agent *sipgo.UserAgent, sc *ServiceConfig) error {
return nil
}

// StartDrain stops accepting new outbound call requests but keeps existing calls open.
// This allows the client to gracefully drain while completing ongoing calls.
func (c *Client) StartDrain() {
c.closing.Break()
}

func (c *Client) Stop() {
ctx := context.Background()
ctx, span := Tracer.Start(ctx, "sip.Client.Stop")
Expand All @@ -147,6 +155,7 @@ func (c *Client) Stop() {
calls := maps.Values(c.activeCalls)
c.activeCalls = make(map[LocalTag]*outboundCall)
c.byRemote = make(map[RemoteTag]*outboundCall)
c.byCallID = make(map[string]*outboundCall)
c.cmu.Unlock()
for _, call := range calls {
call.Close(ctx)
Expand Down Expand Up @@ -320,13 +329,37 @@ func (c *Client) OnRequest(req *sip.Request, tx sip.ServerTransaction) bool {
switch req.Method {
default:
return false
case "INVITE":
return c.onInvite(req, tx)
case "BYE":
return c.onBye(req, tx)
case "NOTIFY":
return c.onNotify(req, tx)
}
}

// onInvite handles mid-dialog re-INVITEs for outbound calls (e.g. session refresh/keep-alive).
func (c *Client) onInvite(req *sip.Request, tx sip.ServerTransaction) bool {
callID := ""
if h := req.CallID(); h != nil {
callID = h.Value()
}
if callID == "" {
return false
}

c.cmu.Lock()
call := c.byCallID[callID]
c.cmu.Unlock()
if call == nil {
c.log.Infow("re-INVITE for unknown outbound call", "sipCallID", callID)
return false
}
call.log.Infow("re-INVITE from remote for outbound call", "sipCallID", callID)
call.handleReInvite(req, tx)
return true
}

func (c *Client) onBye(req *sip.Request, tx sip.ServerTransaction) bool {
ctx := context.Background()
ctx, span := Tracer.Start(ctx, "sip.Client.onBye")
Expand Down
Loading