Files
lpra/docs/plans/tunnel-flow-control.md

6.5 KiB

План: управление потоком в прямом туннеле (tcp-проброс)

Статус: отложено - сделать, если на практике упрёмся. Записано 2026-10-16.

Что сейчас

Прямой туннель (:8446, режим проброса tcp) - одно TCP-соединение на агента, по нему мультиплексируются все проброшенные соединения этого агента кадрами type(1) | stream(4) | length(4) | payload (internal/tunnel/frame.go).

Ёмкости хватает с запасом: на агента - одно соединение и одна горутина чтения, на проброшенное соединение - горутина и буфер 32 КБ; шифрования в туннеле нет. 20 агентов и тысячи соединений - десятки МБ памяти и почти ноль CPU. Потолок - сеть хоста (1 Гбит/с), через которую весь трафик идёт дважды (пользователь ↔ хост ↔ агент).

Проблема: блокировка всего туннеля агента одним соединением

Управления потоком (окон, как в HTTP/2 или yamux) нет, поэтому внутри одного агента медленное соединение тормозит остальные:

  • Хост (internal/server/tunnelserver.go, agentTunnel.readLoop): кадр TypeData пишется пользователю синхронно, c.Write(f.Payload), без дедлайна. Клиент, который медленно читает или завис, останавливает чтение туннеля - встают все пробросы этого агента, пока клиент не отвалится.
  • Агент (internal/agent/tunnel.go, tunClient.readLoop): данные кладутся в канал потока in (256 кусков, до ~8 МБ). Если локальный сервис не читает и канал полон, readLoop блокируется на s.in <- f.Payload - встаёт весь туннель агента.

Между агентами влияния нет: у каждого свой readLoop.

Когда проявится: через одного агента одновременно большая отдача медленному клиенту и что-то интерактивное (SSH, веб) - интерактивное подвисает. Худший случай - зависший клиент держит туннель агента до разрыва TCP.

Предлагаемое решение

  1. Своя очередь записи у каждого потока - на хосте и на агенте. readLoop только кладёт кадр в очередь потока и никогда не пишет в сокет сам; пишет отдельная горутина потока.
  2. Окно (кредиты) на поток. Новый тип кадра TypeWindow (stream, uint32 байт): получатель сообщает, сколько ещё байт готов принять. Отправитель не шлёт TypeData сверх окна. Начальное окно - 256 КБ; получатель возвращает кредит по мере записи в сокет (например, когда записана половина окна). Так очередь потока ограничена окном, а медленный поток тормозит только себя.
  3. Дедлайны записи. Запись в пользовательский или локальный сокет - с SetWriteDeadline (например, 60 с без прогресса). Не успел - поток закрывается (TypeClose), туннель живёт.
  4. Справедливость отправки. Отправка в туннель уже сериализована (wmu); кадры данных ограничить 32 КБ (сейчас так и есть), чтобы большой поток не занимал канал надолго.

Совместимость со старыми агентами

Рукопожатие LPRA-TUN1 не меняем: старый агент отвергает другое приветствие, а старый сервер - ответ не из двух полей. Вместо этого:

  • сервер знает версию агента (из БД, versionAtLeast) и, если агент новый, сразу после рукопожатия шлёт кадр TypeHello (флаги: flowControl);
  • агент включает окна, только получив TypeHello. Старый сервер его не шлёт - новый агент работает с ним по-старому. Старому агенту сервер TypeHello не шлёт и окна не включает (а пришедший неизвестный кадр старый агент и так пропустил бы: в его readLoop нет default).

Новые типы кадров: TypeHello = 5, TypeWindow = 6.

Тесты

  • Медленный читатель на одном потоке не задерживает другой поток того же агента (замер задержки эха на втором потоке при забитом первом).
  • Зависший клиент закрывается по дедлайну, туннель и остальные потоки живы.
  • Новый сервер + старый агент и старый сервер + новый агент - проброс работает, как раньше.
  • Пропускная способность одного потока не падает заметно (окно 256 КБ на RTT до 50 мс даёт ~40 Мбит/с на поток; если мало - окно больше или автоподстройка).

Объём

Сервер: tunnelserver.go (~150 строк), агент: tunnel.go (~150 строк), frame.go (типы), тесты. Нужна новая версия агента; панель после обновления помечает старых как устаревших.