v0.8.8 Socket Pool Salvation 7 мин 27 сентября 2026, 20:10 — 20:45

Акт X: Спасение пула сокетов — 16 000 открытых портов, OpenSSH-баннер и релиз v0.8.8

История внезапного сетевого блэкаута: как фоновый замер RTT-задержки серверов в shellit.exe забил 16 071 порт Windows из-за разницы между socket.close() и socket.destroy(), почему SSH-серверы подвешивают полузакрытые сокеты, оптимизация SQLite-телеметрии и релиз v0.8.8.

BUG-043 BUG-044 #networking #sockets #ephemeral-ports #dart-io #ssh #telemetry #v0.8.8
Стенограмма инцидента: 16 000 открытых сокетов и сетевой блэкаут
27 сентября 2026, 20:02
Развернуть
Архитектор
Что было обнаружено при диагностике: Фоновый процесс shellit.exe (PID 5932) завис в цикле утечки соединений и открыл 16 071 активный TCP-сокет, исчерпав сетевой пул всей системы. Процесс был принудительно остановлен, после чего количество занятых сетевых сокетов мгновенно вернулось в норму (с 16 602 до 533). Причина этого. Просто проведи диагностику и ответь, пока не исправляй.
Antigravity
Диагноз: в pingHost вызывался socket.close(). В Dart это закрывает сокет только на запись (FIN), но OpenSSH-сервер отправляет баннер идентификации. Поскольку сокет не вычитывался и не вызывался socket.destroy(), дескриптор сокета и динамический порт удерживались ядром Windows. Каждые 15 секунд утекал порт, пока не исчерпался диапазон эфемеров Windows (49152-65535, ровно 16384 порта).
Архитектор
давай план. И человеческим языком для чайника объясни, что все это значит
Архитектор
делай
BUG-044 BUG-044: Защита пула сокетов — от socket.close() к socket.destroy()
Анатомия инцидента безопасности
Полузакрытый сокет (Было)
// УЯЗВИМО: socket.close() ждет вычитки буфера
final socket = await Socket.connect(hostname, port, timeout: effectiveTimeout);
stopwatch.stop();
await socket.close(); // FIN отправлен, но баннер OpenSSH висит в ядре!
Гарантированное уничтожение (Стало)
// НАДЕЖНО: socket.destroy() немедленно освобождает порт ядра
Socket? socket;
try {
  socket = await Socket.connect(hostname, port, timeout: effectiveTimeout);
  stopwatch.stop();
  return PingResult.success(latencyMs: stopwatch.elapsedMilliseconds);
} finally {
  socket?.destroy(); // Мгновенный сброс дескриптора и порта
}
Вердикт аудита: OpenSSH при установке TCP-соединения первым делом отправляет строку приветствия. Вызов close() закрывал только передачу, оставляя порт заблокированным в ядре ОС до исчерпания пула Windows. Блок finally с destroy() полностью ликвидировал утечку.
Контекст акта: Внезапный сетевой коллапс рабочей машины: фоновый процесс `shellit.exe` открыл 16 071 активный TCP-сокет, заблокировав сетевой стек всей операционной системы (WSAENOBUFS). Расследование вскрыло коварную ловушку `dart:io` при взаимодействии с демонами OpenSSH: вызов `socket.close()` закрывает поток только на запись, а сервер успевает прислать баннер идентификации. Результатом стало спасение пула портов через `socket.destroy()`, отвязка телеметрии от постоянной записи в SQLite и стабилизация релизов v0.8.7/v0.8.8.

Запись 51. Загадка 404 в GitHub Releases: мостик от v0.8.6 к v0.8.7

⏱️ Время: 26 сентября 2026, 18:20 — 18:35 (~15 минут)

1. Повод и детективная история

История этого акта началась накануне. Едва успев развернуть релиз v0.8.6 с Universal Command Guard, мы решили протестировать функцию «Проверить обновления» прямо в карточке настроек Shellit. Нажимаем кнопку — и получаем невозмутимое сообщение: «No remote releases found. You are running the latest preview build».

Как так? На GitHub релизы лежат, тег v0.8.6 опубликован, а приложение в упор их не замечает.

Второе замечание коснулось веб-летописи: на страницах портала красовалась надпись «8 тематических глав». Но с каждым новым актом менять эту цифру вручную по всем шаблонам и RSS-лентам — верный путь к регулярному рассинхрону.


2. Подводные камни GitHub REST API

Мы полезли в сетевой вызов AboutSettingsCard.releasesApiUrl. Там красовался каноничный URL:
https://api.github.com/repos/kobaltgit/Shellit/releases/latest

Казалось бы, что может быть логичнее для поиска последнего релиза? Но чтение спецификации GitHub REST API преподнесло неожиданный сюрприз:

Эндпоинт /releases/latest возвращает исключительно релизы со статусом Stable. Если все релизы в репозитории имеют флаг Pre-release (а для открытой альфы Shellit все версии v0.* маркируются именно как prerelease), этот эндпоинт всегда возвращает HTTP 404 Not Found!

Получая от сервера код 404, наше приложение честно делало вывод: на удаленном сервере релизов не существует вовсе (BUG-043).


3. Исправление и стабилизация

  1. Честный список релизов (BUG-043):
    Мы заменили эндпоинт на https://api.github.com/repos/kobaltgit/Shellit/releases?per_page=5. Теперь приложение забирает список последних опубликованных релизов, отбрасывает черновики (r['draft'] != true) и берет самый актуальный релиз независимо от того, pre-release он или stable.

  2. Динамические формулировки на сайте:
    В шаблонах Astro (ru/chronicle/index.astro, chronicle/index.astro), RSS-генераторах и llms.txt жесткая цифра «8» заменена на качественное описание («Тематические главы…»). Теперь при добавлении новых глав структура сайта остается вечно актуальной.

  3. Релиз v0.8.7:
    Все изменения зафиксированы, тесты обновлены (7/7 в about_settings_card_test.dart), а версия приложения повышена до v0.8.7.


