fxTunnel

· Часть 7 серии «Пишу свой ngrok на Go: дневник разработки»

Пишу свой ngrok на Go: тесты, гонки и паники

Утром закрыл безопасность. Теперь — тесты. До этого момента в проекте был ноль тестов. Продукт работал в продакшене месяц вообще без тестов.

Контекст

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
// Было: утечка
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(). Дождаться завершения ВСЕХ старых горутин, потом начинать заново.

go
// Было: сброс, пока горутины ещё работают
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-метрики.

Все части дневника разработки