From 610076cf84db01ec411f71c741054f1d1e90d6de Mon Sep 17 00:00:00 2001 From: Tom Bowden Date: Tue, 11 Aug 2026 08:50:45 +1000 Subject: [PATCH 1/2] Make the SIP status for a rung-out inbound call configurable When an inbound call rings for the whole ringing timeout without anything in the room subscribing to the caller's audio, the INVITE was rejected with a hardcoded 486 Busy Here. Carriers treat 486 as a definitive outcome and hand it straight back to the caller, so there is no way to tell an upstream carrier that the call failed on our side and another route should be tried. Which status is right depends on the deployment, and it is not simply a matter of picking a better default: codes that mean "retry me", 503 in particular, invite the carrier to re-send the INVITE repeatedly, making the caller wait out the ringing timeout once per attempt. Add a ringing_timeout_status option to choose the status. It defaults to 486, so behaviour is unchanged unless it is set. Co-Authored-By: Claude Opus 5 --- pkg/config/config.go | 12 ++++++++++-- pkg/sip/inbound.go | 9 ++++++++- pkg/sip/protocol.go | 1 + 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 8192660d..369c6a5a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -36,8 +36,9 @@ import ( ) const ( - DefaultSIPPort int = 5060 - DefaultSIPPortTLS int = 5061 + DefaultSIPPort int = 5060 + DefaultSIPPortTLS int = 5061 + DefaultRingingTimeoutStatus int = 486 // Busy Here ) var ( @@ -94,6 +95,7 @@ type Config struct { SIPHostname string `yaml:"sip_hostname"` OutboundRouteHeaders []string `yaml:"outbound_route_headers"` // Route headers prepended to outbound requests, e.g. "" SIPRingingInterval time.Duration `yaml:"sip_ringing_interval"` // from 1 sec up to 60 (default '1s') + RingingTimeoutStatus int `yaml:"ringing_timeout_status"` // status when a call rings out, 3xx-6xx (default '486') TCP *TCPConfig `yaml:"tcp"` TLS *TLSConfig `yaml:"tls"` RTPPort rtcconfig.PortRange `yaml:"rtp_port"` @@ -208,6 +210,12 @@ func (c *Config) Init() error { if c.MaxCpuUtilization <= 0 || c.MaxCpuUtilization > 1 { c.MaxCpuUtilization = 0.9 } + if c.RingingTimeoutStatus == 0 { + c.RingingTimeoutStatus = DefaultRingingTimeoutStatus + } else if c.RingingTimeoutStatus < 300 || c.RingingTimeoutStatus > 699 { + return psrpc.NewErrorf(psrpc.InvalidArgument, + "ringing_timeout_status must be a SIP failure status between 300 and 699, got %d", c.RingingTimeoutStatus) + } if err := c.InitLogger(); err != nil { return err diff --git a/pkg/sip/inbound.go b/pkg/sip/inbound.go index 8b7f4f77..cd29542d 100644 --- a/pkg/sip/inbound.go +++ b/pkg/sip/inbound.go @@ -1226,7 +1226,11 @@ func (c *inboundCall) waitSubscribe(ctx context.Context, timeout time.Duration) c.close(ctx, end) return false, psrpc.NewErrorf(psrpc.Canceled, "rpc terminated the call") case <-timer.C: - c.closeWithTerm(ctx, stats.ServerError("cannot-subscribe")) + c.close(ctx, EndCall{ + Status: callDropped, + Term: stats.ServerError("cannot-subscribe"), + Code: sip.StatusCode(c.s.conf.RingingTimeoutStatus), + }) return false, psrpc.NewErrorf(psrpc.DeadlineExceeded, "room subscription timed out") case <-c.lkRoom.Subscribed(): return true, nil @@ -1368,6 +1372,9 @@ func (c *inboundCall) close(ctx context.Context, end EndCall) { Status: "Request Terminated", } } + if end.Code != 0 { + result = Result{Code: end.Code} + } log := c.log().WithValues("status", result.Code, "result", string(end.Term.Result), "reason", end.Term.Reason) defer func() { c.stats.Update() diff --git a/pkg/sip/protocol.go b/pkg/sip/protocol.go index b3ee1f9b..44727755 100644 --- a/pkg/sip/protocol.go +++ b/pkg/sip/protocol.go @@ -63,6 +63,7 @@ type EndCall struct { Term stats.Termination Reason livekit.DisconnectReason // disconnect reason for LiveKit participant Headers map[string]string // extra headers to send to SIP peer + Code sip.StatusCode // SIP status for an unanswered call; zero picks the default } var statusNamesMap = map[int]string{ From 6e138342ff34210703cf7e350df6989479ea0027 Mon Sep 17 00:00:00 2001 From: Tom Bowden Date: Tue, 11 Aug 2026 11:56:45 +1000 Subject: [PATCH 2/2] Add a test for the status returned when a call rings out Nothing covered the response to an inbound call that rings for the whole ringing timeout without the room ever subscribing, in either the default or the configured case. Reuse the existing ringForever test room, which already skips the room's subscribed signal, and let NewServiceTest take a handler so the dispatch can return a short ringing timeout. Co-Authored-By: Claude Opus 5 --- pkg/sip/service_test.go | 45 +++++++++++++++++++++++++++++++++++++++ pkg/sip/signaling_test.go | 6 +++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/pkg/sip/service_test.go b/pkg/sip/service_test.go index 7adbffcc..2e5774da 100644 --- a/pkg/sip/service_test.go +++ b/pkg/sip/service_test.go @@ -995,6 +995,51 @@ func TestCANCELSendsBothResponses(t *testing.T) { } } +// A call that rings for the whole ringing timeout without the room ever subscribing +// is rejected with ringing_timeout_status, or 486 Busy Here when it is not configured. +func TestRingingTimeoutStatus(t *testing.T) { + cases := []struct { + name string + configed int + expected sip.StatusCode + }{ + {name: "unconfigured", configed: 0, expected: sip.StatusBusyHere}, + {name: "configured", configed: 480, expected: sip.StatusTemporarilyUnavailable}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + h := &TestHandler{ + DispatchCallFunc: func(ctx context.Context, info *CallInfo) CallDispatch { + identity := fmt.Sprintf("test-participant-%s", info.Call.SipCallId) + return CallDispatch{ + Result: DispatchAccept, + RingingTimeout: 500 * time.Millisecond, + Room: RoomConfig{ + RoomName: "test-room", + Participant: ParticipantConfig{ + Identity: identity, + Name: identity, + }, + }, + } + }, + } + st := NewServiceTest(t, &serviceTestConfig{ + GetRoom: newTestRoomConfig(&testRoomConfig{ringForever: true}), + Handler: h, + }) + st.Server.conf.RingingTimeoutStatus = c.configed + + call := newTestCall(st.TestUA, false) + req, _, err := call.Invite(nil) + require.NoError(t, err) + + resp := st.TestUA.TransactionRequest(t, req, true) + require.Equal(t, c.expected, resp.StatusCode) + }) + } +} + // TestSameCallIDForAuthFlow verifies that the same LiveKit call ID is assigned to both // the initial INVITE (without auth) and the subsequent INVITE (with auth) func TestSameCallIDForAuthFlow(t *testing.T) { diff --git a/pkg/sip/signaling_test.go b/pkg/sip/signaling_test.go index 8744f46f..24c9218d 100644 --- a/pkg/sip/signaling_test.go +++ b/pkg/sip/signaling_test.go @@ -423,6 +423,7 @@ type serviceTest struct { type serviceTestConfig struct { GetRoom GetRoomFunc + Handler Handler } func NewServiceTest(t *testing.T, options *serviceTestConfig) *serviceTest { @@ -434,6 +435,9 @@ func NewServiceTest(t *testing.T, options *serviceTestConfig) *serviceTest { if options.GetRoom == nil { options.GetRoom = newTestRoomConfig(nil) } + if options.Handler == nil { + options.Handler = &TestHandler{} + } sipPort := rand.Intn(testPortSIPMax-testPortSIPMin) + testPortSIPMin loopback := netip.MustParseAddr("127.0.0.1") @@ -492,7 +496,7 @@ func NewServiceTest(t *testing.T, options *serviceTestConfig) *serviceTest { MediaIP: loopback, } - handler := &TestHandler{} + handler := options.Handler err = srv.Start(nil, sconf, nil, cli.OnRequest) require.NoError(t, err)