Трапи: приймач каже про готовність тоді, коли справді готовий
All checks were successful
CI / hygiene (push) Successful in 8s
CI / web (push) Successful in 44s
CI / server (push) Successful in 1m18s
CI / agent (push) Successful in 38s

Перший прогін CI завалив роботу agent на TestInformIsAcknowledged:
«connection refused» від власного слухача. Локально той самий тест
проходив — тобто плаваючий, а плаваючий тест отруює CI сильніше за
відсутність CI: він привчає не дивитись на червоне.

Причина — гонка в тесті. Run піднімається в горутині, а відправник шле
одразу, не чекаючи. Між стартом горутини й зайняттям сокета є вікно, і
датаграма, що в нього потрапила, отримує від ядра «port unreachable».
На вільній машині вікно програє гонку майже завжди, під навантаженням
раннера — виграє.

Заодно знайшлось гірше, і вже не в тесті: рядок «приймач трапів слухає»
друкувався ПЕРЕД tl.Listen(). Тобто журнал стверджував успіх до спроби,
і навіть тоді, коли порт зайняти не вдалося, — а для 162 це саме той
випадок, коли приймач, який мовчить, виглядає точнісінько як спокійна
мережа. Тепер повідомлення йде після фактичного зайняття, і поруч
з'явився Listening(): канал, який закривається тоді ж.

Перевірено 20 прогонів поспіль — зелено. І окремо варте уваги: цю ваду
не спіймала б жодна з наших перевірок, окрім справжнього CI під
навантаженням. Він виправдав себе на першому ж запуску.
This commit is contained in:
byrsapty 2026-08-27 18:11:23 +03:00
parent 4b77c14168
commit e98d51fe76
2 changed files with 48 additions and 1 deletions

View file

@ -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, щоб приймач можна було перевірити без сокета.

View file

@ -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)),