Контекст
29 января, день. Утром закрыл безопасность. Теперь — тесты. До этого момента в проекте был ноль тестов. Продукт работал в продакшене месяц вообще без тестов. Звучит страшно, и так и есть. Для solo-разработки на раннем этапе — бывает. Но оставлять так нельзя.
Зачем тесты, если и так работает
Три причины:
1. Уверенность при изменениях. Хочу добавить фичу — и не бояться, что сломал старое.
2. Документация поведения. Тест — это спецификация: «при таких входных данных ожидаю такой результат».
3. Go race detector. В Go есть флаг -race, который находит гонки данных. Но он работает только когда код исполняется — то есть через тесты.
Третий пункт — самый важный. Гонки данных — самые коварные баги. Два потока одновременно обращаются к одним данным, и программа может работать месяцами, а потом упасть в 3 ночи, когда совпали тайминги.
Что нашёл race detector
Запустил go test -race ./... — и посыпалось.
Гонка на LastPing
Одна горутина записывает время последнего пинга, другая читает его для проверки таймаута. Без синхронизации — гонка.
Фикс: atomic.Int64. Атомарные операции — способ работать с данными из нескольких горутин без мьютексов. Быстро и безопасно.
Double-close паника
Клиент закрывается — вызывается Close(). Но если соединение уже рвётся — Close() вызывается снова из другой горутины. Повторное закрытие канала в Go = паника = краш.
Фикс: sync.Once — гарантирует, что код выполнится ровно один раз. Сколько раз ни вызови Close() — закрытие произойдёт только раз.
Утечки горутин
Самое коварное. io.Copy в двунаправленном копировании блокируется, пока не получит EOF или ошибку. Если одна сторона закрылась, а другая нет — горутина висит навечно.
// Было: утечка
go func() { io.Copy(local, stream) }()
go func() { io.Copy(stream, local) }()
// Стало: закрываем обе стороны
go func() {
io.Copy(local, stream)
local.Close() // разблокирует вторую горутину
}()
go func() {
io.Copy(stream, local)
stream.Close()
}()
Каждая незакрытая горутина — утечка памяти. На сервере с сотнями соединений за день — через неделю out of memory.
WaitGroup паника: самый коварный баг
sync.WaitGroup — счётчик горутин. Add(1) — стартовала, Done() — завершилась, Wait() — ждём всех.
Баг: при реконнекте клиент сбрасывал WaitGroup и запускал новое соединение. Но старые горутины ещё работали. Они вызывали Done() на уже сброшенном WaitGroup — паника.
panic: sync: negative WaitGroup counter
Происходило так:
1. Горутина A: wg.Add(1) → работает
2. Реконнект: wg = new WaitGroup (сброс!)
3. Горутина A заканчивает: wg.Done() → Done на новом WG, счётчик = 0 → паника
Фикс: перед реконнектом — cancel context + wg.Wait(). Дождаться завершения ВСЕХ старых горутин, потом начинать заново.
// Было: сброс, пока горутины ещё работают
c.wg = sync.WaitGroup{}
c.Connect()
// Стало: ждём все горутины
c.cancel() // сигнал: пора заканчивать
c.wg.Wait() // ждём
c.Connect() // теперь безопасно
Две строки разницы между «работает» и «падает в 3 ночи».
Что написал
Unit-тесты для протокола, кодека, конфигурации — базовые случаи и границы.
HTTP-хэндлеры — регистрация, логин, refresh, токены, домены, админка. В Go это удобно через httptest.NewRecorder().
Интеграционные тесты — полный цикл: создать пользователя → залогиниться → создать токен → подключить туннель → проверить трафик. Реальный сервер и клиент, запущенные в тесте.
Параллельно настроил публикацию Docker-образа в GitHub Container Registry.
Итог
29 января (день): - 20+ race conditions найдены и исправлены - Утечки горутин закрыты - WaitGroup паника починена - Unit, handler и интеграционные тесты написаны - Docker-образ публикуется в GHCR
30d8075 test: add comprehensive unit tests for core packages
2832875 fix: resolve critical race conditions, goroutine leaks
1d0e177 fix(client): wait for goroutines before reconnect
Мораль: go test -race — ваш лучший друг. Запускайте в CI, каждый раз, без исключений. Он находит баги, которые не увидите ни в каких логах.
В следующей части: выжимаем скорость. sync.Pool, кэш адресов и Prometheus-метрики.