Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
80 changes: 80 additions & 0 deletions context/nas_timer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// SPDX-FileCopyrightText: 2026 Forsway Scandinavia AB
// SPDX-License-Identifier: Apache-2.0

package context

import (
"time"

"github.com/omec-project/smf/factory"
)

// T3591 values from 3GPP TS 24.501 table 10.3.2, the network-side 5GSM timer table.
const (
// T3591Default applies to terrestrial access.
T3591Default = 16 * time.Second

// T3591Satellite applies to access via a satellite NG-RAN cell. NOTE 5 of that table
// restricts it to satellite NG-RAN RAT type NR(MEO) or NR(GEO); NR(LEO) is not named
// and therefore takes T3591Default, as does NR(OTHER_SAT).
T3591Satellite = 22 * time.Second

// T3591MaxRetriesDefault: NOTE 1 of that table — "Typically, the procedures are aborted on
// the fifth expiry of the relevant timer" — so the command is retransmitted on the first four.
T3591MaxRetriesDefault = 4
)

// NasTimerSource records which layer decided a NAS timer value. The value alone is not enough
// to diagnose with: a session running the terrestrial value over a geostationary link aborts
// procedures early, and the symptom is indistinguishable from a UE that did not answer. Knowing
// whether the value came from configuration, from the network, or from a fallback is what
// separates the two.
type NasTimerSource string

const (
// NasTimerSourceConfig means an operator set the value explicitly.
NasTimerSourceConfig NasTimerSource = "config"

// NasTimerSourceIndication means the AMF told us the extended NAS-SM timer applies.
NasTimerSourceIndication NasTimerSource = "indication"

// NasTimerSourceDefault means neither was available and the terrestrial value applies.
NasTimerSourceDefault NasTimerSource = "default"
)

// ResolveT3591 returns the T3591 value for a session and the layer that decided it.
//
// TS 24.501 subclause 4.23.4 makes the AMF's extended NAS-SM timer indication the selector for
// the satellite value; the SMF is told rather than inferring it from the RAT type. Nothing
// guarantees the indication arrives, however — it depends on the RAN advertising the Rel-18
// RATInformation IE — so an explicit configured value takes precedence over it. A deployment
// that has had to configure the value has usually done so because the automatic path did not
// work, and a site is one orbit.
func ResolveT3591(cfg *factory.TimerValue, extendedNasSmTimer bool) (time.Duration, NasTimerSource) {
if cfg != nil && cfg.ExpireTime > 0 {
return cfg.ExpireTime, NasTimerSourceConfig
}
if extendedNasSmTimer {
return T3591Satellite, NasTimerSourceIndication
}
return T3591Default, NasTimerSourceDefault
}

// EffectiveT3591 reports whether the retransmission timer should run for a session and how many
// times the command may be retransmitted, tolerating a configuration block that was never
// populated.
//
// An absent block means defaults, not "disabled". The timer is the only thing that stops an
// unacknowledged modification from being recorded as applied, so whether it runs must not depend
// on init-time defaulting having been reached. Only an explicit enable: false turns it off.
func EffectiveT3591(cfg *factory.TimerValue) (enabled bool, maxRetries int) {
if cfg == nil {
return true, T3591MaxRetriesDefault
}
enabled = cfg.Enable == nil || *cfg.Enable
maxRetries = cfg.MaxRetryTimes
if maxRetries <= 0 {
maxRetries = T3591MaxRetriesDefault
}
return enabled, maxRetries
}
116 changes: 116 additions & 0 deletions context/nas_timer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// SPDX-FileCopyrightText: 2026 Forsway Scandinavia AB
// SPDX-License-Identifier: Apache-2.0

package context

import (
"testing"
"time"

"github.com/omec-project/smf/factory"
)

