Repository navigation
feat: a timer for NAS procedures, and T3591 resolved for satellite access #666
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 2 commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
| }) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| // Copyright 2019 free5GC.org | ||
| // | ||
| // 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()) | ||
|
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) | ||
| }) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.