mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-31 21:19:55 +00:00
The session deadline lived in two sinks kept in sync by hand: ApplySessionDeadline wrote both the (engine-scoped) sessionwatch.Watcher and the (server-scoped) peer.Status recorder. The clear paths only touched the watcher, so the recorder — which is what the Status RPC / SubscribeStatus snapshot the UI reads from — kept reporting a deadline that had gone stale, surfacing as a frozen "expires in …" countdown. Two cases were leaking: - Profile switch / Down: the watcher is recreated per engine but the recorder outlives it, so a switch to a profile whose server sends no deadline left the previous profile's value in place. - In-place expiry: the watcher arms warning timers at T-WarningLead and T-FinalWarningLead but nothing at the deadline itself, so once the moment passed the recorder kept the now-past value indefinitely. Make the watcher the single writer of the recorder deadline (Update / clearLocked / Close all route through SetSessionExpiresAt) so teardown clears it, and guard GetSessionExpiresAt to report a past deadline as none so in-place expiry stops painting a stale countdown.
520 lines
14 KiB
Go
520 lines
14 KiB
Go
package sessionwatch
|
|
|
|
import (
|
|
"errors"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
cProto "github.com/netbirdio/netbird/client/proto"
|
|
)
|
|
|
|
// fakeRecorder satisfies StatusRecorder and records every call so tests
|
|
// can observe what the watcher emits. SetSessionExpiresAt and PublishEvent
|
|
// land in the same ordered events slice (with the Kind distinguishing
|
|
// them) so tests that care about ordering still work. lastDeadline holds
|
|
// the most recent value passed to SetSessionExpiresAt so tests can assert
|
|
// the recorder ended up cleared/set as expected.
|
|
type fakeRecorder struct {
|
|
mu sync.Mutex
|
|
events []event
|
|
lastDeadline time.Time
|
|
}
|
|
|
|
type eventKind int
|
|
|
|
const (
|
|
stateChange eventKind = iota
|
|
publish
|
|
)
|
|
|
|
type event struct {
|
|
kind eventKind
|
|
// Set only for publish events.
|
|
severity cProto.SystemEvent_Severity
|
|
category cProto.SystemEvent_Category
|
|
message string
|
|
meta map[string]string
|
|
}
|
|
|
|
// SetSessionExpiresAt mirrors peer.Status: a same-value write is a no-op,
|
|
// a real change records the new value and fans out a state-change (the
|
|
// production recorder calls notifyStateChange internally). The baseline
|
|
// is the zero time, so an initial clear before any deadline is set emits
|
|
// nothing — matching the real recorder.
|
|
func (r *fakeRecorder) SetSessionExpiresAt(deadline time.Time) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.lastDeadline.Equal(deadline) {
|
|
return
|
|
}
|
|
r.lastDeadline = deadline
|
|
r.events = append(r.events, event{kind: stateChange})
|
|
}
|
|
|
|
func (r *fakeRecorder) deadline() time.Time {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.lastDeadline
|
|
}
|
|
|
|
func (r *fakeRecorder) PublishEvent(
|
|
severity cProto.SystemEvent_Severity,
|
|
category cProto.SystemEvent_Category,
|
|
message string,
|
|
_ string,
|
|
metadata map[string]string,
|
|
) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.events = append(r.events, event{
|
|
kind: publish,
|
|
severity: severity,
|
|
category: category,
|
|
message: message,
|
|
meta: metadata,
|
|
})
|
|
}
|
|
|
|
func (r *fakeRecorder) snapshot() []event {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
out := make([]event, len(r.events))
|
|
copy(out, r.events)
|
|
return out
|
|
}
|
|
|
|
func (e event) isFinalWarning() bool {
|
|
return e.kind == publish && e.meta[MetaSessionFinal] == "true"
|
|
}
|
|
|
|
func (e event) isWarning() bool {
|
|
return e.kind == publish && e.meta[MetaSessionWarning] == "true" && e.meta[MetaSessionFinal] != "true"
|
|
}
|
|
|
|
func countWhere(events []event, pred func(event) bool) int {
|
|
n := 0
|
|
for _, e := range events {
|
|
if pred(e) {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
func waitForEvents(t *testing.T, r *fakeRecorder, want int) []event {
|
|
t.Helper()
|
|
deadline := time.Now().Add(500 * time.Millisecond)
|
|
for time.Now().Before(deadline) {
|
|
if got := r.snapshot(); len(got) >= want {
|
|
return got
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
got := r.snapshot()
|
|
t.Fatalf("timed out waiting for %d events, got %d: %+v", want, len(got), got)
|
|
return nil
|
|
}
|
|
|
|
// newWatcher builds a watcher with the final timer disabled (finalLead=0),
|
|
// matching the lead-only behaviour the pre-final-warning tests assume.
|
|
func newWatcher(lead time.Duration, r *fakeRecorder) *Watcher {
|
|
return NewWithLeads(lead, 0, r)
|
|
}
|
|
|
|
func TestUpdateZeroBeforeAnythingIsNoop(t *testing.T) {
|
|
r := &fakeRecorder{}
|
|
w := newWatcher(50*time.Millisecond, r)
|
|
defer w.Close()
|
|
|
|
_ = w.Update(time.Time{})
|
|
|
|
if got := r.snapshot(); len(got) != 0 {
|
|
t.Fatalf("expected no events on initial zero, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestUpdateNonZeroFiresStateChange(t *testing.T) {
|
|
r := &fakeRecorder{}
|
|
w := newWatcher(50*time.Millisecond, r)
|
|
defer w.Close()
|
|
|
|
d := time.Now().Add(time.Hour)
|
|
_ = w.Update(d)
|
|
|
|
events := waitForEvents(t, r, 1)
|
|
if events[0].kind != stateChange {
|
|
t.Fatalf("expected stateChange, got %+v", events[0])
|
|
}
|
|
if !w.Deadline().Equal(d) {
|
|
t.Fatalf("deadline mismatch: %v vs %v", w.Deadline(), d)
|
|
}
|
|
}
|
|
|
|
func TestSameDeadlineIsNoop(t *testing.T) {
|
|
r := &fakeRecorder{}
|
|
w := newWatcher(50*time.Millisecond, r)
|
|
defer w.Close()
|
|
|
|
d := time.Now().Add(time.Hour)
|
|
_ = w.Update(d)
|
|
_ = w.Update(d)
|
|
_ = w.Update(d)
|
|
|
|
events := waitForEvents(t, r, 1)
|
|
if len(events) != 1 {
|
|
t.Fatalf("expected exactly 1 event for repeated same deadline, got %d: %+v", len(events), events)
|
|
}
|
|
}
|
|
|
|
func TestWarningFiresOnceWithinLeadWindow(t *testing.T) {
|
|
r := &fakeRecorder{}
|
|
lead := 50 * time.Millisecond
|
|
w := newWatcher(lead, r)
|
|
defer w.Close()
|
|
|
|
// Deadline 80ms out — warning should fire after ~30ms.
|
|
d := time.Now().Add(80 * time.Millisecond)
|
|
_ = w.Update(d)
|
|
|
|
events := waitForEvents(t, r, 2)
|
|
if events[0].kind != stateChange {
|
|
t.Fatalf("event[0] should be stateChange, got %+v", events[0])
|
|
}
|
|
if !events[1].isWarning() {
|
|
t.Fatalf("event[1] should be a warning publish, got %+v", events[1])
|
|
}
|
|
}
|
|
|
|
func TestWarningFiresImmediatelyWhenAlreadyInsideWindow(t *testing.T) {
|
|
r := &fakeRecorder{}
|
|
w := newWatcher(time.Hour, r) // lead > delta => fire immediately
|
|
defer w.Close()
|
|
|
|
d := time.Now().Add(10 * time.Millisecond)
|
|
_ = w.Update(d)
|
|
|
|
events := waitForEvents(t, r, 2)
|
|
if !events[1].isWarning() {
|
|
t.Fatalf("expected immediate warning publish, got %+v", events[1])
|
|
}
|
|
}
|
|
|
|
func TestNewDeadlineCancelsPriorTimer(t *testing.T) {
|
|
r := &fakeRecorder{}
|
|
lead := 50 * time.Millisecond
|
|
w := newWatcher(lead, r)
|
|
defer w.Close()
|
|
|
|
first := time.Now().Add(80 * time.Millisecond) // would fire warning ~30ms in
|
|
_ = w.Update(first)
|
|
|
|
// Replace with a far-future deadline before the warning fires.
|
|
time.Sleep(5 * time.Millisecond)
|
|
second := time.Now().Add(time.Hour)
|
|
_ = w.Update(second)
|
|
|
|
// Wait past when first's warning would have fired.
|
|
time.Sleep(80 * time.Millisecond)
|
|
|
|
if n := countWhere(r.snapshot(), event.isWarning); n != 0 {
|
|
t.Fatalf("warning fired for cancelled deadline: %+v", r.snapshot())
|
|
}
|
|
}
|
|
|
|
func TestRefreshAfterFireArmsNewWarning(t *testing.T) {
|
|
r := &fakeRecorder{}
|
|
lead := 30 * time.Millisecond
|
|
w := newWatcher(lead, r)
|
|
defer w.Close()
|
|
|
|
first := time.Now().Add(50 * time.Millisecond)
|
|
_ = w.Update(first)
|
|
|
|
// Wait for stateChange + warning of the first cycle.
|
|
waitForEvents(t, r, 2)
|
|
|
|
// Simulate a successful extend: brand new deadline.
|
|
second := time.Now().Add(60 * time.Millisecond)
|
|
_ = w.Update(second)
|
|
|
|
// 4 events total: stateChange, warning (first), stateChange, warning (second).
|
|
events := waitForEvents(t, r, 4)
|
|
if events[2].kind != stateChange {
|
|
t.Fatalf("event[2] should be stateChange for the new deadline, got %+v", events[2])
|
|
}
|
|
if !events[3].isWarning() {
|
|
t.Fatalf("event[3] should be a warning publish for the new deadline, got %+v", events[3])
|
|
}
|
|
}
|
|
|
|
func TestUpdateZeroAfterNonZeroClearsState(t *testing.T) {
|
|
r := &fakeRecorder{}
|
|
w := newWatcher(time.Hour, r)
|
|
defer w.Close()
|
|
|
|
d := time.Now().Add(2 * time.Hour)
|
|
_ = w.Update(d)
|
|
waitForEvents(t, r, 1)
|
|
|
|
_ = w.Update(time.Time{})
|
|
|
|
events := waitForEvents(t, r, 2)
|
|
if events[1].kind != stateChange {
|
|
t.Fatalf("expected stateChange on clear, got %+v", events[1])
|
|
}
|
|
if !w.Deadline().IsZero() {
|
|
t.Fatalf("Deadline should be zero after clear")
|
|
}
|
|
}
|
|
|
|
func TestUpdateRejectsBeforeEpoch(t *testing.T) {
|
|
r := &fakeRecorder{}
|
|
w := newWatcher(50*time.Millisecond, r)
|
|
defer w.Close()
|
|
|
|
good := time.Now().Add(time.Hour)
|
|
if err := w.Update(good); err != nil {
|
|
t.Fatalf("seed Update: %v", err)
|
|
}
|
|
|
|
err := w.Update(time.Unix(-100, 0))
|
|
if !errors.Is(err, ErrDeadlineBeforeEpoch) {
|
|
t.Fatalf("want ErrDeadlineBeforeEpoch, got %v", err)
|
|
}
|
|
if !w.Deadline().IsZero() {
|
|
t.Fatalf("rejected pre-epoch update must clear deadline; got %v", w.Deadline())
|
|
}
|
|
}
|
|
|
|
func TestUpdateRejectsTooFarFuture(t *testing.T) {
|
|
r := &fakeRecorder{}
|
|
w := newWatcher(50*time.Millisecond, r)
|
|
defer w.Close()
|
|
|
|
good := time.Now().Add(time.Hour)
|
|
if err := w.Update(good); err != nil {
|
|
t.Fatalf("seed Update: %v", err)
|
|
}
|
|
|
|
err := w.Update(time.Now().Add(50 * 365 * 24 * time.Hour))
|
|
if !errors.Is(err, ErrDeadlineTooFarFuture) {
|
|
t.Fatalf("want ErrDeadlineTooFarFuture, got %v", err)
|
|
}
|
|
if !w.Deadline().IsZero() {
|
|
t.Fatalf("rejected far-future update must clear deadline; got %v", w.Deadline())
|
|
}
|
|
}
|
|
|
|
func TestUpdateInPastClearsDeadline(t *testing.T) {
|
|
r := &fakeRecorder{}
|
|
w := newWatcher(50*time.Millisecond, r)
|
|
defer w.Close()
|
|
|
|
good := time.Now().Add(time.Hour)
|
|
if err := w.Update(good); err != nil {
|
|
t.Fatalf("seed Update: %v", err)
|
|
}
|
|
// Drain the stateChange from the seed.
|
|
waitForEvents(t, r, 1)
|
|
|
|
err := w.Update(time.Now().Add(-1 * time.Hour))
|
|
if !errors.Is(err, ErrDeadlineInPast) {
|
|
t.Fatalf("want ErrDeadlineInPast, got %v", err)
|
|
}
|
|
if !w.Deadline().IsZero() {
|
|
t.Fatalf("in-past update must clear the deadline, got %v", w.Deadline())
|
|
}
|
|
events := waitForEvents(t, r, 2)
|
|
if events[1].kind != stateChange {
|
|
t.Fatalf("expected stateChange on clear, got %+v", events[1])
|
|
}
|
|
}
|
|
|
|
func TestUpdateWithinSkewAccepted(t *testing.T) {
|
|
r := &fakeRecorder{}
|
|
w := newWatcher(50*time.Millisecond, r)
|
|
defer w.Close()
|
|
|
|
// 5 seconds in the past is within the 30s Skew tolerance — accept it.
|
|
d := time.Now().Add(-5 * time.Second)
|
|
if err := w.Update(d); err != nil {
|
|
t.Fatalf("within-skew Update should succeed, got %v", err)
|
|
}
|
|
if !w.Deadline().Equal(d) {
|
|
t.Fatalf("expected deadline to be applied, got %v want %v", w.Deadline(), d)
|
|
}
|
|
}
|
|
|
|
func TestCloseSilencesUpdates(t *testing.T) {
|
|
r := &fakeRecorder{}
|
|
w := newWatcher(50*time.Millisecond, r)
|
|
w.Close()
|
|
|
|
_ = w.Update(time.Now().Add(time.Hour))
|
|
|
|
time.Sleep(20 * time.Millisecond)
|
|
if got := r.snapshot(); len(got) != 0 {
|
|
t.Fatalf("expected no events after Close, got %+v", got)
|
|
}
|
|
}
|
|
|
|
// TestCloseClearsRecorderDeadline pins the profile-switch fix: a watcher
|
|
// holding a live deadline must zero the recorder on Close so the next
|
|
// engine's watcher (and the UI reading the shared server-scoped recorder)
|
|
// doesn't start out showing the previous session's stale "expires in".
|
|
func TestCloseClearsRecorderDeadline(t *testing.T) {
|
|
r := &fakeRecorder{}
|
|
w := newWatcher(time.Hour, r)
|
|
|
|
d := time.Now().Add(2 * time.Hour)
|
|
if err := w.Update(d); err != nil {
|
|
t.Fatalf("seed Update: %v", err)
|
|
}
|
|
if got := r.deadline(); !got.Equal(d) {
|
|
t.Fatalf("recorder deadline after Update = %v, want %v", got, d)
|
|
}
|
|
|
|
w.Close()
|
|
|
|
if got := r.deadline(); !got.IsZero() {
|
|
t.Fatalf("recorder deadline after Close = %v, want zero", got)
|
|
}
|
|
}
|
|
|
|
// TestCloseWithoutDeadlineLeavesRecorderUntouched guards the symmetric
|
|
// case: closing a watcher that never held a deadline must not emit a
|
|
// redundant clear (the recorder may legitimately hold a value written by
|
|
// some other path; the watcher only owns what it set).
|
|
func TestCloseWithoutDeadlineLeavesRecorderUntouched(t *testing.T) {
|
|
r := &fakeRecorder{}
|
|
w := newWatcher(time.Hour, r)
|
|
|
|
w.Close()
|
|
|
|
if got := r.snapshot(); len(got) != 0 {
|
|
t.Fatalf("expected no events from Close on an empty watcher, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestFinalWarningFiresAfterRegularWarning(t *testing.T) {
|
|
r := &fakeRecorder{}
|
|
// Warning fires at deadline-80ms, final at deadline-30ms.
|
|
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
|
defer w.Close()
|
|
|
|
d := time.Now().Add(100 * time.Millisecond)
|
|
_ = w.Update(d)
|
|
|
|
// Expect stateChange + warning + final-warning.
|
|
events := waitForEvents(t, r, 3)
|
|
|
|
if countWhere(events, func(e event) bool { return e.kind == stateChange }) != 1 {
|
|
t.Fatalf("expected exactly 1 stateChange, got %+v", events)
|
|
}
|
|
if countWhere(events, event.isWarning) != 1 {
|
|
t.Fatalf("expected exactly 1 warning publish, got %+v", events)
|
|
}
|
|
if countWhere(events, event.isFinalWarning) != 1 {
|
|
t.Fatalf("expected exactly 1 final-warning publish, got %+v", events)
|
|
}
|
|
|
|
// Warning must precede final (same deadline, longer lead fires first).
|
|
var wIdx, fIdx int
|
|
for i, e := range events {
|
|
switch {
|
|
case e.isWarning():
|
|
wIdx = i
|
|
case e.isFinalWarning():
|
|
fIdx = i
|
|
}
|
|
}
|
|
if wIdx > fIdx {
|
|
t.Fatalf("warning must publish before final-warning, got order %+v", events)
|
|
}
|
|
}
|
|
|
|
func TestDismissSuppressesFinalWarning(t *testing.T) {
|
|
r := &fakeRecorder{}
|
|
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
|
defer w.Close()
|
|
|
|
d := time.Now().Add(100 * time.Millisecond)
|
|
_ = w.Update(d)
|
|
|
|
// Wait for the warning publish so we know we're inside the warning
|
|
// window, then dismiss before the final timer would fire.
|
|
deadline := time.Now().Add(500 * time.Millisecond)
|
|
for time.Now().Before(deadline) {
|
|
if countWhere(r.snapshot(), event.isWarning) >= 1 {
|
|
break
|
|
}
|
|
time.Sleep(2 * time.Millisecond)
|
|
}
|
|
if countWhere(r.snapshot(), event.isWarning) < 1 {
|
|
t.Fatalf("warning did not publish in time, events=%+v", r.snapshot())
|
|
}
|
|
|
|
w.Dismiss()
|
|
|
|
// Now wait past when the final would have fired.
|
|
time.Sleep(120 * time.Millisecond)
|
|
|
|
if n := countWhere(r.snapshot(), event.isFinalWarning); n != 0 {
|
|
t.Fatalf("final-warning published after Dismiss(), events=%+v", r.snapshot())
|
|
}
|
|
}
|
|
|
|
func TestDismissResetByNewDeadline(t *testing.T) {
|
|
r := &fakeRecorder{}
|
|
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
|
defer w.Close()
|
|
|
|
first := time.Now().Add(100 * time.Millisecond)
|
|
_ = w.Update(first)
|
|
|
|
// Dismiss against the first deadline.
|
|
w.Dismiss()
|
|
|
|
// Replace with a fresh deadline before the first's timers complete.
|
|
time.Sleep(10 * time.Millisecond)
|
|
second := time.Now().Add(100 * time.Millisecond)
|
|
_ = w.Update(second)
|
|
|
|
// The second cycle must publish a final-warning (the dismiss state
|
|
// did not carry over).
|
|
deadline := time.Now().Add(500 * time.Millisecond)
|
|
for time.Now().Before(deadline) {
|
|
if countWhere(r.snapshot(), event.isFinalWarning) >= 1 {
|
|
break
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
if countWhere(r.snapshot(), event.isFinalWarning) < 1 {
|
|
t.Fatalf("final-warning did not publish on fresh deadline after Dismiss reset, events=%+v", r.snapshot())
|
|
}
|
|
}
|
|
|
|
func TestDismissBeforeUpdateIsNoop(t *testing.T) {
|
|
r := &fakeRecorder{}
|
|
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
|
defer w.Close()
|
|
|
|
// No deadline tracked yet; Dismiss must be a no-op (no panic, no state).
|
|
w.Dismiss()
|
|
|
|
d := time.Now().Add(100 * time.Millisecond)
|
|
_ = w.Update(d)
|
|
|
|
// Final warning should still publish — Dismiss only acts on the current
|
|
// deadline, and there was none at the time of the call.
|
|
deadline := time.Now().Add(500 * time.Millisecond)
|
|
for time.Now().Before(deadline) {
|
|
if countWhere(r.snapshot(), event.isFinalWarning) >= 1 {
|
|
return
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
t.Fatalf("final-warning did not publish after no-op pre-Update Dismiss, events=%+v", r.snapshot())
|
|
}
|