func TestResolveT3591(t *testing.T) {
tests := []struct {
name string
cfg *factory.TimerValue
extendedNasSmTimer bool
wantValue time.Duration
wantSource NasTimerSource
}{
{
name: "configured value overrides a present indication",
cfg: &factory.TimerValue{ExpireTime: 30 * time.Second},
extendedNasSmTimer: true,
wantValue: 30 * time.Second,
wantSource: NasTimerSourceConfig,
},
{
name: "configured value applies with no indication",
cfg: &factory.TimerValue{ExpireTime: 30 * time.Second},
extendedNasSmTimer: false,
wantValue: 30 * time.Second,
wantSource: NasTimerSourceConfig,
},
{
name: "indication decides when unconfigured",
cfg: &factory.TimerValue{},
extendedNasSmTimer: true,
wantValue: T3591Satellite,
wantSource: NasTimerSourceIndication,
},
{
name: "terrestrial default when neither is available",
cfg: &factory.TimerValue{},
extendedNasSmTimer: false,
wantValue: T3591Default,
wantSource: NasTimerSourceDefault,
},
{
name: "absent configuration behaves as unconfigured",
cfg: nil,
extendedNasSmTimer: true,
wantValue: T3591Satellite,
wantSource: NasTimerSourceIndication,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
gotValue, gotSource := ResolveT3591(tc.cfg, tc.extendedNasSmTimer)
if gotValue != tc.wantValue {
t.Errorf("value = %s, want %s", gotValue, tc.wantValue)
}
if gotSource != tc.wantSource {
t.Errorf("source = %s, want %s", gotSource, tc.wantSource)
}
})
}
}

// A LEO session takes the terrestrial value. TS 24.501 table 10.3.2 NOTE 5 restricts the
// satellite value to RAT type NR(MEO) or NR(GEO), so the AMF does not set the indication for
// NR(LEO) and the SMF must not reach for 22s on its own.
func TestResolveT3591LeoTakesTerrestrialValue(t *testing.T) {
value, source := ResolveT3591(&factory.TimerValue{}, false)
if value != T3591Default {
t.Fatalf("a LEO session must take %s, got %s", T3591Default, value)
}
if source != NasTimerSourceDefault {
t.Fatalf("source = %s, want %s", source, NasTimerSourceDefault)
}
if T3591Default == T3591Satellite {
t.Fatal("the terrestrial and satellite values must differ for this test to mean anything")
}
}

func TestEffectiveT3591(t *testing.T) {
enable, disable := true, false

tests := []struct {
name string
cfg *factory.TimerValue
wantEnabled bool
wantRetries int
}{
{"absent block runs with defaults", nil, true, T3591MaxRetriesDefault},
{"empty block runs with defaults", &factory.TimerValue{}, true, T3591MaxRetriesDefault},
{"only a value set still runs", &factory.TimerValue{ExpireTime: time.Second}, true, T3591MaxRetriesDefault},
{"explicit enable", &factory.TimerValue{Enable: &enable}, true, T3591MaxRetriesDefault},
{"explicit disable is honoured", &factory.TimerValue{Enable: &disable}, false, T3591MaxRetriesDefault},
{"retry count is respected", &factory.TimerValue{MaxRetryTimes: 2}, true, 2},
{"a nonsense retry count falls back", &factory.TimerValue{MaxRetryTimes: -1}, true, T3591MaxRetriesDefault},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
enabled, retries := EffectiveT3591(tc.cfg)
if enabled != tc.wantEnabled {
t.Errorf("enabled = %v, want %v", enabled, tc.wantEnabled)
}
if retries != tc.wantRetries {
t.Errorf("maxRetries = %d, want %d", retries, tc.wantRetries)
}
})
}
}
87 changes: 87 additions & 0 deletions context/timer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Copyright 2019 free5GC.org
Comment thread
gab-arrobo marked this conversation as resolved.
//
// SPDX-License-Identifier: Apache-2.0
//

package context

import (
"sync"
"sync/atomic"
"time"
)

// Timer can be used for retransmission, it will manage retry times automatically.
//
// Ported from the AMF, which carries the same type for its NAS timers, so that the two network
// functions retransmit on the same semantics. One difference: Stop is idempotent here. The
// original documents that calling it more than once is unsafe, which is a hazard for a timer
// stopped by an incoming message — a retransmitted acknowledgement racing an abort would stop
// it twice, and a second send on the closed channel panics.
type Timer struct {
ticker *time.Ticker
expireTimes atomic.Int32
maxRetryTimes atomic.Int32
done chan bool
stopOnce sync.Once
}

// NewTimer returns a Timer and starts a goroutine that calls expiredFunc on every interval d
// until Stop is called. Once the number of expiries exceeds maxRetryTimes the timer calls
// cancelFunc and turns itself off. expiredFunc receives the current expiry count.
func NewTimer(d time.Duration, maxRetryTimes int,
expiredFunc func(expireTimes int32),
cancelFunc func(),
) *Timer {
t := &Timer{}
t.expireTimes.Store(0)
t.maxRetryTimes.Store(int32(maxRetryTimes))
t.done = make(chan bool, 1)
t.ticker = time.NewTicker(d)

go func(ticker *time.Ticker) {
defer ticker.Stop()

for {
select {
case <-t.done:
return
case <-ticker.C:
t.expireTimes.Add(1)
if t.ExpireTimes() > t.MaxRetryTimes() {
cancelFunc()
return
}
expiredFunc(t.ExpireTimes())
Comment thread
gab-arrobo marked this conversation as resolved.
}
}
}(t.ticker)

return t
}

