# Разбор кода перед сборкой `1.0.0-rc3` ```text Findings base: 6d1686b8 — дерево, на котором найдены дефекты Fixes verified in: 8dcb50a — дерево, на котором проверены исправления ``` Две базы названы отдельно намеренно: сам разбор шёл по первой, а выводы исправлений проверялись по второй, и без этой пары читателю приходилось гадать, к какому состоянию относится каждое утверждение отчёта. Источник — не прогон на хосте, а разбор дерева на коммите `6d1686b8` и повторная сверка Hysteria-интеграции с официальной документацией Hysteria 2. Разбор проводился по состоянию **после** предыдущего прохода ([2026-09-01-v1.0.0-rc2-preflight-findings.md](2026-09-01-v1.0.0-rc2-preflight-findings.md)), то есть проверялись выводы уже принятых исправлений, а не исходное состояние. Тема прохода — **вторая попытка**. Предыдущий проход сделал правильным порядок «сначала долговременная запись, потом разрыв сессии» и правильно запретил откат при неудаче разрыва. Но способа прийти к согласованному состоянию **потом** он не дал: у двух операций повтор не работал вовсе, и их частичный результат оставался навсегда. Проверить на живом сервере ещё предстоит: список ручных проверок — в конце документа. ## Сводка | ID | Дефект | Приоритет | Статус | | --- | --- | --- | --- | | ADMIT-04 | Устаревший снимок `/online` возвращал уже занятое место | P0 | закрыт | | RECON-01 | Живая сессия без строки в базе не завершалась никогда | P0 | закрыт | | RECON-02 | Снижение `maxDevices` после неудачного `/kick` не имело второй попытки | P1 | закрыт | | ADMIT-05 | Учёт выданных разрешений рос неограниченно | P2 | закрыт | | GATE-02 | Гейты приёмки не покрывали ни одного из новых инвариантов | P1 | закрыт | | DOC-02 | Два места документации описывали снятую архитектуру | P3 | закрыт | | VER-01 | `GO_VERSION` отставала на patch-релиз | P3 | закрыт | ADMIT-04 и RECON-01 найдены внешним разбором; RECON-02, ADMIT-05, GATE-02 и вторая половина DOC-02 — при проверке его выводов по коду. --- ## ADMIT-04 — устаревший снимок `/online` возвращал уже занятое место **Наблюдалось только рассуждением:** тестами это состояние не воспроизводилось, а `go test -race` был и остаётся зелёным. Предыдущий проход закрыл сравнение двух ОДИНАКОВЫХ снимков: появился process-local учёт выданных, но ещё не проявившихся разрешений (`apps/service/peer_admission.go`). Сетевой запрос при этом по-прежнему выполнялся **вне** блокировки — сознательно, чтобы не сериализовать подключения всех пиров через один HTTP-обмен, — и это оставило вторую половину гонки открытой: снимки приходили в резервацию в произвольном порядке. ```text 1. A получает разрешение при /online = 0; pending = [A], lastOnline = 0 2. B читает /online = 0 и задерживается на обратном пути 3. A подключается — Hysteria показывает /online = 1 4. C читает /online = 1 и первым входит в резервацию: online > lastOnline -> разрешение A признано проявившимся lastOnline = 1, C получает отказ (верно) 5. B входит со своим устаревшим online = 0 6. `online > lastOnline` ложно; следом безусловное lastOnline = online 7. lastOnline = 0, pending пуст -> 0 < 1 -> B ДОПУЩЕН ``` При `maxDevices = 1` подключений становится два — ровно тем способом, от которого лимит и должен защищать. **Это не data race.** Все обращения к памяти корректно защищены мьютексом, поэтому детектор гонок здесь молчит принципиально, и «`-race` зелёный» не является свидетельством. Доказать свойство может только семантический тест. **Исправление.** Последовательность «прочитать `/online` → занять место» выполняется под замком **по `authId`** (`lockPeerAdmission`). Не глобальный мьютекс вокруг сети: внутри замка идёт HTTP-обмен, и общий замок выстроил бы подключения всех пиров в одну очередь. Конкурируют только авторизации одного пира, а их упорядоченность и есть требуемое свойство — снимок, прочитанный под замком, не может оказаться старше уже обработанного. Время удержания ограничено сверху таймаутом обращения к Traffic Stats API (3 с). Учёт разрешений при этом остаётся нужен: сериализация не устраняет задержку между ответом `allow` и появлением клиента в `/online`. Механизмы закрывают разные половины и работают вместе. Карта замков не растёт: запись живёт ровно столько, сколько есть желающие её взять (счётчик ссылок). **Закреплено:** `TestHysteria2AuthRejectsStaleOnlineSnapshot` — воспроизводит последовательность выше через удержание конкретного ответа `/online`; проверено, что тест падает на коде без замка. Плюс `TestPeerAdmissionGate*` — исключение одного `authId`, отсутствие сериализации разных, отсутствие утечки записей. --- ## RECON-01 — живая сессия без строки в базе не завершалась никогда **Корневая причина — форма обхода в cron:** ```go peers, err := dao.ListPeer("auth_id in ?", chunk) for _, peer := range peers { ... } ``` Обход шёл по НАЙДЕННЫМ строкам, поэтому `authId`, которому в базе ничего не соответствует, молча выпадал. А именно он и остаётся единственным следом сессии после неудавшегося второго шага: ```text DB: old-auth импорт old-auth -> new-auth, COMMIT прошёл /kick old-auth -> 500 ``` Повторить операцию в этом состоянии **невозможно**: `applyPeerImportEntry` читает `replaced := authIDOf(existing)`, то есть повтор того же файла найдёт в базе уже `new-auth` и разорвёт ЕГО. Старое значение после первой неудачи не хранится нигде. Восстановить состояние переподключением тоже нельзя — авторизация нового значения не знает, — а старая QUIC-сессия живёт своей жизнью сколь угодно долго. То же самое даёт удаление пира, у которого не удался `/kick` и следом всё-таки прошло удаление строки. Это ровно тот класс stale session, от которого весь предыдущий проход и должен был защитить. **Исправление.** `enforcePeerAccess` обходит каждый `authId` из `/online`, а не строки выборки. Идентификатор без строки в базе — orphan-сессия, и правильный исход у неё тот же, что и у первой попытки: `/kick`. **Отдельно проверено, что отказ базы не рвёт сессии.** «Пира нет» и «прочитать не удалось» — разные ответы; трактовка второго как первого отключила бы всех подключённых пиров сразу при недоступной SQLite. Ошибка выборки прекращает цикл до единого обращения к `/kick`. **Закреплено:** `TestCronKicksSessionWithoutPeerRow`, `TestCronReconcilesSessionAfterFailedImportKick` (end-to-end: импорт `old→new`, `/kick` 500, затем обычный цикл учёта рвёт `old`), `TestCronSendsNoKickWhenPeerLookupFails`. --- ## RECON-02 — снижение `maxDevices` после неудачного `/kick` не имело второй попытки ```text maxDevices: 5 -> 1 DB update: OK /kick: FAIL ``` Оператор повторяет сохранение формы. Панель при правке отправляет все поля, включая `maxDevices`, но условие разрыва сравнивает ```go *peerDto.MaxDevices < *before.MaxDevices ``` то есть `1 < 1` → `false`. Второй `PATCH` возвращает успех, `/kick` больше не вызывается, и пять подключённых клиентов продолжают работать при лимите 1. Для `disabled` повторяемость сделана специально (условие смотрит на ЗАПРОШЕННОЕ состояние, а не на переход), для квоты и срока её обеспечивает cron через `peerAccessDenied`. Лимит устройств в политику доступа не входит и входить не должен — это свойство сессий, а не хранимого состояния пира, — поэтому механизма схождения у него не было вовсе. **Исправление — не в условии `updateRequiresReconcile`.** Делать разрыв при каждом сохранении формы нельзя: тогда любая правка пометки рвала бы сессии. Схождение обеспечивает та же сверка живых сессий: `онлайн-устройств > maxDevices` → `/kick`. Побочно это лечит и любое другое случайное превышение лимита. **Число устройств взято из upstream-контракта, а не из предположения.** Официальная документация Traffic Stats API: `/online` возвращает «*the number of Hysteria client instances ("devices"), NOT the number of active proxy connections*». Сравнение с `maxDevices` корректно. Предикат `peerSessionNeedsReconcile` **не является вторым экземпляром политики доступа**: `disabled`, квота, срок и блокировка остаются целиком за `peerAccessDenied`, и предикат его вызывает, а не повторяет. **Закреплено:** `TestCronKicksWhenOnlineExceedsMaxDevices`, `TestCronDoesNotKickAtExactDeviceLimit` (граница), `TestCronKicksPeerWithUnusableMaxDevices`, `TestCronReconcilesSessionAfterFailedMaxDevicesReduction` (end-to-end). --- ## ADMIT-05 — учёт выданных разрешений рос неограниченно `forgetIfIdle` вызывался **только на ветке отказа**. После успешной выдачи запись оставалась с непустым списком разрешений, а когда разрешение протухало, снять её было уже некому: следующего обращения к этому `authId` могло не быть никогда. В карте копились удалённые пиры и старые идентификаторы, переписанные импортом, — за время жизни процесса она только росла. **Исправление.** Уборка идёт по ФАКТИЧЕСКОЙ картине подключений в цикле учёта — единственном месте продукта, где она известна целиком. Решение принимается не по хранимому `lastOnline`: тот обновляется только на пути авторизации и у отключившегося пира остаётся прежним сколь угодно долго. Удаление записи без действующих разрешений и без подключений не меняет ни одного будущего решения: следующая резервация начнёт с чистой записи и придёт к тому же ответу. **Закреплено:** `TestSweepDeviceAdmissionsForgetsIdlePeers`, `TestSweepDeviceAdmissionsKeepsOnlinePeers`, `TestCronSweepsAdmissionsOfOfflinePeers`. --- ## GATE-02 — гейты приёмки не покрывали новых инвариантов `run_access_revocation_acceptance` проверял наличие `reserveDeviceSlot` и `reconcileLiveSessions`, но ни порядок «замок → чтение `/online`», ни форму обхода в cron. То есть исправления ADMIT-04 и RECON-01 можно было бы снять следующим проходом, не уронив сборку. Добавлены три гейта: 1. авторизация берёт замок **до** чтения `/online` и ключует его `*peer.AuthId` (литеральный ключ означал бы один замок на процесс); 2. `enforcePeerAccess` обходит `authIDs` из `/online`, ветка `!found` **завершает** сессию, а не пропускает её, отказ выборки прекращает цикл до `/kick`, и уборка учёта вызывается; 3. `peerSessionNeedsReconcile` вызывает `peerAccessDenied` и не упоминает ни одного поля политики доступа самостоятельно. Каждый гейт проверен в обе стороны: он проходит на исправленном коде и падает на восстановленном состоянии «до». --- ## DOC-02 — документация описывала снятую архитектуру Два места, а не одно: 1. `docs/testing/11-3-target-and-runtime.md:40` — «traffic accounting/kick ориентируются на systemd status». Прямо противоположно реализации после предыдущего прохода и остальной документации; 2. `docs/admin/04-admin-panel.md` — «GET /online (вне блокировки: сеть не должна сериализовать все подключения)». Верно описывало прежнее устройство и стало ложным вместе с исправлением ADMIT-04. Второе найдено при закрытии первого: документация здесь входит в acceptance-контракт, поэтому расхождение — не косметика. --- ## VER-01 — `GO_VERSION` отставала на patch-релиз `GO_VERSION=1.26.7`; 1.26.8 вышел 2026-09-01 (fixes в cgo, компиляторе, runtime, `debug/elf` и `os`). Stdlib целиком попадает в production-бинарь, поэтому «на один патч позади» — свойство выпускаемого артефакта, а не среды сборки. Не архитектурный blocker и не security emergency; закрыто вместе с остальным, раз проход всё равно затрагивает контракт версий. Major не менялся: линия 1.26 поддерживается, переход на 1.27 ради номера не нужен. Обновлены `GO_VERSION`, `GO_LINUX_AMD64_SHA256` и `toolchain` в `apps/go.mod` — расхождение между ними роняет сборку на `verify_go_toolchain_contract`. Контрольная сумма взята из `https://go.dev/dl/?mode=json&include=all` и сверена повторным независимым запросом. --- ## Что осталось проверить на живом хосте Автоматика доказывает логику; следующие свойства наблюдаемы только на реальном сервере с Hysteria и настоящими клиентами: 1. подключить `maxDevices + 1` устройств одновременно и убедиться, что принято ровно `maxDevices`; 2. снизить `maxDevices` при нескольких подключённых устройствах, оборвав Traffic Stats API на время операции, и убедиться, что следующий цикл учёта (не позднее 30 с) разрывает сессии; 3. импортировать пира с новым `auth_id`, оборвав `/kick`, и убедиться, что старая сессия завершается следующим циклом; 4. удалить пира с оборванным `/kick`, затем восстановить API и убедиться, что повтор удаления проходит целиком; 5. убедиться, что при недоступной SQLite (симуляция) ни одна сессия не рвётся; 6. `go test -race ./service/...` на релизном билдере: локально проверка невыполнима, C-компилятора на машине разработчика нет.