Запись 52. 16 000 открытых сокетов: как фоновый пинг исчерпал сетевой пул Windows и чему нас научил socket.destroy()

⏱️ Время: 27 сентября 2026, 20:10 — 20:45 (~35 минут)

1. Повод и тревожный звонок из системного монитора

Всё началось с внезапного сетевого блэкаута. На рабочей машине перестали открываться сайты, браузеры выдавали сетевые сбои, веб-сервисы падали по таймауту, а системный монитор рапортовал об аномалии:

Фоновый процесс shellit.exe (PID 5932) завис в цикле утечки соединений и открыл ровно 16 071 активный TCP-сокет, намертво исчерпав сетевой пул всей операционной системы. Стоило принудительно остановить процесс — и число занятых сетевых сокетов мгновенно вернулось в норму (с 16 602 до 533).

Число 16 071 сразу бросилось в глаза любому системному инженеру:

  • По умолчанию в Windows динамический диапазон клиентских портов (эфемеров) — от 49152 до 65535.
  • Это дает ровно 16 384 порта.
  • За вычетом системных служб Shellit съел ровно 100% оставшихся портов Windows, вызвав отказ WSAENOBUFS (No buffer space available) для всех приложений операционной системы.

2. Подводные камни: коварство полузакрытых сокетов в Dart (dart:io)

Диагностика сетевого стека вскрыла тончайший нюанс взаимодействия среды Dart с сетевым протоколом OpenSSH:

  1. Разница между close() и destroy():
    В сервисе SshClientService.pingHost измерялась RTT-задержка до хостов. При проверке создавался сокет Socket.connect(hostname, port), после чего вызывался await socket.close().
    Однако в Dart метод socket.close() закрывает сокет только на передачу (shutdown(SHUT_WR)), отправляя удаленному серверу TCP FIN. Поток чтения при этом остается открытым в ожидании, пока удаленная сторона закроет свой конец.

  2. Приветствие OpenSSH:
    При открытии порта 22 любой стандартный SSH-сервер первым делом отправляет строку идентификации версии протокола (SSH-2.0-OpenSSH...). Этот баннер прилетал в сетевой стек Windows. Поскольку Shellit данные не вычитывал и не вызывал socket.destroy(), сокет переходил в полузакрытое состояние с непрочитанными байтами в буфере. Среда Dart VM и ядро Windows удерживали дескриптор сокета и его динамический порт.

  3. Фоновый таймер телеметрии:
    Монитор пинга каждые 15 секунд честно опрашивал серверы из каталога. Каждый опрос оставлял в ядре «подвешенную трубку». За несколько часов работы в фоне эти сокеты монотонно копились, пока не вычерпали весь системный лимит портов до последней капли.

  4. Реактивный шум в базе данных:
    Каждый замер пинга заодно записывал задержку в SQLite-таблицу hostsTable через Drift. Это вызывало постоянный триггер реактивного стрима watchAllHosts(), провоцируя бесконечные перерисовки виджетов интерфейса.


3. Исправление и архитектурная стабилизация (BUG-044)

Для ликвидации дефекта мы применили трехуровневую защиту:

1. Гарантированный socket.destroy()

В методе SshClientService.pingHost вызов полузакрытия заменен на блок finally:

Socket? socket;
try {
socket = await Socket.connect(hostname, port, timeout: effectiveTimeout);
stopwatch.stop();
return PingResult.success(latencyMs: stopwatch.elapsedMilliseconds);
} finally {
socket?.destroy();
}

Метод destroy() моментально обрывает соединение в обе стороны (RST), очищает буферы ядра и немедленно возвращает системный порт Windows в пул доступных портов.

2. Защита терминала и SFTP от утечек при ошибках аутентификации

В методы createTerminalSession и openSftpSession добавлен аналогичный блок finally: если аутентификация сорвалась, ключ отклонен или произошел таймаут рукопожатия, client?.close() и socket?.destroy() гарантированно вызываются до выхода из метода.

3. Разгрузка SQLite и UI-потока

В HostsNotifier.updateHostLatency добавлен флаг persist: false. Текущая RTT-задержка теперь обновляется в оперативной памяти виджетов мгновенно без записи на диск и без паразитных событий в реактивном стриме базы данных Drift.


4. Итог: Релиз v0.8.8

  • Устранен инцидент BUG-044 (P0 Blocker исчерпания сетевого пула Windows).
  • Написан регрессионный юнит-тест с эмуляцией передачи OpenSSH-баннера (ssh_client_service_test.dart).
  • Все 430+ тестов монорепозитория завершились со 100% успехом:
    • core_foundation: 27/27 успешно.
    • ssh_network_core: 81/81 успешно.
    • terminal_ui: 126/126 успешно.
    • desktop_plugin_sdk: 62/62 успешно.
    • apps/shellit: 61/61 успешно.
  • Статический анализ flutter analyze — абсолютный ноль замечаний.
  • Версия программы повышена до v0.8.8.

Фоновый мониторинг серверов в Shellit теперь работает абсолютно бесшумно, бережно относясь к ресурсам операционной системы и защищая сетевой стек от любых утечек.

📖 Типографика статьи выровнена по алгоритму Кнута — Пласса с помощью mcp-typography-server на базе Justif.
Knuth-Plass • TeX Style