// MaxRetryTimes returns the max retry times of the timer.
func (t *Timer) MaxRetryTimes() int32 {
return t.maxRetryTimes.Load()
}

// ExpireTimes returns the current expire times of the timer.
func (t *Timer) ExpireTimes() int32 {
return t.expireTimes.Load()
}

// Stop turns off the timer. Stop is safe to call more than once and safe to call concurrently with
// the timer aborting on its own.
//
// Stop does not wait for the timer's goroutine. An expiry that is already due when Stop is called
// can still be delivered afterwards: the goroutine selects between the stop signal and the ticker,
// and Go's select picks at random when both are ready. A caller whose callbacks must not act for a
// stopped timer has to check that the timer is still the one it is using, under the same lock that
// it holds when it calls Stop. Waiting here for a callback in progress is not an alternative, since
// callers stop the timer under the lock their callbacks take.
func (t *Timer) Stop() {
t.stopOnce.Do(func() {
t.done <- true
close(t.done)
})
}
64 changes: 64 additions & 0 deletions context/timer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// SPDX-FileCopyrightText: 2026 Forsway Scandinavia AB
// SPDX-License-Identifier: Apache-2.0

package context

import (
"sync"
"sync/atomic"
"testing"
"time"
)

func TestTimerRetransmitsThenCancels(t *testing.T) {
var expiries atomic.Int32
cancelled := make(chan struct{})

NewTimer(5*time.Millisecond, 4,
func(int32) { expiries.Add(1) },
func() { close(cancelled) },
)

select {
case <-cancelled:
case <-time.After(2 * time.Second):
t.Fatal("timer never cancelled")
}

if got := expiries.Load(); got != 4 {
t.Errorf("retransmissions = %d, want 4 before the fifth expiry aborts", got)
}
}

func TestTimerStopIsIdempotent(t *testing.T) {
timer := NewTimer(time.Hour, 4, func(int32) {}, func() {})

var wg sync.WaitGroup
for range 4 {
wg.Add(1)
go func() {
defer wg.Done()
timer.Stop() // a second send on the closed channel would panic
}()
}
wg.Wait()
}

func TestTimerStoppedBeforeExpiryDoesNotFire(t *testing.T) {
var expiries atomic.Int32
var cancels atomic.Int32

timer := NewTimer(20*time.Millisecond, 4,
func(int32) { expiries.Add(1) },
func() { cancels.Add(1) },
)
timer.Stop()
time.Sleep(120 * time.Millisecond)

if got := expiries.Load(); got != 0 {
t.Errorf("expiries after Stop = %d, want 0", got)
}
if got := cancels.Load(); got != 0 {
t.Errorf("cancels after Stop = %d, want 0", got)
}
}
17 changes: 17 additions & 0 deletions factory/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,23 @@ type Configuration struct {
EnableUpfAdapter bool `yaml:"enableUPFAdapter,omitempty"`
ULCL bool `yaml:"ulcl,omitempty"`
PCSCFInfo PCSCFInfo `yaml:"pcscfInfos,omitempty"`
T3591 *TimerValue `yaml:"t3591,omitempty"`
}

// TimerValue configures a NAS retransmission timer. It follows the shape the AMF uses for
// its own NAS timers so that operators meet one convention rather than two.
//
// ExpireTime left unset means "resolve automatically": the extended NAS-SM timer indication
// from the AMF if it arrives, and the terrestrial value from 3GPP TS 24.501 otherwise. Setting
// it is a deliberate operator override of that resolution, for deployments whose RAN does not
// advertise the information the indication is derived from.
type TimerValue struct {
// Enable is a pointer so that an absent key means "on" rather than "off". With a plain bool
// a configuration that set only expireTime would silently disable the timer, which is the
// opposite of what writing a value expresses. This follows EnableKafka above.
Enable *bool `yaml:"enable,omitempty"`
ExpireTime time.Duration `yaml:"expireTime"`
MaxRetryTimes int `yaml:"maxRetryTimes,omitempty"`
}

type StaticIpInfo struct {
Expand Down
Loading
Loading