diff --git a/agent/internal/modules/traps/receiver.go b/agent/internal/modules/traps/receiver.go index f777430..196eb09 100644 --- a/agent/internal/modules/traps/receiver.go +++ b/agent/internal/modules/traps/receiver.go @@ -95,6 +95,10 @@ type Receiver struct { unknownSource atomic.Uint64 notify chan struct{} + + // listening закривається, коли сокет зайнято. Окремо від notify: + // той про події в черзі, цей — про готовність приймати їх узагалі. + listening chan struct{} } // New створює приймач. Порожня адреса означає DefaultAddr. @@ -107,6 +111,8 @@ func New(addr string, log *slog.Logger) *Receiver { log: log, buckets: map[string]*bucket{}, notify: make(chan struct{}, 1), + + listening: make(chan struct{}), } r.perSource.Store(defaultPerSource) return r @@ -203,7 +209,24 @@ func (r *Receiver) Run(ctx context.Context) error { tl.Close() }() - r.log.Info("приймач трапів слухає", "адреса", r.addr) + // Про готовність повідомляємо ПІСЛЯ того, як сокет справді зайнято, + // а не перед спробою. Раніше рядок «приймач трапів слухає» друкувався + // до Listen — тобто журнал стверджував успіх навіть тоді, коли порт + // зайняти не вдалося, і єдиний слід поразки губився серед рядків про + // вдалий старт. Для 162 це особливо погано: приймач, який не слухає, + // виглядає точнісінько як спокійна мережа. + go func() { + select { + case <-tl.Listening(): + r.log.Info("приймач трапів слухає", "адреса", r.addr) + close(r.listening) + case <-ctx.Done(): + // Listen так і не піднявся — канал лишається відкритим, і + // той, хто на нього чекає, побачить це як зупинку, а не як + // готовність. + } + }() + if err := tl.Listen(r.addr); err != nil { if ctx.Err() != nil { return nil @@ -213,6 +236,16 @@ func (r *Receiver) Run(ctx context.Context) error { return nil } +// Listening закривається, коли сокет справді зайнято. +// +// Потрібне тим, хто шле одразу після старту: між викликом Run і +// зайняттям порту є вікно, і датаграма, що потрапила в нього, не +// губиться тихо — ядро відповідає «port unreachable». У тесті це +// виглядало як плаваюча помилка «connection refused» раз на кілька +// прогонів, тобто найгірший різновид: на вільній машині зелено, під +// навантаженням CI червоно. +func (r *Receiver) Listening() <-chan struct{} { return r.listening } + // Handle розбирає й кладе в чергу один трап. // // Окремо від Run, щоб приймач можна було перевірити без сокета. diff --git a/agent/internal/modules/traps/traps_test.go b/agent/internal/modules/traps/traps_test.go index 2ad24eb..08bfbd5 100644 --- a/agent/internal/modules/traps/traps_test.go +++ b/agent/internal/modules/traps/traps_test.go @@ -353,6 +353,20 @@ func TestInformIsAcknowledged(t *testing.T) { done := make(chan error, 1) go func() { done <- r.Run(ctx) }() + // Чекаємо, доки сокет справді зайнято. + // + // Без цього тест плаває: між стартом горутини й зайняттям порту є + // вікно, і датаграма, що в нього потрапила, отримує від ядра + // «port unreachable» — у вигляді «connection refused» на боці + // відправника. На вільній машині вікно програє гонку майже завжди, + // під навантаженням CI — виграє. Саме так це й знайшлось: тест був + // зелений локально й червоний у першому ж прогоні на раннері. + select { + case <-r.Listening(): + case <-time.After(5 * time.Second): + t.Fatal("приймач не зайняв порт за 5 с") + } + sender := &gosnmp.GoSNMP{ Target: "127.0.0.1", Port: uint16(portOf(t, addr)),