From e98d51fe7695ec9729bcf1fd72f293f268dfa6e3 Mon Sep 17 00:00:00 2001 From: byrsapty Date: Thu, 27 Aug 2026 18:11:23 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A2=D1=80=D0=B0=D0=BF=D0=B8:=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D0=B9=D0=BC=D0=B0=D1=87=20=D0=BA=D0=B0=D0=B6=D0=B5=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=20=D0=B3=D0=BE=D1=82=D0=BE=D0=B2=D0=BD=D1=96?= =?UTF-8?q?=D1=81=D1=82=D1=8C=20=D1=82=D0=BE=D0=B4=D1=96,=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BB=D0=B8=20=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B4=D1=96=20?= =?UTF-8?q?=D0=B3=D0=BE=D1=82=D0=BE=D0=B2=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Перший прогін CI завалив роботу agent на TestInformIsAcknowledged: «connection refused» від власного слухача. Локально той самий тест проходив — тобто плаваючий, а плаваючий тест отруює CI сильніше за відсутність CI: він привчає не дивитись на червоне. Причина — гонка в тесті. Run піднімається в горутині, а відправник шле одразу, не чекаючи. Між стартом горутини й зайняттям сокета є вікно, і датаграма, що в нього потрапила, отримує від ядра «port unreachable». На вільній машині вікно програє гонку майже завжди, під навантаженням раннера — виграє. Заодно знайшлось гірше, і вже не в тесті: рядок «приймач трапів слухає» друкувався ПЕРЕД tl.Listen(). Тобто журнал стверджував успіх до спроби, і навіть тоді, коли порт зайняти не вдалося, — а для 162 це саме той випадок, коли приймач, який мовчить, виглядає точнісінько як спокійна мережа. Тепер повідомлення йде після фактичного зайняття, і поруч з'явився Listening(): канал, який закривається тоді ж. Перевірено 20 прогонів поспіль — зелено. І окремо варте уваги: цю ваду не спіймала б жодна з наших перевірок, окрім справжнього CI під навантаженням. Він виправдав себе на першому ж запуску. --- agent/internal/modules/traps/receiver.go | 35 +++++++++++++++++++++- agent/internal/modules/traps/traps_test.go | 14 +++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) 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)),