Proxy: исправления и проверка архитектуры Дата: 2026-10-24. Статус: реализовано и проверено на Linux. Ниже сохранён согласованный проект; фактические изменения и результаты — в конце. Подтверждённые факты до исправлений 1. udp_proxy.c не проверяет bind/sendto, результат socket_set_nonblocking, часть выделений/регистраций/таймеров и отправку ответа через router. Неудачный запуск flow может оставлять ресурс без рабочего таймера очистки. 2. server_init и client_create оба запускают общие UDP/ICMP-контексты. client_destroy сохраняет их по server.enabled, server_destroy уничтожает всегда. Простой rollback через destroy одного модуля не выражает владение общими ресурсами. 3. ICMP init публикует контекст и сообщает успех без raw socket. При этом тот же контекст нужен для клиентских REPLY: отсутствие возможности exit не означает, что клиентская сторона ICMP не может работать через другой exit. 4. В диагностическом прогоне tcp_io error_cb при HUP отменяет read_waiter, включает чтение и напрямую вызывает read_cb вопреки read_paused. Для fd=15 порог чтения 4; журнал фиксирует read_paused=1 и рост rq до 53. Это нарушение ограничения очереди, а не только избыточная диагностика. 5. EPIPE/ENOTCONN в HTTPS CONNECT воспроизведены. Системная трасса четырёх TLS-потоков: recv(destination)=0 -> поздняя TLS-запись 24 байта от клиента -> send(destination)=24 -> SO_ERROR=EPIPE либо shutdown(SHUT_WR)=ENOTCONN. Повторный shutdown не обнаружен. Определение содержимого зашифрованной TLS-записи по трассе невозможно. Сам по себе этот сценарий не доказывает дефект half-close в proxy. 6. После удаления exit-потока приходят поздние WINDOW: диспетчер пишет unhandled, хотя это известная команда уже завершённого потока. Создавать поток по WINDOW нельзя. 7. enabled=yes в серверных тестовых конфигах не поддержан парсером. Сейчас сервер включается при обработке любого параметра секции, включая неизвестный. Предлагаемая архитектура Владелец общих UDP/ICMP-контекстов — UTUN_INSTANCE. Их жизненный цикл координируется в utun_instance.c после router_init и до router_destroy. TCP client/server управляют только своими listeners, TCP-потоками и bindings. Общих счётчиков ссылок не требуется: при существующем lifecycle они живут до остановки instance. Контексты нужны при включённом exit или клиентском TUN; SOCKS/HTTP без TUN их не требуют. server.enabled обозначает действующий TCP exit. Роль при запуске ICMP exit должна передаваться явно, а не определяться случайным порядком вызовов init. При отказе создания клиента rollback освобождает только его ресурсы и собственный binding. Повторное создание клиента/сервера поверх активного состояния отклоняется. Результат client_create публикуется в inst->tcp_proxy_client только после успеха. Порядок остановки: клиент и exit-потоки -> общие UDP/ICMP -> router -> drain deferred free. При destroy всего instance остановку proxy перенести перед остановкой UTUN-группы, разборкой topo_groups и ETCP-сокетов/соединений. Сейчас proxy уничтожается в phase M, после topo_groups и транспорта: для нового rollback нельзя сохранять этот поздний порядок. Общий instance_init_common уже вызывает instance_destroy при отказе создания; нужно использовать этот путь, не добавляя параллельную систему частичной очистки. При create_from_config сохранить существующее правило: отказ возвращает config вызывающему коду. Ресурсы закрываются в одном uasync-потоке; stop/destroy не вызывается из активного callback уничтожаемого владельца. Таймеры/waiters отменяются до освобождения памяти. Инициализация и ICMP Разделить создание общего ICMP-контекста/регистрацию REPLY и запуск raw exit socket. Это две разные операции: при отказе raw нельзя уничтожать работающую клиентскую сторону. Например, icmp_proxy_init создаёт общий контекст, отдельный enable_exit открывает raw socket и регистрирует чтение. Новый raw socket публикуется только после полного успеха. Обе операции возвращают ошибки непосредственно своему вызывающему коду. Рекомендуемая политика: EPERM/EACCES/отсутствие поддержки raw явно отключают только ICMP exit, с WARN о причине и итогом запуска tcp=on/udp=on/icmp_exit=off. TCP/UDP продолжают работать. Ошибки выделения/регистрации обязательных ресурсов прерывают запуск proxy с полной очисткой. Если продукт требует обязательного ICMP, отсутствие raw должно быть фатальным на уровне конфигурации; это отдельная политика, а не скрытый fallback внутри init. В production виртуальные echo-ответы недопустимы. Тестовый loopback выбирается явно и должен работать одинаково с правами raw и без них. Заданные SOCKS/HTTP listeners обязательны: enabled с пустым/неверным адресом либо неудачный bind/listen проваливают create. Нельзя вернуть готовый клиент без выбранного входа. Проверки router bind оставляем, но при живом inst нынешний bind не выделяет память и возвращает 0; не нужно изображать реальные OOM-пути там, где их нет. UDP и ограниченные ресурсы Новый flow сначала полностью создаётся локально: socket -> nonblocking -> bind -> read registration -> обеспечение expire timer. Только затем добавляется в список. Любой отказ снимает уже созданные регистрации/таймеры и закрывает именно новый сокет. Если общий таймер не удаётся продлить, оставшиеся flows закрываются с диагностикой: состояние без гарантированной очистки не сохраняется. sendto проверяется по точному размеру. Для нулевой датаграммы n=0 означает успех. EINTR допускает повтор операции; EAGAIN/ENOBUFS означает явное отбрасывание датаграммы с диагностикой. Не добавляем неограниченную очередь и TCP-подобные повторы UDP. Остальные ошибки также логируются с peer, tuple, длиной и errno; packet-specific ошибка не должна автоматически уничтожать рабочий flow. Невалидный/сломанный сокет удаляется. Проверяются ошибки router send, выделения ответов и доставки в TUN. ICMP request тоже не публикуется без рабочего механизма истечения срока ожидания. TCP HUP, FIN и границы ответственности FIN относится только к одному направлению и следует после его DATA. Ошибка реальной записи остаётся ошибкой даже после FIN противоположной стороны; ENOTCONN не превращается в успешный on_fin_sent. Существующая регрессия test_fin_shutdown_error сохраняет этот контракт. HUP — повод обработать завершение сокета, но не разрешение обойти high_water или явную pause_read. При достижении high_water чтение останавливается до low_water. Если level-triggered HUP не даёт event loop уснуть, регистрацию сокета нужно временно снять и восстановить по queue waiter; простой return с оставленным HUP в epoll/poll создаёт busy loop. Нужен один механизм восстановления регистрации в tcp_io, учитывающий connected, fin_remote, write_buf, write_queue и уничтожение владельца. Состояние fd/пулы сохраняются до окончательного destroy; запланированная запись не должна зависеть от паузы чтения. Повторное добавление использует новую generation uasync, старые события игнорируются. Явную паузу владельца следует отличать от паузы по high_water; HUP не снимает ни одну. Это наиболее чувствительный этап: перед реализацией проверить переходы регистрации при HUP с ожидающей записью, low_water, обеих FIN и destroy из callback. Не менять глобальную семантику всех регистраций uasync ради одного tcp_io-сценария. Поздние WINDOW/FIN/CLOSE/ERROR для отсутствующего потока игнорируются как события завершённого sid с debug-диагностикой. DATA не воскрешает поток, неверное окно живого потока по-прежнему является ошибкой. HTTP не обязан ждать FIN для ответа с известной длиной. Проверить синхронный loopback: состояние перед отправкой control резервируется заранее; после callback с возможным destroy нельзя обращаться к владельцу без проверки его жизни. HTTPS-тест разделить на нормальное двустороннее TLS shutdown и явный abrupt close. В первом случае ожидать отсутствие EPIPE/ENOTCONN; во втором — корректную ошибку, сохранность уже доставленного ответа и освобождение ресурсов. Proxy не разбирает TLS. Сначала проверить эту гипотезу управляемым тестовым сервером, затем менять fixture. Проверка после реализации - Fault injection: socket/nonblocking/bind/read registration/expire timer/sendto/OOM. Проверять отсутствие fd/памяти/таймеров/handlers после каждого отказа и успешный повторный запуск. - client only, exit only, обе роли на одном instance, два независимых instance; отказ raw сохраняет ICMP REPLY клиента, остановка одного TCP-модуля не ломает общие контексты. - UDP payload=0, краткая/ошибочная отправка, transient error и следующий успешный пакет. - HUP при полном proxy window и остановленном consumer: очередь остаётся ограниченной, event loop не крутится, после снятия блокировки все байты и FIN доходят в правильном порядке. - pending write при HUP, manual pause, low_water/re-register, destroy в callback и stale events. - FIN в обе стороны при backpressure, RST до/после ответа, нормальный/оборванный TLS, поздний WINDOW, синхронный loopback и HTTP keep-alive со сменой sid. - Текущие proxy9, test_tcp_io, тесты uasync/queue, затем ASAN/UBSan и полный check.sh. Windows/FreeBSD требуют отдельной проверки poll/select; Linux PASS её не заменяет. Доказательства исходного анализа Диагностический test_socks_http_proxy под gdb: PASS, 16 byte-exact загрузок. Включены существующие proxy/socket debug через gdb, проектные файлы не менялись. Полный журнал: /tmp/proxy-design-debug.log; события lifecycle: /tmp/proxy-design-lifecycle.log. Системные вызовы TLS: /tmp/proxy-design-close.744427, сводка четырёх соединений: /tmp/proxy-design-close-summary.txt. Эти журналы относятся к реализации до исправлений. Реализация и проверка архитектуры 1. UTUN_INSTANCE единолично создаёт и уничтожает общие UDP/ICMP-контексты. TCP-модули владеют только своими bindings/listeners/потоками. Их rollback не затрагивает общие контексты. Занятой binding не перезаписывается, повторный init TCP отклоняется. Proxy закрывается и deferred free обрабатываются до остановки групп/транспорта. 2. Все заданные listeners и TUN mappings обязательны. Частичный отказ возвращает NULL и освобождает созданные ресурсы; create_from_config сохраняет config у вызывающего. 3. ICMP init создаёт контекст/binding, enable_exit атомарно создаёт raw socket/регистрацию. Отсутствие прав/поддержки отключает только ICMP exit с явным WARN. Клиентские REPLY сохраняются; остальные ошибки запуска фатальны. Test loopback включается явно. 4. UDP flow публикуется после всех обязательных шагов. Отказы создания/продления таймера не оставляют бессрочных flows. Проверяются socket/nonblocking/bind/registration, sendto, ответные выделения/router/TUN. EINTR повторяется, нулевая датаграмма допустима; ошибки пакета логируются без очереди повторов, сломанный сокет закрывается. 5. TCP HUP сохраняет high/low-water и ручную остановку чтения. Без доступного I/O регистрация временно снимается; low_water восстанавливает её с новой generation. fd/очереди сохраняются; запись обслуживается независимо. Ошибка восстановления регистрации корректно вызывает on_error, включая destroy владельца из callback. 6. Поздний WINDOW отсутствующего sid отбрасывается с DEBUG. Формат протокола и машина CONNECTED/WINDOW/FIN сохранены. Реальные ошибки записи/SHUT_WR не превращаются в успех. HTTPS fixture теперь завершает TLS двусторонне; abrupt close — отдельный режим. 7. Неподдержанный enabled в серверных конфигурациях профильных и TUN-тестов заменён поддержанным connect_timeout. Контракты заголовков и документы модулей обновлены. Проверено: - test_proxy_lifecycle: 25/25 изолированных сценариев, контроль fd/памяти/регистраций/таймеров. Покрыты socket/nonblock/bind/registration/timer/OOM, отправка/EINTR/нулевой UDP, частичный отказ instance, повторный запуск, ICMP REPLY после EPERM, HUP re-register, ожидающая запись и destroy из error callback. /tmp/proxy-fix-lifecycle2.log. - test_tcp_io: 19/19, HUP high_water=4 на epoll/poll, парковка без busy loop, точная доставка данных и FIN после low_water, ручная пауза и независимая запись. - SOCKS/HTTP/HTTPS: оба режима по 16 byte-exact загрузок. Штатный TLS shutdown без EPIPE/ENOTCONN; abrupt сохраняет ошибки TCP без повреждения доставленных ответов. /tmp/proxy-fix-check1/test_socks_http_proxy.log, /tmp/proxy-fix-tls-abrupt.log. - Основной make check: 185/185 PASS. /tmp/proxy-fix-full-check2.log. Первый check.sh остановлен общим лимитом 141с; local_transport_packets отдельно PASS. Повтор завершил основной набор, но TUN-этапу помешал чужой процесс на UDP 15002. - Те же TUN fixtures и идентичный src/utun выполнены в отдельном network namespace: proxy9/9, burst1/1, load8/8 PASS; load проверил134217728 байт. /tmp/proxy-fix-network-check.log; копия fixtures/логов: /tmp/utun-proxy-fix-network/. - Настоящий raw ICMP echo до127.0.0.1 через два ETCP-instance и явный loopback PASS. UTUN_TEST_ICMP_RAW=1 требует raw socket и не переключается на виртуальный ответ. /tmp/proxy-fix-icmp-raw.log, /tmp/proxy-fix-icmp-loopback.log. - ASAN/UBSan/LSan: 16/16 PASS с detect_leaks=1, включая оба TLS-режима, lifecycle, tcp_io/flush, пакеты, regressions, HTTP, TCP/UDP/ICMP, services, uasync/queue и STCP. Настоящий raw ICMP дополнительно PASS под теми же санитайзерами с root. Все встроенные зависимости собраны с sanitizer-флагами в отдельной копии текущих исходников /tmp/utun-proxy-fix-sanitizers; обычные объекты проекта не менялись. /tmp/proxy-fix-sanitizer-results.log, /tmp/proxy-fix-sanitizer-icmp-raw.log. В проверенных Linux-сценариях противоречий владения/rollback/half-close не выявлено. Сборка и runtime Windows/FreeBSD не проверялись; Linux epoll/poll не заменяют эту проверку.