diff --git a/doc/encrypted_attachments.md b/doc/encrypted_attachments.md index 11bddc81..2bf94812 100644 --- a/doc/encrypted_attachments.md +++ b/doc/encrypted_attachments.md @@ -45,8 +45,26 @@ Nonce метаданных использует `part=UINT64_MAX`; файл — Получатель проверяет подписанный SHA256 полного контейнера, UUID, tag метаданных, все tags файла, точный размер и hash тела. Результат пишется во временный файл -и публикуется после успешной проверки и durable rename. Все длительные файловые -операции и криптография выполняются в media_async. +и публикуется после успешной проверки и durable rename. Подготовка файлов +и криптография выполняются в media_async worker. + +`encrypted_file_write/read` возвращают закрытый проверенный `file_io_temp`, +не изменяя конечное назначение. Задача владеет им до publish/discard; запись идёт +в эксклюзивно созданный соседний файл с правами 0600 на POSIX. Одинаковые файлы, +включая `/./`, hardlink и конечную ссылку назначения, отклоняются до подготовки; +публикация повторно проверяет идентичность. При ошибке read не выдаёт новые метаданные. + +Режим `FILE_IO_DURABLE` действует весь срок temp: flush/sync открытого файла/close, +затем rename и сохранение каталога. После успешного rename ошибка directory sync +не откатывает установленный результат; temp уже отсоединён от владельца. +Completion не удаляет файлы по заранее вычисленному имени: очистка учитывает, +какие артефакты задача действительно создала или опубликовала. + +Реестр `file_io` защищает активные temp от очистки кеша, включая время после close. +Восстановление до запуска workers и очистка после их завершения удаляют оставшиеся +`.uenc.tmp.` / `.file.tmp.` в `media/decrypted`. +Конечные файлы и имена других форматов этот проход сохраняет; активные temp другого +instance того же процесса остаются защищены общим реестром. ## Хранение и разрешения @@ -110,9 +128,14 @@ backpressure и повтором через 5 секунд; restart продол `test_encrypted_file`: границы порций, пустой файл, metadata roundtrip, неверные ключ/автор/UUID/hash, подмена метаданных с пересчитанным внешним hash, -лишние байты и отсутствие частичного plaintext после отказа. +лишние байты, alias обоих API, сохранность существующего назначения/метаданных, +23 сценария отказов I/O с контролем fd/temp, включая ошибку после rename. +`test_media_primitives` проверяет оба режима temp и границу durable-публикации; +`test_attachment_send` останавливает сервис после настоящего crypto rename до completion. `test_encrypted_attachment`: реальные A–S–B/C, скачивание с S при выключенном A, перезапуски автора с запросами и получателя с ключом, выдача B и отказ C, отсутствие ключа на S/C, отрицательные control-пакеты, повтор GRANT, восстановление удалённого plaintext без нового согласия и удаление канала. +При startup/restart дополнительно проверяются оба дерева: stale temp удаляются, +открытый и закрытый активные temp, конечные файлы и чужие имена сохраняются. diff --git a/doc/etcp_multipath_plan.txt b/doc/etcp_multipath_plan.txt index bf4fc425..98c0fcaa 100644 --- a/doc/etcp_multipath_plan.txt +++ b/doc/etcp_multipath_plan.txt @@ -3,6 +3,188 @@ ETCP: независимая обратная связь каналов и до Статус +24.10.2026: исправлены абсолютные сроки ACK и конкуренция ACK/DATA разных путей. +Обычный ACK-only обслуживается от received_at_us головы своего пути + 2 мс; +piggyback готового DATA и срочный ответ остаются немедленными. Среди готовых ACK +выбирается самый старый срок; DATA другого пути уступает ему, DATA того же пути +забирает его piggyback. Ограниченная измерительная серия заканчивается до смены пути. +Один прежний таймер пересчитывается после piggyback/частичной отправки; новый RX +не отодвигает старые записи после callback таймера. Логический till имеет собственный +абсолютный срок вместо отдельного boolean pending. При OOM таймера используется +существующий флаг срочного ACK, исходные received_at_us не меняются. +Новых очередей/таймеров нет; wire-формат, RTO, min_rtt и модель BBR сохранены. + +Две регрессии до фикса воспроизводят ACK в возрасте 0 мкс и обход готового ACK +другого пути исходящим DATA: /tmp/etcp-ack-deadline-before.log, +/tmp/etcp-ack-due-priority-before.log. Новые проверки покрывают границы 1999/2000 мкс, +piggyback разных путей, смену головы, early timer callback, RX между timer/sender, +частичный ACK-кадр, срочный ответ, OOM, sender failure с close, reset и DOWN/UP/удаление. +Диагностика расширена до 106 полей: ранние записи внутри готовой пачки допустимы; +early_ordinary_ack_packets отдельно проверяет отправку всего пакета раньше срока. + +Проверка после исправления: windows 60 сценариев PASS с управляемыми и реальными +часами; восемь профильных тестов ETCP/STCP ASAN/UBSan с detect_leaks=1 PASS без +ошибок/утечек. Отдельный ASAN two-stuck с диагностикой 10 мс: 5202 проверенных среза, +ранних ACK-пакетов нет, доставка/ресурсы PASS. Полный check.sh: 184/184, proxy 9/9, +burst 1/1, load 8/8 PASS, 134217728 проверенных байт, 185 с со сборкой. + +Три последовательных bidirectional с диагностикой: ранних обычных ACK-пакетов 0 +во всех трёх прогонах. На одиночном 8 Мбит/с пути полоса ACK 0.143..0.169 Мбит/с +вместо 0.347/0.390 в предыдущем диагностическом прогоне. Агрегат: A->B +8.667..8.804, B->A 8.695..8.796 из 10 Мбит/с. Сам агрегат проходит все свои пороги +каждый раз; весь профиль остаётся FAIL из-за одиночных каналов, с 3/2/4 нарушениями. +Средний возраст entries ACK-only на 8 Мбит/с может быть меньше 2 мс: внутри готового +кадра передаются и новые entries. Это учитывается отдельно от ранней отправки кадра. + +Полная штатная последовательная матрица после калибровки эмулятора: первые шесть +скоростных профилей и все 9 профилей отказов PASS. Агрегированный goodput, +Мбит/с: equal 14.416/16, bandwidth 9.032/10, rtt 14.516/16, fast-distant 8.857/10, +slow-distant 8.978/10, three 11.122/12, bidirectional 8.750/8.768 из 10 на направление. +Остаются два FAIL одиночного дуплекса: 1.609 вместо 1.700 Мбит/с (путь 2 Мбит/с) +и 6.730 вместо 6.800 Мбит/с (обратное направление пути 8 Мбит/с). +Two-stuck: gap 206.227 мс, latency 375.457 мс; load-drop/load-hold: gap 41.402/41.845 мс, +steady goodput 7.229/7.466 из 8 Мбит/с. Max latency насыщенного источника +180.693/89.239 мс записана; отдельным порогом она здесь не ограничена. +Дальше отдельно разбирать недооценку полосы/PROBE_RTT одиночного дуплекса, +STCP backpressure и проверки изменения RTT/BW и повторного отказа. + +Логи: /tmp/etcp-ack-deadline-{windows,real-time,full-check}.log, +/tmp/etcp-ack-deadline-asan-test_*.log, /tmp/etcp-ack-deadline-asan-two-stuck.log/.csv, +/tmp/etcp-ack-deadline-matrix.log/.csv, /tmp/etcp-ack-deadline-duplex-<1..3>.log/.csv. +Сводка ACK: /tmp/etcp-ack-deadline-summary.json; актуальный HTML с 106 полями: +/tmp/etcp-ack-deadline-duplex.html. Проверка CSV и JS smoke графиков/фильтров PASS. + +Предыдущий этап: диагностика до исправления ACK. + +24.10.2026: добавлена буферизованная диагностика multipath с шагом 100 мс. +Интервал задаётся UTUN_MULTIPATH_DIAG_MS=10..1000. Новых таймеров нет: срез снимается +существующим тестовым циклом, фактический dt_us записывается в каждой строке. +Конец фазы добавляет короткий срез, поэтому скорость считают по dt_us, а не всегда по 100 мс. +Данные сохраняются в память и выводятся в CSV/лог после drain каждого стенда; +при провале CHECK уже собранные срезы выводятся перед exit. Переполнение буфера — ошибка. +Этот этап меняет только диагностику теста, алгоритмы ETCP/BBR сохранены. + +Запуск из корня репозитория: + UTUN_MULTIPATH_DIAG=/tmp/etcp-duplex.csv ./tests/test_etcp_multipath bidirectional > /tmp/etcp-duplex.log 2>&1 + python3 tools/etcp_diag_report.py /tmp/etcp-duplex.csv /tmp/etcp-duplex.html +Для подробного отказа: UTUN_MULTIPATH_DIAG_MS=10 и профиль two-stuck. +Уровень категории debug явно включается самим тестом; без UTUN_MULTIPATH_DIAG новый вывод выключен. + +104 поля связывают очередь приложения, partial/pending/ожидания normalizer, очередь +фрагментов, send_q (новые/повторы), wait_ack и recv_q с физическим flight/cwnd пути. +Видны suspended/resume/deferred callbacks, состояние sender, номера seq/till, +общий бюджет DATA, очередь ACK и возраст её головы, возраст старейшей попытки, +RTT/jitter/RTO, BW, режим/фаза BBR, pacing, долг и срок готовности pacer, флаг застревания. +Отдельно записываются DATA TX/первые DATA RX, ACK-only/ACK в DATA, задержки ACK, +обычные ACK-only раньше 2 мс, samples BBR, проверки блокировки окном и pacer, +наблюдаемые min/max flight/cwnd/долга и пики send_q/ACK внутри интервала. + +Очереди соединения повторяются в строках его линков — их нельзя суммировать между путями. +У pacer положительный долг допускается строго меньше 1 мс; отрицательное значение — +сырой кредит, который при отправке ограничивается 1 мс. Блокировки выбора — число +проверок, а не длительность ожидания. Пики внутри среза — наблюдения на TX/RX/ACK/выборе, +не непрерывная запись каждого изменения. channel_pending включает propagation delay, +сериализацию и HOLD; channel_peak/delay_max накапливаются с последнего reset_stats. +Wire-байты — UDP без IP/UDP headers; ACK-only включает timestamp, crypto и SESSION. +Piggyback учитывает только положительную ACK-секцию, без повторного начисления crypto. + +HTML автономен, без библиотек/сети: выбор стенда/направления/канала/фазы/интервала, +восемь графиков и все поля выбранного курсором среза. PROBE_RTT выделен фоном. +Перед построением проверяются схема CSV, учёт очередей, счётчиков и непрерывность интервалов. +Проверены 598 срезов дуплекса (100 мс), 5208 срезов two-stuck (10 мс) и 5196 срезов +ASAN/UBSan two-stuck с detect_leaks=1 без ошибок/утечек. Намеренный CHECK сохранил +72 среза до выхода. Неверные интервалы 6/6 и повреждённые CSV 3/3 отклонены. +Общий check.sh итоговой версии: 184/184, proxy 9/9, burst 1/1, load 8/8 PASS, +134217728 проверенных байт, 166 с. HTML прошёл JS smoke-проверку восьми графиков, +104 полей, фильтров, диапазона, курсора, пустого диапазона и сброса. +Длинный дуплекс отдельно по-прежнему FAIL по скорости: в последнем прогоне 5 нарушений, +агрегат 8.513/8.337 из 10 Мбит/с. Это результат одного прогона, не доказательство эффекта диагностики на скорость. + +На одиночном дуплексном пути 8 Мбит/с итоговые срезы показывают расход ACK 0.347/0.390 Мбит/с. +Обычные ACK-only раньше 2 мс: 1774/1828 и 1961/2019 записей; среднее ожидание 230/271 мкс. +Наблюдаемый пик очереди ACK — 4 записи, включая короткие пики до немедленной отправки. +У side=1 path=1 mask=2 в 4.5..5.4 с есть 4 кодограммы приложения, фрагмент и до 8 DATA +в send_q, а pacing составляет около 6.24..6.26 Мбит/с. В 5.5..5.7 с PROBE_RTT уменьшает +окно примерно с 81.5 до 32.9 тыс. байт; после него pacing возвращается лишь к 6.27..6.35 Мбит/с. +В этих срезах виден заданный моделью темп, а не отсутствие данных у sender. +Обнаружен обход ACK_DELAY посторонним wake sender. Диагностическое отключение PROBE_RTT +повышает скорость одиночного 8 Мбит/с пути примерно с 6.8 до 7.1 Мбит/с, но не закрывает +весь дуплекс. Прототип ожидания общего таймера уменьшает полосу ACK примерно до 0.21 Мбит/с, +однако также не закрывает все пороги. Эти прототипы остаются в /tmp, в протокол не внесены. +Следующий разбор: абсолютные deadlines ACK и переустановка единственного таймера после +piggyback/частичной отправки. Проверка одного наличия timer недостаточна: новый RX между +срабатыванием timer и отложенным sender может повторно задержать уже готовые старые ACK. + +Артефакты: /tmp/etcp-duplex-diag-{100ms,10ms,asan,abort}.csv/.log, +/tmp/etcp-duplex-diag-view.html, /tmp/etcp-fault-diag-view.html, +/tmp/etcp-duplex-diag-full-check.log. Полный пакетный трейс и контроль PROBE_RTT: +/tmp/etcp-duplex-next-{trace,no-probe-control,ack-control}.log, +/tmp/etcp-duplex-next-analysis.json; observer — /tmp/etcp-probe-review.c/.mk. +Сводка итоговых срезов: /tmp/etcp-duplex-diag-summary.json. + +24.10.2026: реализованы сканирование send_q, история путей пакета и быстрое застревание. +Остались прежние input_send_q/input_wait_ack: первый готовый повтор имеет приоритет, +иначе выбирается первый готовый новый DATA. Заблокированная запись остаётся на месте +и не мешает следующим. Если кандидата нет, допускается одна подгрузка из input_queue +в пределах общего бюджета unacked_bytes/optimal_inflight и приёмного окна пира. +После синхронного callback проверяются reset/close/эпоха; прежний указатель линка не используется. +Во время ограниченной burst-серии готовый повтор другого пути ждёт её завершения; +FILLER сохраняет измерение, а новые DATA не обгоняют этот повтор. + +Каждый логический пакет хранит 32 байта битовой истории живых слотов local_link_id. +Пока есть неиспользованный допустимый путь, повтор ждёт его cwnd/pacer, а не начинает +новый обход уже использованных. После полного обхода прежний путь также уступает +другой допустимой альтернативе. История/last_rr_link меняются только после принятия +DATA транспортом; rollback их сохраняет. Удаление линка очищает слот до unlink и +callbacks, поэтому новый линк с тем же local_link_id не наследует старую историю. + +Три RTO физических попыток без ACK-прогресса ставят tx_stalled. Пока есть хотя бы +один незастрявший initialized+UP путь, повторы исключают застрявшие, даже когда +рабочий временно занят. Если застряли все — разрешены все UP-пути. Новые DATA +используют прежний выбор и могут проверить восстановление: флаг снимает действующий +ACK собственной попытки, отправленной после установки флага. Старый/чужой ACK +этого не делает. Reset очищает состояние; физический UP, ACK/keepalive и формула RTO +сохранены. Новых очередей, payload-копий, таймеров или ранних повторов до RTO нет. + +Windows: 51 сценарий PASS с управляемыми и реальными часами. Покрыты заблокированный +префикс 256 записей, приоритет повторов, общий бюджет, OOM, rollback, полный обход, +ID 255/переиспользование слота из callback, три RTO, старые ACK, восстановление, +все застрявшие пути и reset/close/удаление burst-линка из callback подгрузки. +Восемь профильных тестов ASAN/UBSan с detect_leaks=1 проходят без ошибок/утечек. +Контроль занятости рабочего пути 1 мс теперь проходит: latency 377.012 мс вместо 736.623 мс, +gap 206.810 мс, ready_wait 2.318 мс при бюджете 460.552 мс. Отрицательный контроль 200 мс +по-прежнему проваливает ready_wait (200.764 мс), gap и latency; пороги не ослаблены. +Логи: /tmp/etcp-send-scan-{windows-final,real-time,busy_1ms,ready_delay_200ms}.log, +/tmp/etcp-send-scan-asan-test_*.log. + +Полная последовательная матрица после калибровки эмулятора: все 9 профилей отказов PASS. +Two-stuck: gap 217.945 мс, latency 379.997 мс при пределе 462.219 мс, ready_wait 1.184 мс. +Ещё три отдельных повтора two-stuck: 3/3 PASS, latency 374.981..378.557 мс, +gap 203.057..205.265 мс, ready_wait максимум 13.268 мс. Slow-drop: gap 259.464 мс, +latency 264.704 мс при пределе 350.129 мс; это профиль с RTT 200 мс и ожиданием RTO. +Load-drop/load-hold: gap 39.234/39.355 мс, steady goodput 7.221/7.468 из 8 Мбит/с. +Для насыщенного источника проверяются gap и steady goodput; max_latency 161.287/86.306 мс +здесь записана, но отдельным порогом не ограничена. ASAN/UBSan two-stuck/load-drop +также PASS с detect_leaks=1, без ошибок и утечек. + +Первые шесть скоростных профилей PASS. Агрегированный goodput +(min(application,fresh DATA), Мбит/с): equal 14.390/16, bandwidth 9.048/10, +rtt 14.244/16, fast-distant 8.841/10, slow-distant 8.966/10, three 11.017/12. +Дуплекс 8.412/8.435 из 10 Мбит/с остаётся FAIL: все 7 нарушений матрицы относятся +к его скоростным порогам (одиночные каналы и агрегат). Доставка, порядок и ресурсы +проходят. Общая multipath-задача остаётся открытой: далее разбирать дуплекс, +STCP backpressure и отдельные проверки смены RTT/BW/повторного отказа. +Логи: /tmp/etcp-send-scan-matrix.log/.csv, +/tmp/etcp-send-scan-native-two-stuck-<1..3>.log/.csv, +/tmp/etcp-send-scan-asan-{two-stuck,load-drop}.log/.csv. + +Полный check.sh после изменений: 184/184, proxy 9/9, burst 1/1, load 8/8 PASS; +134217728 проверенных байт, 197 с со сборкой. Отдельная длинная multipath-матрица +в check.sh не входит; её оставшиеся нарушения дуплекса перечислены выше. +Лог: /tmp/etcp-send-scan-full-check.log. + +Предыдущая диагностика (до реализации сканирования и истории): + 24.10.2026: проверки отказов разделены на RTO, готовность повтора и доставку. В этом этапе менялись только тесты/диагностика, логика ETCP сохранена. TX учитывается после принятия send транспортом, а не при подготовке пакета. @@ -714,11 +896,12 @@ uint8: хранить факт состоявшейся отправки отд Для retry: A->B при обоих UP и меньшем inflight у A; A->B->C->A; посторонние DATA/ACK между попытками; пропуск заблокированных каналов; один доступный путь; все временно заняты; burst старого пути; удаление курсора. - После этого повторить всю матрицу скоростей. Выбор пути первоначальных DATA менять только + Матрица повторена после сканирования send_q и истории путей; текущие результаты приведены в статусе. + Выбор пути первоначальных DATA менять только при сохраняющейся недогрузке: эксперименты уже показывают полную утилизацию без смены алгоритма выбора DATA, поэтому дополнительная эвристика пока лишняя. -3. Быстрое восстановление и здоровье каналов. +3. Быстрое восстановление и здоровье каналов — согласованный этап реализован. Предварительное исправление OOM завершено: выбран сброс всей сессии с сохранением линков, без удержания старого fragment/cursor и без отдельного OOM retry. Очистка не выделяет память, старые callbacks/ожидания отменяются; активные @@ -727,11 +910,13 @@ uint8: хранить факт состоявшейся отправки отд Собственный RTT/RTO и ротация уже обязательны в этапе 2. Проверять ожидание RTO отдельно от задержки готового повтора и доставки альтернативным путём. Ранние копии до RTO исключены по решению пользователя от24.10.2026. - При потере ограничить новые DATA на проблемном пути, пробовать восстановление - ограниченными probe, повторять недоставленные DATA через рабочие каналы. - Не отправлять следующий bulk вслепую в тот же застрявший путь. - Проверки: DROP/HOLD отдельно по направлениям и вместе, несколько застрявших - каналов, возврат старых копий после HOLD, повторный отказ, смена RTT/BW на ходу. + Реализованы флаг после трёх RTO, исключение застрявшего пути только из повторов, + полная история обхода, сканирование send_q и восстановление по свежему собственному ACK. + Отдельные probes/ограничение новых DATA не входят в выбранное решение: исходный + выбор новых DATA сохранён, подтверждённая новая отправка снимает флаг застревания. + DROP/HOLD отдельно по направлениям и вместе, несколько застрявших каналов, + возврат старых копий после HOLD проходят; повторный отказ и смена RTT/BW на ходу + остаются отдельными проверками. Отсутствие рабочих альтернатив и reset/close покрыты. 4. Учёт нижележащей очереди STCP. Ограниченный tx_queue и уведомление о реальном прогрессе/готовности должны diff --git a/doc/etcp_protocol.txt b/doc/etcp_protocol.txt index 7c061f14..b05302fd 100644 --- a/doc/etcp_protocol.txt +++ b/doc/etcp_protocol.txt @@ -26,8 +26,9 @@ HELLO/CHALLENGE/CONFIRM и keepalive передаются своим незав При наличии незастрявшего initialized+UP пути застрявшие исключены из повторов, даже если альтернатива занята. Если застряли все, доступны все UP-пути. Прежний путь разрешён лишь при отсутствии других допустимых; иначе ждём альтернативу. DOWN последней альтернативы пробуждает отправителя. - 3. Если DATA не помещается, он остаётся в очереди; для накопленных ACK выбирается - тот же линк, который принял соответствующий DATA, без ограничения DATA-cwnd/pacing. + 3. Наступивший по сроку или срочный ACK обслуживает тот же линк, который принял DATA, + без ограничения DATA-cwnd/pacing. Готовый DATA другого линка уступает этому ACK; + DATA того же линка забирает ACK piggyback. Если DATA не помещается, он остаётся в очереди. Кумулятивный till можно отправить по любому рабочему пути. ACK-only не увеличивает нагрузку DATA-shaper; физическая очередь канала остаётся общей, её накопление ограничивают DATA-cwnd/pacing BBR. 4. Выделить ETCP_DGRAM до удаления DATA из очереди и изменения счётчиков. @@ -61,6 +62,20 @@ HELLO/CHALLENGE/CONFIRM и keepalive передаются своим незав Если логический seq ещё не доставлен и других активных копий нет, он возвращается в input_send_q. Уже доставленный seq не возвращается, даже если его физическая копия истекла позже. +Обычный ACK-only ждёт received_at_us старейшей записи своего пути + 2000мкс. +Piggyback на готовом DATA передаёт ACK сразу. Готовый ACK может включать более свежие записи: +срок проверяется по голове пакета, а не отдельно для каждой записи внутри него. +Среди готовых обычных ACK выбирается самый старый срок; срочный ответ на ACK_REQUEST имеет приоритет. +Короткая ограниченная измерительная burst-серия завершается до перехода к ACK другого пути. +Продвижение логической границы также задаёт срок 2мс от первого неподтверждённого продвижения; +следующие DATA его не отодвигают. Любой принятый пакет с актуальным till снимает этот запрос. +Один ack_resp_timer соединения обслуживает минимум сроков очередей рабочих путей и логической границы. +После piggyback/частичной отправки срок пересчитывается по оставшимся головам. Новый RX между +timer и отложенным sender не задерживает старые ACK: готовность проверяется по абсолютному времени. +При DOWN очередь пути не создаёт таймер; после UP её старый наступивший срок действует сразу. +OOM ACK-таймера логируется и включает существующий срочный ответ, сохраняя received_at_us для RTT. +Reset/close отменяют таймер и очищают сроки; DATA/RTO/BBR и wire-формат этим механизмом не меняются. + inflight - две очереди (ll_queue): - input_send_q: список пакетов ожидающих отправку - input_wait_ack: список неподтвержденных пакетов ожидающих ack diff --git a/doc/media_async_audit_findings.txt b/doc/media_async_audit_findings.txt new file mode 100644 index 00000000..93f267e5 --- /dev/null +++ b/doc/media_async_audit_findings.txt @@ -0,0 +1,211 @@ +Аудит media_async / media_delivery — 2026-10-24 +Статус: подтверждённый дефект исправлен; полный аудит двух каталогов ещё не завершён. +Ниже сохранены исходное воспроизведение и согласованный проект; результаты исправления — в конце. + +Подтверждённый дефект: исходник уничтожается через другое имя того же файла + +encrypted_file_write, src/media_async/encrypted_file.c:79-87, сравнивает только строки +source/destination. Разные строки могут обозначать один inode. fopen(destination,"wb") +усекает уже открытый source. Криптография затем успешно обрабатывает пустой файл. +Контейнер корректен для нулевого содержимого, но исходные данные потеряны; возврат равен 0. + +Воспроизведено на настоящих функциях из src/libutun.a: +- source и source с компонентом /./: исходные30 байт заменены контейнером152 байта, + plaintext_size=0, rc=0, SHA256 исходника изменился; +- source и hardlink: тот же результат; +- encrypted_file_read тоже пропускает оба вида alias: контейнер182 байта заменяется + plaintext30 байт, rc=0, исходный ciphertext больше не существует в прежнем виде. + +У read такая же проверка strcmp в строке118 и разрушающий fopen в строке142. +В текущем групповом pipeline вызывающий выбирает отдельный .part; воспроизведение +доказывает дефект файлового API, а не наблюдавшуюся потерю реального пользовательского вложения. +Ошибку нельзя замаскировать описанием одинаковых файлов как успешного no-op: +шифрование и расшифровка должны создавать другое содержимое. + +Диагностика: +- /tmp/media-encrypted-alias-repro.c — четыре проверки, сверяются inode, размеры и SHA256; +- /tmp/media-encrypted-alias-repro.mk — использует цели/библиотеки/флаги tests/Makefile; +- /tmp/media-encrypted-alias-repro.log — полный журнал с media=debug; +- /tmp/media-encrypted-alias-lvVS77/ — сохранённые входы и результаты опыта. +Проектные .c/.h для воспроизведения не менялись. + +Согласованная архитектура исправления + +Одной проверки file_io_paths перед fopen(wb) недостаточно для полного исправления +контракта владения. Сейчас encrypted_file удаляет destination при ошибке, а +ak_file_done повторно удаляет заранее вычисленный .part даже при раннем отказе. +Нужно убрать открытие произвольного назначения с усечением и очистку по одному имени. + +Граница модулей + +- lib/file_io: UTF-8/regular-file проверки, идентичность, эксклюзивный temp, + flush/sync/close, atomic rename, владение temp и защита от orphan cleanup. +- encrypted_file: создание/проверка контейнера в собственном temp; без публикации + конечного имени, SQLite и состояния async-задачи. +- chat_attachment: задача владеет temp, выбирает конечное имя и выполняет одну + публикацию; completion меняет состояние БД/UI и освобождает только свои ресурсы. + Подготовка, sync и rename остаются в worker, как сейчас; новые файловые I/O в uasync не добавляются. + +Предлагаемый контракт write/read + +1. Функция получает source, конечный target_path и указатель на обнулённый file_io_temp. + target_path задаёт место будущего результата; он не открывается с усечением. + До начала работы file_io_paths(source,target_path) обязан вернуть 0. + Совпадение строки, /./, hardlink и конечная ссылка назначения отклоняются без изменений. +2. Источник открывается общим file_io_open_read. Только после успешного открытия + создаётся уникальный temp рядом с target_path через file_io_temp_open. + На POSIX он имеет права 0600; создание эксклюзивное, владелец сразу учтён в общем реестре. +3. Контейнер пишется/проверяется через уже открытый FILE этого temp. Нет второго .part, + второго открытия выхода по пути или дополнительного слоя публикации внутри codec. +4. Полностью проверяются данные, закрытие входа и durable завершение выхода. + При ошибке удаляется только созданный этой операцией temp; существующие source/target сохраняются. + При успехе возвращается закрытый, синхронизированный temp, который ещё не опубликован. + Результат info/type у read присваивается только после полного успешного завершения подготовки. +5. Владелец file_io_temp размещается прямо в ak_file и имеет стабильный адрес до done; + копировать его структуру нельзя: реестр хранит адрес владельца. + Функции не принимают уже активный output и не уничтожают его при отказе аргументов. + +Единая политика durability + +В file_io_temp_open добавить flags, в file_io_temp хранить выбранный режим на весь +срок операции. FILE_IO_DURABLE включает обе части: finish и publish. +Обычные пользователи передают 0 и сохраняют свою семантику; отдельные флаги в каждом +finish/publish не нужны. Это предотвращает случайное сохранение файла без сохранения имени. + +Durable finish: fflush -> fsync/_commit того же открытого fd -> fclose. Ошибка любой +фазы сохраняет первый код ошибки; close выполняется и при отказе sync. Открывать путь +заново ради fsync не нужно. После отказа temp остаётся у владельца до discard. + +Publish повторно проверяет идентичность source/target и принимает только закрытый temp. +На POSIX: rename и fsync каталога назначения; соседний temp находится в том же каталоге. +На Windows: общий wide API и MOVEFILE_WRITE_THROUGH. Родительский каталог вычисляется +без обрезания: имя без '/' означает '.', '/file' означает '/'. +Политика sync принадлежит file_io, а не формату зашифрованного контейнера. + +Точка фиксации — успешный rename. До неё ошибка сохраняет старый target. +После rename ошибка fsync каталога означает: результат уже установлен, долговечность +имени не подтверждена. Нельзя обещать откат, удалять новый файл вслепую или восстанавливать +старое содержимое. В этот момент temp уже отсоединён от реестра и его имя в owner пусто. +Этот переход должен быть явно описан в file_io.h и проверен тестом. +Длительный fsync выполняется после снятия блокировки реестра временных файлов. + +Владение задачи и отмена + +ak_file хранит file_io_temp вместо temporary[...]. Worker после успешной подготовки +один раз вызывает file_io_temp_publish с тем же target и источником. +До publish поле temporary непустое; после успешного rename оно пустое даже при +ошибке directory sync. По этому переходу задача фиксирует факт своей публикации. +Completion всегда может вызвать discard: после rename он ничего не удаляет. +Cleanup при отмене/удалении канала учитывает отдельно действительно созданные/ +заменённые артефакты. Вычисленный путь не является доказательством владения файлом. +При отказе до создания выхода нельзя удалять существующий target или его alias. + +Отдельных async owners, refcount и фоновых потоков не требуется. Ключ по-прежнему сохраняется +до шифрования; ready получателя/публикация сообщения подтверждаются только после +успешного worker и проверки актуальности канала в completion. Авторская строка ключа +сохраняется до worker по прежним правилам. Общей транзакции ФС/SQLite нет: после rename +путь остаётся известен attachment_keys для последующей очистки/повторной операции. + +Кеш и восстановление после crash + +Активный temp защищён существующим file_io-реестром, в том числе после finish. +В chat_msg убрать специальное сохранение вычисленного cipher_path.part; конечный +cipher_path по-прежнему защищается attachment_keys, временный файл — своим owner. +После crash реестра нет: такие temp допустимо удалить. + +Нельзя ограничиться нынешним orphan walk дерева media: plaintext-temp находится +в decrypted, а ak_recover_preparations знает только фиксированный .part. +В init/очистке после завершения workers нужно удалять оставшиеся временные файлы +строгого собственного формата .uenc.tmp.<32hex> / .file.tmp.<32hex> +в управляемых каталогах media/decrypted. Использовать file_io_remove_orphan, +чтобы сохранить активные temp другого instance того же процесса. Конечные файлы, +ключи и данные иных форматов такой проход не удаляет; ошибка обхода логируется. + +Область изменений + +Основные файлы: lib/file_io.c/h, media_async/encrypted_file.c/h, +chat/chat_attachment.c, chat/chat_msg.c, test_encrypted_file/test_media_primitives +и сетевые проверки зашифрованных вложений. Остальные вызовы file_io_temp_open +в media_async/media_delivery/video переводятся на явные flags=0. +DM не вызывает encrypted_file_write/read: он использует media_crypto streams +и общие sync/publish helper-функции из encrypted_file.h. При правке общего слоя +необходимо сохранить их durable контракт; перенос общих операций в file_io должен +быть прямым, без совместимых обёрток и без изменения DM state machine. +Формат контейнера, nonce, протокол ключей и схема БД для этого фикса не меняются. + +Регрессии и критерии готовности + +- Обе операции: одинаковая строка, /./, hardlink, symlink, отсутствующий/необычный source; + source и прежний target не меняются, alias не удаляется, чужой temp не освобождается. +- Обычные файлы размером 0/1/32767/32768/32769 байт, большой файл, UTF-8 пути; + успешная подготовка не меняет target до publish. Read не публикует info/type при ошибке. +- Внедрение ошибок open/fdopen/read/write/seek/flush/fsync/input-close/output-close/ + rename/directory-sync/RNG; отсутствие утечек fd/памяти/регистраций temp. +- Подмена target на hardlink между подготовкой и publish: повторная проверка + отклоняет публикацию, исходник сохраняется, удаляется только собственный temp. +- Отказ directory sync после rename: новый файл цел, temp-owner отсоединён, + ошибка не представляется как откат; повторная операция корректна. +- Orphan cleanup во время work, после finish, после publish; crash/restart + в обоих деревьях, чужие имена сохраняются. Отмена, stop и удаление канала + до work, после подготовки и после rename: один done, нет публикации сообщения/ready. +- Профильные 20 тестов, ASAN/UBSan/LSan и полный набор. Изменение file_io требует + также проверки его прежних пользователей: copy, video и media blocks/download. + +Этот проект реализован. Успешные 20 тестов ниже относятся к исходной реализации; +проверки исправленного кода приведены в конце. + +Что проверено и что осталось + +Прочитаны все шесть пар .c/.h media_async, целиком file_transfer.c/h, заголовки +media_delivery/download/index/proto, sync.md и doc/encrypted_attachments.md. +Полная повторная сверка остальных реализаций media_delivery и исправление контрактов +ещё не выполнены. Прежние [V] в общем checklist относятся к предыдущим аудитам; +новые незавершённые пункты отмечены [~]. + +Linux: ./build.sh --no-gui -j4 — PASS;20/20 профильных тестов — PASS: +media_primitives, attachment, attachment_send, encrypted_file, encrypted_attachment, +media_async, media_index, media_index_regressions, media_delivery_sql, +media_delivery_download, media_download_timeout, media_query, media_blocks, +media_replication, media_delivery_integration, media_delivery_full, +media_delivery_chat, dm_media, dm_worker, services. +Логи: /tmp/media-audit-build.log, /tmp/media-doc-audit-results.log, +/tmp/media-doc-audit-logs/. Штатный test_encrypted_file не покрывает alias. +Старые сбои encrypted_attachment/media_delivery_chat в этом прогоне не воспроизвелись; +один PASS не закрывает задачи о нестабильности. +На исходном этапе полный check.sh и новые sanitizer-прогоны не запускались. + +Результаты исправления — 2026-10-24 + +- file_io_temp получил единый FILE_IO_DURABLE: sync открытого fd при finish, + write-through rename на Windows и directory sync после rename на POSIX. + После rename temporary пуст, включая отказ directory sync; discard не удаляет результат. + Общие sync/publish функции перенесены в file_io; DM вызывает их напрямую. +- Write/read готовят отдельный закрытый temp. Source/target не открываются с усечением; + совпадающая строка, /./, hardlink и конечная ссылка отклоняются до создания выхода. + Read выдаёт info/type только после успешного закрытия/сохранения проверенного результата. +- ak_file хранит один output, учитывает подготовленный plaintext snapshot и выполненный rename. + Worker публикует один раз; completion очищает только собственные артефакты. + Кеш использует реестр владельцев; recovery очищает строгие temp в media и decrypted. + Построение путей и SQL recovery используют одну каноническую media_base. +- test_encrypted_file: прежние шесть размеров, восемь alias-сценариев обоих API, + активный output, alias перед publish и 23 отказа I/O с контролем fd/temp/метаданных. + До rename прежний target сохраняется; после отказа directory sync новый файл установлен. +- test_media_primitives: durable и обычный режим, sync failure до rename, failure после + rename, относительный/корневой каталог, защита активного temp в worker и после close. +- test_attachment_send детерминированно ждёт настоящего crypto rename без обработки + completion, затем останавливает сервис: нет сообщения/ключа/оставшихся файлов/temp. +- test_encrypted_attachment при каждом startup/restart проверяет оба дерева: + stale temp удалены; открытый/закрытый активные owners, конечные файлы и чужие имена сохранены. +- Профильный Linux-набор: 21/21 PASS, включая video/no_ffmpeg, DM, services и media delivery. + Основной make check: 185/185 PASS. TUN в отдельном network namespace с тем же бинарником: + proxy9/9, burst1/1, load8/8 PASS; 134217728 проверенных байт. +- ASAN/UBSan/LSan: 21/21 PASS с detect_leaks=1. Все встроенные зависимости собраны + с sanitizer-флагами в /tmp/utun-media-fix-sanitizers; обычные объекты проекта не смешивались. + Windows/FreeBSD build/runtime не проверялись. + +Логи: /tmp/media-fix-profile-results.log, /tmp/media-fix-full-check.log, +/tmp/media-fix-network-check.log, /tmp/media-fix-sanitizer-results.log, +/tmp/media-fix-cancel-after-rename.log, /tmp/media-fix-encrypted-file.log. +Проверка реализации не выявила противоречий владения в перечисленных сценариях. +Частичные отметки остальных файлов аудита сохраняются; этот фикс не означает полного +завершения проверки media_async/media_delivery или закрытия старых нестабильных тестов. diff --git a/doc/service_lifecycle.md b/doc/service_lifecycle.md index 26eb6d8c..b754d28a 100644 --- a/doc/service_lifecycle.md +++ b/doc/service_lifecycle.md @@ -136,6 +136,15 @@ completion. Отказ запуска/неверные аргументы даю закрытия temp; вызывающий отвечает за очистку своего результата при отмене. Подробные файловые контракты, 64-битные размеры и ошибки RNG описаны в `media_async.h`. +Групповой crypto worker хранит один `file_io_temp` прямо в задаче вложения. +`encrypted_file_write/read` только готовят закрытый durable temp; worker публикует +его одним rename, completion подтверждает результат в БД/UI. Отмена до completion +не публикует сообщение; cleanup учитывает реально созданные/заменённые файлы. +Ошибка синхронизации каталога после rename означает установленное имя без подтверждённой +durability; отложенный discard уже не удаляет результат. Общей транзакции ФС/БД нет. +До запуска workers и после их завершения recovery очищает собственные stale temp +в `media/decrypted`; общий file_io-реестр сохраняет активные temp всех instance. + `media_delivery_init()` вызывается после подготовки chat core и media_index, пока общая БД и группы живы; подписка на роли обязательна. Репликация хранит отдельный контекст и CM handle для каждой пары «CHAT-группа–пир». DOWN отменяет текущий RPC, diff --git a/doc/tasks.md b/doc/tasks.md index 2f0dd49e..4c30356a 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -4,6 +4,24 @@ ## Текущие задачи +[+] **media_async encrypted_file: не уничтожать source через alias назначения** — исправлено 24.10.2026. + write/read отвергают только одинаковые строки; /./ и hardlink обходят проверку одного файла. + write возвращает0, заменяя исходные30 байт контейнером пустого файла; read возвращает0, + заменяя входной ciphertext182 байта plaintext30 байт. Проверены реальные функции, inode и SHA256. + Реализован один file_io_temp в ak_file; codec готовит закрытый проверенный temp, + worker публикует его одним rename. Target не открывается с усечением; alias проверяется до work и перед publish. + Durability задаётся на весь срок temp; ошибка directory sync после rename не считается откатом. + Cleanup удаляет только собственные артефакты; crash recovery учитывает temp в media и decrypted. + DM не вызывает codec: сохранить его контракт общих sync/publish helpers при изменениях file_io. + Детали, проверка архитектуры исправления и незавершённый аудит: [media_async_audit_findings.txt](media_async_audit_findings.txt). + Воспроизведение: /tmp/media-encrypted-alias-repro.c, /tmp/media-encrypted-alias-repro.log. + Добавлены восемь alias-сценариев, 23 отказа I/O с контролем fd/temp/метаданных, + отмена настоящего worker после rename до completion, startup/restart обоих деревьев. + Профильные21/21 и основной make check185/185 PASS; proxy9/9, burst1/1, load8/8 PASS + в отдельном network namespace,134217728 проверенных байт. ASAN/UBSan/LSan21/21 PASS, detect_leaks=1. + Логи: /tmp/media-fix-full-check.log, /tmp/media-fix-network-check.log, /tmp/media-fix-sanitizer-results.log. + Полный аудит остальных контрактов media_async/media_delivery ещё не завершён; Windows/FreeBSD не проверялись. + [+] **Proxy: исправить HUP/backpressure, владение и rollback** — завершено 24.10.2026. Согласованный проект, причины и результаты: [doc/proxy_fix_plan.txt](proxy_fix_plan.txt). HUP больше не обходит read_high_water/ручную паузу; idle HUP снимается с poll, @@ -25,6 +43,50 @@ Windows/FreeBSD runtime не проверялся. [ ] **ETCP: полезная утилизация нескольких каналов и доставка при зависании части линков** — общая задача открыта. + 24.10.2026: исправлен обход срока ACK посторонним wake. Абсолютный срок головы пути +2 мс, + один общий таймер, пересчёт после piggyback/частичной отправки; новые DATA не отодвигают старые ACK. + Наступивший ACK другого пути имеет приоритет перед DATA; свой DATA забирает его piggyback. + Сохранены срочный ответ, bounded burst, OOM fallback и очистка при reset/close; wire/BBR/RTO не менялись. + Windows 60 сценариев с обоими режимами часов PASS; восемь ASAN/UBSan-тестов и ASAN two-stuck без ошибок/утечек. + В трёх дуплексных прогонах ранних ACK-пакетов 0; на 8 Мбит/с ACK занимают 0.143..0.169 вместо прежних 0.347/0.390 Мбит/с. + Агрегат проходит все свои пороги: 8.667..8.804 / 8.695..8.796 из 10 Мбит/с; одиночные каналы ещё дают FAIL. + Полная матрица: первые 6 скоростных профилей и все 9 профилей отказов PASS, агрегат дуплекса 8.750/8.768. + Остались 2 FAIL одиночного дуплекса: 1.609<1.700 и 6.730<6.800 Мбит/с; дальше разбирать BW/PROBE_RTT и STCP backpressure. + Load-drop/load-hold: паузы 41.402/41.845 мс, steady 7.229/7.466 из 8 Мбит/с; max latency 180.693/89.239 мс записана. + Check.sh 184/184, proxy 9/9, burst 1/1, load 8/8 PASS, 134217728 проверенных байт, 185 с. + Диагностика 106 полей различает раннюю запись в готовой пачке и отправку всего ACK-пакета раньше срока. + Логи/CSV: /tmp/etcp-ack-deadline-*; HTML: /tmp/etcp-ack-deadline-duplex.html; детали — doc/etcp_multipath_plan.txt. + Предыдущий этап — диагностика до исправления ACK: + Продолжение 24.10.2026: диагностика срезов 100 мс (настройка 10..1000 мс), 104 поля; CSV и лог буферизуются до drain. + Видны очереди, callbacks/deferred waiter, DATA/ACK, flight/cwnd, BBR/RTT/RTO, долг/готовность pacer и канал эмулятора. + tools/etcp_diag_report.py проверяет CSV и строит автономные интерактивные графики с исходными срезами. + Дуплекс: 598 срезов; two-stuck/ASAN: 5208/5196 срезов, без ошибок sanitizer/утечек; CHECK сохраняет накопленный лог. + Итоговый check.sh 184/184, proxy 9/9, burst 1/1, load 8/8 PASS, 166 с; JS smoke-проверка графиков/фильтров PASS. + На одиночном 8 Мбит/с пути ACK занимают 0.347/0.390 Мбит/с, среднее ожидание ACK-only 230/271 мкс вместо окна 2 мс. + В срезах side=1 path=1 DATA готовы, а BBR задаёт около 6.3 Мбит/с; PROBE_RTT временно уменьшает окно до 32.9 тыс. байт. + Дедлайн обходит посторонний wake sender. Дальше разбирать абсолютные deadlines ACK; простого !timer недостаточно. + Алгоритмы протокола на этом этапе не менялись; полный дуплекс всё ещё не проходит скоростные пороги (5 нарушений, 8.513/8.337). + Логи/CSV: /tmp/etcp-duplex-diag-*; просмотр: /tmp/etcp-duplex-diag-view.html; детали — doc/etcp_multipath_plan.txt. + 24.10.2026: реализованы сканирование send_q с приоритетом готовых повторов и ограниченная подгрузка новых DATA. + Заблокированный пакет пропускается. История путей пакета (32 байта) исключает новый обход до перебора допустимых; + история/RR фиксируются после принятого send, удаление линка очищает слот до callbacks/повторной выдачи ID. + После 3 RTO без ACK-прогресса tx_stalled исключает путь из повторов при наличии незастрявшего UP-пути. + Все застрявшие пути допускаются, новые DATA сохраняют прежний выбор; действующий ACK отправки после флага снимает его. + Старый/чужой ACK не восстанавливает путь; reset очищает состояние. Новых очередей/таймеров/копий нет, RTO сохранён. + Windows: 51 сценарий PASS с управляемыми/реальными часами; восемь ASAN/UBSan-тестов без ошибок/утечек. + Контроль занятого на 1 мс рабочего пути: latency 377.012 вместо 736.623 мс, ready_wait 2.318 мс, профиль PASS. + Отрицательный контроль задержки готового повтора 200 мс остаётся FAIL по очереди и общей задержке. + Полная последовательная multipath-матрица: все 9 профилей отказов PASS, первые 6 скоростных профилей PASS. + Two-stuck: latency 379.997 мс при пределе 462.219 мс; ещё 3/3 повтора — 374.981..378.557 мс. + Load-drop/load-hold: паузы 39.234/39.355 мс, steady goodput 7.221/7.468 из 8 Мбит/с. + Max latency под насыщенной нагрузкой 161.287/86.306 мс записана; отдельным порогом она здесь не ограничена. + Two-stuck/load-drop также проходят ASAN/UBSan с detect_leaks=1 без ошибок/утечек. + Остаётся дуплекс: 8.412/8.435 из 10 Мбит/с, все 7 нарушений матрицы — его скоростные пороги. + Далее — дуплекс, STCP backpressure, отдельные проверки смены RTT/BW и повторного отказа. + Полный check.sh: 184/184, proxy 9/9, burst 1/1, load 8/8 PASS, 134217728 проверенных байт, 197 с со сборкой. + Длинная multipath-матрица запускается отдельно; её общий результат остаётся FAIL из-за дуплекса. + Логи: /tmp/etcp-send-scan-*.log; подробности — doc/etcp_multipath_plan.txt. + Предыдущая диагностика: 24.10.2026: тесты отдельно проверяют обработку RTO (опоздание<=20мс), ожидание готового повтора (<=100мс) и доставку сверх заданной задержки канала (<=100мс). TX учитывается после принятия send. Быстрые профили сохраняют общий предел100мс; длинные учитывают по одному RTO отказавших путей плюс100мс. diff --git a/lib/file_io.c b/lib/file_io.c index 01365190..7bf2e12e 100644 --- a/lib/file_io.c +++ b/lib/file_io.c @@ -135,11 +135,88 @@ int file_io_paths(const char *source, const char *destination) { return !memcmp(input.identity, output.identity, sizeof(input.identity)); } -int file_io_temp_open(struct file_io_temp *output, const char *destination) { +/* Синхронизировать именно открытый файл; повторного открытия имени нет. */ +static int file_io_sync_descriptor(FILE *file, const char *path) { +#ifdef _WIN32 + int result = _commit(_fileno(file)); +#else + int result = fsync(fileno(file)); +#endif + if (result) { + int error = errno ? errno : EIO; + DEBUG_ERROR(DEBUG_CATEGORY_SYS, "file sync failed path=%s errno=%d", path, error); + errno = error; + } + return result ? -1 : 0; +} + +int file_io_sync_file(const char *path) { + FILE *file = file_io_open_update(path); + if (!file) return -1; + int result = file_io_sync_descriptor(file, path); + if (fclose(file)) { + DEBUG_ERROR(DEBUG_CATEGORY_SYS, "file sync close failed path=%s errno=%d", path, errno); result = -1; + } + return result; +} + +int file_io_sync_directory(const char *path) { + if (!path || !*path || strlen(path) >= FILE_IO_PATH_MAX) { + DEBUG_ERROR(DEBUG_CATEGORY_SYS, "file directory sync invalid path"); return -1; + } +#ifndef _WIN32 + char parent[FILE_IO_PATH_MAX]; + memcpy(parent, path, strlen(path) + 1); + char *slash = strrchr(parent, '/'); + if (!slash) memcpy(parent, ".", 2); + else if (slash == parent) slash[1] = 0; + else *slash = 0; + int fd = open(parent, O_RDONLY | O_DIRECTORY); + if (fd < 0) { DEBUG_ERROR(DEBUG_CATEGORY_SYS, "file directory open failed path=%s errno=%d", parent, errno); return -1; } + int error = fsync(fd) ? (errno ? errno : EIO) : 0; + if (close(fd) && !error) error = errno ? errno : EIO; + if (error) { DEBUG_ERROR(DEBUG_CATEGORY_SYS, "file directory sync/close failed path=%s errno=%d", parent, error); return -1; } +#endif + return 0; +} + +/* Только rename; владелец temp снимает регистрацию до длительного directory sync. */ +static int file_io_rename(const char *source, const char *destination, unsigned flags) { +#ifdef _WIN32 + wchar_t from[FILE_IO_PATH_MAX], to[FILE_IO_PATH_MAX]; + if (file_io_wide(source, from) || file_io_wide(destination, to)) return -1; + DWORD options = MOVEFILE_REPLACE_EXISTING | ((flags & FILE_IO_DURABLE) ? MOVEFILE_WRITE_THROUGH : 0); + if (!MoveFileExW(from, to, options)) { + DEBUG_ERROR(DEBUG_CATEGORY_SYS, "file rename failed source=%s destination=%s winerr=%lu", source, destination, GetLastError()); + return -1; + } +#else + (void)flags; + if (rename(source, destination)) { + DEBUG_ERROR(DEBUG_CATEGORY_SYS, "file rename failed source=%s destination=%s errno=%d", source, destination, errno); return -1; + } +#endif + return 0; +} + +int file_io_publish(const char *source, const char *destination) { + if (file_io_paths(source, destination)) { + DEBUG_ERROR(DEBUG_CATEGORY_SYS, "file publish invalid source/destination"); return -1; + } + if (file_io_rename(source, destination, FILE_IO_DURABLE)) return -1; + if (file_io_sync_directory(destination)) { + DEBUG_ERROR(DEBUG_CATEGORY_SYS, "file installed but directory durability unconfirmed destination=%s", destination); return -1; + } + DEBUG_DEBUG(DEBUG_CATEGORY_SYS, "file durably installed destination=%s", destination); + return 0; +} + +int file_io_temp_open(struct file_io_temp *output, const char *destination, unsigned flags) { if (!output || output->file || output->temporary[0] || !destination || !*destination - || strlen(destination) + 38 >= FILE_IO_PATH_MAX) { + || (flags & ~FILE_IO_DURABLE) || strlen(destination) + 38 >= FILE_IO_PATH_MAX) { DEBUG_ERROR(DEBUG_CATEGORY_SYS, "file temporary invalid owner/path"); return -1; } + output->flags = flags; for (unsigned attempt = 0; attempt < 8; attempt++) { uint8_t random[16]; char suffix[33]; if (random_bytes(random, sizeof(random))) { DEBUG_ERROR(DEBUG_CATEGORY_SYS, "file temporary RNG failed"); return -1; } @@ -171,7 +248,7 @@ int file_io_temp_open(struct file_io_temp *output, const char *destination) { if (!output->file) close(fd); #endif if (!output->file) { DEBUG_ERROR(DEBUG_CATEGORY_SYS, "file fdopen failed path=%s errno=%d", output->temporary, errno); return -1; } - DEBUG_DEBUG(DEBUG_CATEGORY_SYS, "file temporary opened path=%s", output->temporary); + DEBUG_DEBUG(DEBUG_CATEGORY_SYS, "file temporary opened path=%s durable=%u", output->temporary, flags & FILE_IO_DURABLE); return 0; } DEBUG_ERROR(DEBUG_CATEGORY_SYS, "file temporary collision limit destination=%s", destination); return -1; @@ -180,9 +257,12 @@ int file_io_temp_open(struct file_io_temp *output, const char *destination) { int file_io_temp_finish(struct file_io_temp *output) { if (!output || !output->file) { DEBUG_ERROR(DEBUG_CATEGORY_SYS, "file finish without open file"); return -1; } int error = fflush(output->file) ? (errno ? errno : EIO) : 0; + if (!error && (output->flags & FILE_IO_DURABLE) && file_io_sync_descriptor(output->file, output->temporary)) + error = errno ? errno : EIO; if (fclose(output->file) && !error) error = errno ? errno : EIO; output->file = NULL; - if (error) { DEBUG_ERROR(DEBUG_CATEGORY_SYS, "file flush/close failed path=%s errno=%d", output->temporary, error); return -1; } + if (error) { DEBUG_ERROR(DEBUG_CATEGORY_SYS, "file flush/sync/close failed path=%s errno=%d", output->temporary, error); return -1; } + DEBUG_DEBUG(DEBUG_CATEGORY_SYS, "file temporary finished path=%s durable=%u", output->temporary, output->flags & FILE_IO_DURABLE); return 0; } @@ -212,25 +292,15 @@ int file_io_temp_publish(struct file_io_temp *output, const char *source, const if (!output || output->file || !output->temporary[0] || file_io_paths(source, destination)) { DEBUG_ERROR(DEBUG_CATEGORY_SYS, "file publish invalid state/paths"); return -1; } -#ifdef _WIN32 - wchar_t from[FILE_IO_PATH_MAX], to[FILE_IO_PATH_MAX]; - if (file_io_wide(output->temporary, from) || file_io_wide(destination, to)) return -1; TEMP_LOCK(); - if (!MoveFileExW(from, to, MOVEFILE_REPLACE_EXISTING)) { - DWORD error = GetLastError(); TEMP_UNLOCK(); - DEBUG_ERROR(DEBUG_CATEGORY_SYS, "file publish failed destination=%s winerr=%lu", destination, error); return -1; - } -#else - TEMP_LOCK(); - if (rename(output->temporary, destination)) { - int error = errno; TEMP_UNLOCK(); - DEBUG_ERROR(DEBUG_CATEGORY_SYS, "file publish failed destination=%s errno=%d", destination, error); return -1; - } -#endif + if (file_io_rename(output->temporary, destination, output->flags)) { TEMP_UNLOCK(); return -1; } file_io_temp_untrack(output); output->temporary[0] = 0; TEMP_UNLOCK(); - DEBUG_DEBUG(DEBUG_CATEGORY_SYS, "file published destination=%s", destination); + if ((output->flags & FILE_IO_DURABLE) && file_io_sync_directory(destination)) { + DEBUG_ERROR(DEBUG_CATEGORY_SYS, "file temporary installed but directory durability unconfirmed destination=%s", destination); return -1; + } + DEBUG_DEBUG(DEBUG_CATEGORY_SYS, "file published destination=%s durable=%u", destination, output->flags & FILE_IO_DURABLE); return 0; } diff --git a/lib/file_io.h b/lib/file_io.h index 27fd7eb5..6cadbeec 100644 --- a/lib/file_io.h +++ b/lib/file_io.h @@ -4,7 +4,8 @@ * Активный temp автоматически защищён от remove_orphan во всех потоках до publish/discard, включая время после finish. * Пока temp активен, структуру нельзя копировать/перемещать; с одним temp работает один владелец. * Исходник и каталоги должны оставаться стабильными на время операции. Это не защита от подмены родительских каталогов. - * Publish заменяет имя атомарно, но не обещает сохранность при аварии питания. POSIX temp имеет права 0600. */ + * FILE_IO_DURABLE сохраняет данные при finish и имя при publish. POSIX temp имеет права 0600. + * После успешного rename temp отсоединён даже при ошибке синхронизации каталога: отката публикации нет. */ #ifndef FILE_IO_H #define FILE_IO_H #include @@ -12,6 +13,7 @@ #include #define FILE_IO_PATH_MAX 4096 +#define FILE_IO_DURABLE 1u enum file_io_type { FILE_IO_REGULAR = 1, FILE_IO_DIRECTORY, FILE_IO_LINK, FILE_IO_OTHER }; struct file_io_info { int type; // обычный файл, каталог, ссылка/reparse point или другой объект @@ -22,6 +24,7 @@ struct file_io_temp { char temporary[FILE_IO_PATH_MAX]; // только собственный temp; пусто после discard/publish FILE *file; // открытый wb+; NULL после finish struct file_io_temp *next_owned; // внутренний реестр живых temp; изменяет только file_io под блокировкой + unsigned flags; // режим, заданный open: 0 либо FILE_IO_DURABLE на весь срок операции }; // path — UTF-8; follow!=0 разрешает конечную ссылку. info обязателен, заполняется только при успехе. @@ -36,13 +39,22 @@ FILE *file_io_open_read(const char *path); FILE *file_io_open_update(const char *path); // Источник должен быть regular; назначение отсутствует или regular без конечной ссылки. 1 — один файл, 0 — разные, -1 — ошибка. int file_io_paths(const char *source, const char *destination); +// Сохранить существующий закрытый regular-файл без конечной ссылки; UTF-8, 0/-1. +int file_io_sync_file(const char *path); +// Сохранить каталог, содержащий path; без разделителя используется '.', для '/file' — '/'. UTF-8, 0/-1. +int file_io_sync_directory(const char *path); +// Переместить отдельный готовый файл с durable заменой destination; caller владеет source. 0/-1. +// Ошибка после rename не откатывает замену; сохраняется каталог назначения. Файлы/каталоги не подменяются извне. +int file_io_publish(const char *source, const char *destination); // Создать эксклюзивный temp рядом с destination; структура заранее обнулена, даже после ошибки вызвать discard. -int file_io_temp_open(struct file_io_temp *output, const char *destination); -// Проверить flush/close, обнулить file при любом результате; temp остаётся собственностью output. +// flags=0 — flush/close и atomic rename; FILE_IO_DURABLE — также sync файла и каталога назначения. +int file_io_temp_open(struct file_io_temp *output, const char *destination, unsigned flags); +// Проверить flush/[sync]/close, обнулить file при любом результате; temp остаётся собственностью output. int file_io_temp_finish(struct file_io_temp *output); // Закрыть/удалить собственный temp; повторный вызов допустим. Ошибки очистки логируются. void file_io_temp_discard(struct file_io_temp *output); -// Повторно проверить paths и заменить destination закрытым temp; одинаковые файлы отклоняются. +// Повторно проверить paths и заменить destination закрытым соседним temp; одинаковые файлы отклоняются. 0/-1. +// При ошибке до rename temporary сохраняется; после rename он пуст, в том числе при отказе directory sync. int file_io_temp_publish(struct file_io_temp *output, const char *source, const char *destination); /* Удалить уже признанный сиротой файл, атомарно проверив владение file_io_temp во всех потоках. * 1 — удалён, 0 — активный temp или уже отсутствует, -1 — ошибка (логируется). diff --git a/src/chat/chat_attachment.c b/src/chat/chat_attachment.c index c12502d7..a6babc9e 100644 --- a/src/chat/chat_attachment.c +++ b/src/chat/chat_attachment.c @@ -23,11 +23,13 @@ enum { AK_REQUEST = 1, AK_REQUEST_ACK, AK_GRANT, AK_DENY, AK_RESULT_ACK }; struct ak_file { struct UTUN_INSTANCE* inst; - char channel[32], source[1024], cipher[1024], plain[1024], temporary[1040], type[32]; + char channel[32], source[1024], cipher[1024], plain[1024], type[32]; + struct file_io_temp output; // Единственный собственный temp; стабильный адрес до completion. uint8_t id[16], key[32], message[32], hash[32], reply[32]; struct attachment_info info; uint64_t author, request; int encrypt, error; + int plain_prepared, published; // Только реально подготовленный snapshot и выполненный rename дают право очистки. }; /* Все ошибки SQLite содержат конкретную операцию. */ @@ -94,10 +96,8 @@ invalid: /* Разделённые деревья исключают раздачу plaintext и удаление его обходом orphan media. */ static int ak_paths(struct ak_file* job) { - const char* slash = strrchr(CC(job->inst)->db_path, '/'); char base[512], hex[33]; ak_hex(job->id, hex); - if (slash) snprintf(base, sizeof(base), "%.*s", (int)(slash - CC(job->inst)->db_path), CC(job->inst)->db_path); - else snprintf(base, sizeof(base), "."); + if (media_index_get_base(CC(job->inst)->db, base)) return -1; int a = snprintf(job->cipher, sizeof(job->cipher), "%s/media/%s/%s.uenc", base, job->channel, hex); int b = snprintf(job->plain, sizeof(job->plain), "%s/decrypted/%s/%s.file", base, job->channel, hex); if (a < 0 || b < 0 || (size_t)a >= sizeof(job->cipher) || (size_t)b >= sizeof(job->plain)) { @@ -121,12 +121,18 @@ static int ak_parent(const char* path) { static void ak_file_work(void* arg) { struct ak_file* job = arg; job->error = -1; if (ak_parent(job->plain) || ak_parent(job->cipher)) return; - snprintf(job->temporary, sizeof(job->temporary), "%s.part", job->encrypt ? job->cipher : job->plain); if (job->encrypt) { - if (ma_copy_file(job->source, job->plain) || encrypted_file_sync(job->plain) || encrypted_file_sync_directory(job->plain) || - encrypted_file_write(job->plain, job->temporary, job->key, job->author, job->id, &job->info, job->type)) return; - } else if (encrypted_file_read(job->cipher, job->temporary, job->key, job->author, job->id, job->hash, &job->info, job->type)) return; - if (encrypted_file_publish(job->temporary, job->encrypt ? job->cipher : job->plain)) return; + if (ma_copy_file(job->source, job->plain)) return; + job->plain_prepared = 1; + if (file_io_sync_file(job->plain) || file_io_sync_directory(job->plain) || + encrypted_file_write(job->plain, job->cipher, &job->output, job->key, job->author, job->id, &job->info, job->type)) return; + } else if (encrypted_file_read(job->cipher, job->plain, &job->output, job->key, job->author, job->id, + job->hash, &job->info, job->type)) return; + int error = file_io_temp_publish(&job->output, job->encrypt ? job->plain : job->cipher, job->encrypt ? job->cipher : job->plain); + job->published = !job->output.temporary[0]; // Rename уже выполнен и при последующем отказе directory sync. + DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "attachment keys: output publication encrypt=%d installed=%d error=%d", + job->encrypt, job->published, error); + if (error) return; job->error = 0; } @@ -174,11 +180,12 @@ static void ak_file_done(void* arg, int error) { if (job->encrypt) chat_event_submitted(inst, job->request, 0); } if (job->encrypt && remove(job->source) && errno != ENOENT) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: prepared source cleanup failed"); - if ((!success || !valid) && job->temporary[0] && remove(job->temporary) && errno != ENOENT) - DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: temporary cleanup failed"); + file_io_temp_discard(&job->output); if (!valid) { - if (remove(job->plain) && errno != ENOENT) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: deleted channel plaintext cleanup failed"); - if (job->encrypt && remove(job->cipher) && errno != ENOENT) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: deleted channel ciphertext cleanup failed"); + if (((job->encrypt && job->plain_prepared) || (!job->encrypt && job->published)) && remove(job->plain) && errno != ENOENT) + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: deleted channel plaintext cleanup failed"); + if (job->encrypt && job->published && remove(job->cipher) && errno != ENOENT) + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: deleted channel ciphertext cleanup failed"); } if (valid) { if (success && !job->encrypt) chat_media_cache_enforce(inst); @@ -544,9 +551,52 @@ static void ak_tick(void* arg) { if (!CC(inst)->attachment_timer) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: timer allocation failed"); } +struct ak_temp_cleanup { const char* root; const char* extension; unsigned removed; }; + +/* Только /..tmp.; чужие имена и активные owners сохраняются. */ +static void ak_remove_temp(void* arg, const char* path, int64_t size) { + (void)size; + struct ak_temp_cleanup* cleanup = arg; + size_t prefix = strlen(cleanup->root); + if (strncmp(path, cleanup->root, prefix) || path[prefix] != '/') return; + const char* relative = path + prefix + 1; + const char* slash = strchr(relative, '/'); + if (!slash || slash - relative >= 20) return; + char channel[20]; memcpy(channel, relative, (size_t)(slash - relative)); channel[slash - relative] = 0; + if (!ak_channel(channel)) return; + const char* name = slash + 1; + const char* hex = "0123456789abcdef"; + if (strlen(name) != 74 || strspn(name, hex) != 32 || memcmp(name + 32, cleanup->extension, 5) || + memcmp(name + 37, ".tmp.", 5) || strspn(name + 42, hex) != 32) return; + int removed = file_io_remove_orphan(path); + if (removed > 0) cleanup->removed++; + else if (removed < 0) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: temporary recovery failed path=%s", path); +} + +/* И при crash до публикации, и при прерванной расшифровке temp не является готовым вложением. */ +static void ak_recover_temps(struct UTUN_INSTANCE* inst) { + char base[MEDIA_BASE_MAX]; + if (media_index_get_base(CC(inst)->db, base)) return; + const char* trees[] = {"media", "decrypted"}; + for (unsigned i = 0; i < 2; i++) { + char root[MEDIA_BASE_MAX + 16]; snprintf(root, sizeof(root), "%s/%s", base, trees[i]); + struct file_io_info info; + int status = file_io_inspect(root, 0, &info); + if (status == 1) continue; + if (status < 0) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: cannot inspect temporary tree path=%s", root); continue; } + struct ak_temp_cleanup cleanup = {.root = root, .extension = i ? ".file" : ".uenc"}; + if (ma_dir_walk(root, ak_remove_temp, &cleanup)) + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: temporary recovery incomplete path=%s removed=%u", root, cleanup.removed); + else DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "attachment keys: temporary recovery path=%s removed=%u", root, cleanup.removed); + } +} + /* После crash/отмены убрать только собственные подготовки, не имеющие сообщения. * Вызывается до запуска workers либо после их полного завершения. */ static void ak_recover_preparations(struct UTUN_INSTANCE* inst) { + ak_recover_temps(inst); + char base[MEDIA_BASE_MAX]; + if (media_index_get_base(CC(inst)->db, base)) return; char** channels = NULL; int count = 0, columns = 0; if (sqlite3_get_table(CC(inst)->db, "SELECT channel_id FROM channels", &channels, &count, &columns, NULL) != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: recovery channel scan failed: %s", sqlite3_errmsg(CC(inst)->db)); return; @@ -566,16 +616,14 @@ static void ak_recover_preparations(struct UTUN_INSTANCE* inst) { for (int row = 0; row < rows; row++) { for (int field = 0; field < fields; field++) { const char* path = paths[(row + 1) * fields + field]; - char temporary[1040]; snprintf(temporary, sizeof(temporary), "%s.part", path); - if ((remove(path) && errno != ENOENT) || (remove(temporary) && errno != ENOENT)) { + if (remove(path) && errno != ENOENT) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: unfinished file removal failed path=%s: %s", path, strerror(errno)); failed = 1; } } } sqlite3_free_table(paths); if (rows && !failed) { - const char* slash = strrchr(CC(inst)->db_path, '/'); - unsigned prefix = slash ? (unsigned)(slash - CC(inst)->db_path) : 1; + unsigned prefix = (unsigned)strlen(base); char cleanup[768]; snprintf(cleanup, sizeof(cleanup), "DELETE FROM media_files WHERE chat_id='%s' AND location IN" "(SELECT substr(a.cipher_path,%u) FROM attachment_keys a WHERE %s)", channel, prefix + 2, condition); diff --git a/src/chat/chat_msg.c b/src/chat/chat_msg.c index b6b86414..467d71bd 100644 --- a/src/chat/chat_msg.c +++ b/src/chat/chat_msg.c @@ -1683,7 +1683,7 @@ static void chat_media_cache_cleanup_orphans(struct UTUN_INSTANCE* inst) { for (int i = 0; i < rels.count; i++) u_free(rels.items[i]); u_free(rels.items); return; } - /* Ещё не опубликованные контейнеры и их временные файлы принадлежат crypto worker. */ + /* Конечные контейнеры принадлежат attachment_keys; активные temp защищены file_io-реестром. */ if (cc->attachment_initialized) { sqlite3_stmt* active = NULL; if (sqlite3_prepare_v2(cc->db, "SELECT cipher_path FROM attachment_keys", -1, &active, NULL) == SQLITE_OK) { @@ -1693,8 +1693,6 @@ static void chat_media_cache_cleanup_orphans(struct UTUN_INSTANCE* inst) { size_t prefix = strlen(media_base); if (path && !strncmp(path, media_base, prefix) && path[prefix] == '/') { cache_rel_add(&rels, path + prefix + 1); - char temporary[1040]; snprintf(temporary, sizeof(temporary), "%s.part", path + prefix + 1); - cache_rel_add(&rels, temporary); } } if (rc != SQLITE_DONE) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "encrypted cache: active scan failed rc=%d", rc); diff --git a/src/dm/dm_media.c b/src/dm/dm_media.c index 8b015715..71e39096 100644 --- a/src/dm/dm_media.c +++ b/src/dm/dm_media.c @@ -154,7 +154,7 @@ static void dm_cleanup_pump(struct dm_media_state* media); static void dm_cleanup_work(void* arg) { struct dm_cleanup_job* job = arg; job->error = dm_file_remove(job->path); - if (!job->error) job->error = encrypted_file_sync_directory(job->path); + if (!job->error) job->error = file_io_sync_directory(job->path); } static void dm_cleanup_done(void* arg, int error) { @@ -488,7 +488,7 @@ static void dm_file_work(void* arg) { struct dm_file_job* job = arg; job->error = -1; if (job->work == DM_FILE_DELETE) { - if (!dm_file_remove(job->cipher) && !dm_file_remove(job->temp)) job->error = encrypted_file_sync_directory(job->cipher); + if (!dm_file_remove(job->cipher) && !dm_file_remove(job->temp)) job->error = file_io_sync_directory(job->cipher); return; } if (job->work == DM_FILE_PREPARE) { @@ -530,9 +530,9 @@ done: if (out && fclose(out)) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: worker output close failed"); job->error = -1; } if (!job->error) { if (job->work == DM_FILE_PREPARE) { - job->error = encrypted_file_publish(job->temp, job->cipher); - if (!job->error) job->error = encrypted_file_sync_directory(job->plain); - } else job->error = encrypted_file_publish(job->work == DM_FILE_CUSTODY ? job->cipher : job->temp, job->output); + job->error = file_io_publish(job->temp, job->cipher); + if (!job->error) job->error = file_io_sync_directory(job->plain); + } else job->error = file_io_publish(job->work == DM_FILE_CUSTODY ? job->cipher : job->temp, job->output); } } diff --git a/src/media_async/encrypted_file.c b/src/media_async/encrypted_file.c index 8e50445f..ad61a226 100644 --- a/src/media_async/encrypted_file.c +++ b/src/media_async/encrypted_file.c @@ -10,118 +10,85 @@ #include #include #include -#include -#ifdef _WIN32 -#include -#endif - -/* Сделать создание/удаление имён durable на POSIX. */ -int encrypted_file_sync_directory(const char* path) { -#ifndef _WIN32 - char parent[1024]; - snprintf(parent, sizeof(parent), "%s", path); - char* slash = strrchr(parent, '/'); - if (!slash) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "encrypted file: path without parent: %s", path); return -1; } - *slash = '\0'; - int fd = open(parent, O_RDONLY | O_DIRECTORY); - if (fd < 0) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "encrypted file: directory open failed: %s: %s", parent, strerror(errno)); return -1; } - int rc = fsync(fd); - if (rc) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "encrypted file: directory sync failed: %s: %s", parent, strerror(errno)); - close(fd); - return rc ? -1 : 0; -#else - (void)path; - return 0; -#endif -} - -/* Windows MoveFileEx обеспечивает замену и write-through существующего артефакта. */ -int encrypted_file_publish(const char* temp, const char* path) { -#ifdef _WIN32 - if (!MoveFileExA(temp, path, MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { - DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "encrypted file: publish failed %s -> %s winerr=%lu", temp, path, GetLastError()); - return -1; - } -#else - if (rename(temp, path) != 0) { - DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "encrypted file: publish failed %s -> %s: %s", temp, path, strerror(errno)); - return -1; - } -#endif - return encrypted_file_sync_directory(path); -} #define EF_HEADER 66 #define EF_META (32 + ATTACHMENT_WIRE_MAX) -/* Гарантировать сохранение файла перед публикацией или подтверждением. */ -int encrypted_file_sync(const char* path) { - FILE* f = fopen(path, "rb+"); - if (!f) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "encrypted file: sync open failed path=%s: %s", path, strerror(errno)); return -1; } -#ifdef _WIN32 - int rc = _commit(_fileno(f)); -#else - int rc = fsync(fileno(f)); -#endif - if (fclose(f)) rc = -1; - if (rc) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "encrypted file: sync failed path=%s: %s", path, strerror(errno)); - return rc ? -1 : 0; -} - -/* Записать контейнер; частичный destination удаляется при любой ошибке. */ -int encrypted_file_write(const char* source, const char* destination, const uint8_t key[32], +/* Подготовить контейнер в эксклюзивном temp; конечный target не открывается для записи. */ +int encrypted_file_write(const char* source, const char* target, struct file_io_temp* output, const uint8_t key[32], uint64_t author, const uint8_t id[16], const struct attachment_info* info, const char* type) { + if (!output || output->file || output->temporary[0]) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "encrypted file: write requires inactive output owner"); return -1; + } FILE* in = NULL; FILE* out = NULL; int rc = -1; uint8_t header[EF_HEADER] = {0}, meta[EF_META] = {0}, cipher[EF_META + 16], nonce[13], hash[32]; size_t length = 0; uint64_t size = 0; - if (!source || !destination || !strcmp(source, destination) || !key || !author || !id || + const char* reason = "invalid arguments"; + if (!source || !target || !key || !author || !id || !type || strlen(type) >= 32 || attachment_validate(info)) goto done; + reason = "source/target alias or invalid file type"; + if (file_io_paths(source, target)) goto done; + reason = "cannot open source"; + in = file_io_open_read(source); + if (!in) goto done; + reason = "metadata encoding/encryption failed"; int n = attachment_encode(info, meta + 32, ATTACHMENT_WIRE_MAX); if (n < 0) goto done; memcpy(meta, type, strlen(type)); media_cipher_nonce(author, id, UINT64_MAX, nonce); if (media_ccm_encrypt(key, nonce, meta, (size_t)n + 32, cipher, &length)) goto done; - in = fopen(source, "rb"); out = fopen(destination, "wb"); - if (!in || !out) goto done; + reason = "cannot create private output"; + if (file_io_temp_open(output, target, FILE_IO_DURABLE)) goto done; + out = output->file; + reason = "container write/encryption failed"; memcpy(header, "UTENC001", 8); memcpy(header + 8, id, 16); header[32] = (uint8_t)(length >> 8); header[33] = (uint8_t)length; if (fwrite(header, 1, sizeof(header), out) != sizeof(header) || fwrite(cipher, 1, length, out) != length || media_encrypt_stream(key, author, id, in, out, &size, hash)) goto done; for (unsigned i = 0; i < 8; i++) header[24 + i] = (uint8_t)(size >> (56 - i * 8)); memcpy(header + 34, hash, 32); - if (fseeko(out, 0, SEEK_SET) || fwrite(header, 1, sizeof(header), out) != sizeof(header) || fflush(out)) goto done; + reason = "cannot rewrite container header"; + if (fseeko(out, 0, SEEK_SET) || fwrite(header, 1, sizeof(header), out) != sizeof(header)) goto done; + reason = "source close or output durable finish failed"; rc = 0; done: OPENSSL_cleanse(meta, sizeof(meta)); if (in && fclose(in)) rc = -1; - if (out && fclose(out)) rc = -1; - if (!rc) rc = encrypted_file_sync(destination); + if (!rc) rc = file_io_temp_finish(output); if (rc) { - DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "encrypted file: preparation failed source=%s: %s", source ? source : "?", strerror(errno)); - if (out && remove(destination) && errno != ENOENT) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "encrypted file: partial cleanup failed"); - } else DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "encrypted file: prepared author=%llu bytes=%llu", (unsigned long long)author, - (unsigned long long)size); + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "encrypted file: preparation rejected source=%s target=%s reason=%s", + source ? source : "?", target ? target : "?", reason); + file_io_temp_discard(output); + } else DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "encrypted file: prepared author=%llu bytes=%llu temp=%s", + (unsigned long long)author, (unsigned long long)size, output->temporary); return rc; } /* Проверить подписанный hash, метаданные и все порции до разрешения публикации. */ -int encrypted_file_read(const char* source, const char* destination, const uint8_t key[32], uint64_t author, +int encrypted_file_read(const char* source, const char* target, struct file_io_temp* output, const uint8_t key[32], uint64_t author, const uint8_t id[16], const uint8_t hash[32], struct attachment_info* info, char type[32]) { + if (!output || output->file || output->temporary[0]) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "encrypted file: read requires inactive output owner"); return -1; + } FILE* in = NULL; FILE* out = NULL; + struct attachment_info result; + char result_type[32]; int rc = -1; uint8_t actual[32], header[EF_HEADER], meta[EF_META], cipher[EF_META + 16], nonce[13]; size_t length = 0; uint64_t size = 0; const char* reason = "invalid arguments"; - if (!source || !destination || !strcmp(source, destination) || !key || !author || !id || !hash || !info || !type) goto done; + if (!source || !target || !key || !author || !id || !hash || !info || !type) goto done; + reason = "source/target alias or invalid file type"; + if (file_io_paths(source, target)) goto done; reason = "cannot hash ciphertext"; if (ma_sha256_file(source, actual)) goto done; reason = "ciphertext does not match signed manifest hash"; if (CRYPTO_memcmp(actual, hash, 32)) goto done; reason = "cannot open ciphertext or truncated header"; - in = fopen(source, "rb"); + in = file_io_open_read(source); if (!in || fread(header, 1, sizeof(header), in) != sizeof(header)) goto done; reason = "unsupported container format or mismatched UUID"; if (memcmp(header, "UTENC001", 8) || memcmp(header + 8, id, 16)) goto done; @@ -134,14 +101,14 @@ int encrypted_file_read(const char* source, const char* destination, const uint8 if (media_ccm_decrypt(key, nonce, cipher, encrypted_size, meta, &length)) goto done; reason = "invalid attachment metadata"; if (length < 32 || - !memchr(meta, 0, 32) || !meta[0] || attachment_decode(meta + 32, length - 32, info)) goto done; + !memchr(meta, 0, 32) || !meta[0] || attachment_decode(meta + 32, length - 32, &result)) goto done; reason = "invalid content type"; for (unsigned i = 0; i < 32 && meta[i]; i++) if (meta[i] < 33 || meta[i] > 126 || meta[i] == '"' || meta[i] == '\\') goto done; - memcpy(type, meta, 32); + memcpy(result_type, meta, 32); for (unsigned i = 0; i < 8; i++) size = (size << 8) | header[24 + i]; - out = fopen(destination, "wb"); reason = "cannot open temporary plaintext"; - if (!out) goto done; + if (file_io_temp_open(output, target, FILE_IO_DURABLE)) goto done; + out = output->file; reason = "file authentication, size or body hash failed"; if (media_decrypt_stream(key, author, id, size, header + 34, in, out)) goto done; reason = "close or durable file sync failed"; @@ -149,12 +116,15 @@ int encrypted_file_read(const char* source, const char* destination, const uint8 done: OPENSSL_cleanse(meta, sizeof(meta)); if (in && fclose(in)) rc = -1; - if (out && fclose(out)) rc = -1; - if (!rc) rc = encrypted_file_sync(destination); + if (!rc) rc = file_io_temp_finish(output); if (rc) { - DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "encrypted file: rejected source=%s reason=%s", source ? source : "?", reason); - if (out && remove(destination) && errno != ENOENT) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "encrypted file: rejected output cleanup failed"); - } else DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "encrypted file: verified author=%llu bytes=%llu", (unsigned long long)author, - (unsigned long long)size); + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "encrypted file: rejected source=%s target=%s reason=%s", + source ? source : "?", target ? target : "?", reason); + file_io_temp_discard(output); + } else { + *info = result; memcpy(type, result_type, sizeof(result_type)); + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "encrypted file: verified author=%llu bytes=%llu temp=%s", + (unsigned long long)author, (unsigned long long)size, output->temporary); + } return rc; } diff --git a/src/media_async/encrypted_file.h b/src/media_async/encrypted_file.h index 7afc3438..8e1e1b3f 100644 --- a/src/media_async/encrypted_file.h +++ b/src/media_async/encrypted_file.h @@ -1,23 +1,27 @@ /* Контейнер группового вложения: публичный заголовок, зашифрованные метаданные и * порции AES-256-CCM. Полный SHA256 контейнера берётся из подписанного сообщения. - * Все функции файловые, вызываются только в media_async worker. Выход decrypt — - * временный файл; публиковать его можно только после успеха и durable сохранения. */ + * Вызывать только в media_async worker. Write/read готовят отдельный durable temp, + * конечное имя публикует владелец через file_io_temp_publish. Source не изменяется. */ #ifndef ENCRYPTED_FILE_H #define ENCRYPTED_FILE_H #include "attachment.h" +#include "../../lib/file_io.h" #ifdef __cplusplus extern "C" { #endif #define ENCRYPTED_ATTACHMENT_TYPE "application/x-utun-encrypted" -int encrypted_file_write(const char* source, const char* destination, const uint8_t key[32], +/* source/target — UTF-8 пути разных файлов; target отсутствует либо regular без конечной ссылки. + * output заранее обнулён и имеет стабильный адрес до publish/discard; активный output отклоняется без очистки. + * 0 — output содержит закрытый проверенный temp рядом с target, target ещё не изменён. + * -1 — ошибка; source/target сохранены, собственный temp передан discard (ошибки удаления логируются). + * Ключ — 32 байта, UUID — 16, author!=0. + * Пути и файлы не подменяются во время вызова; публикация повторно проверяет их идентичность. */ +int encrypted_file_write(const char* source, const char* target, struct file_io_temp* output, const uint8_t key[32], uint64_t author, const uint8_t id[16], const struct attachment_info* info, const char* type); -int encrypted_file_read(const char* source, const char* destination, const uint8_t key[32], uint64_t author, +/* Проверить подписанный hash всего контейнера, UUID, метаданные и все tags. Контракт output — как у write. + * info/type[32] присваиваются только при успехе; при ошибке прежние значения сохраняются. */ +int encrypted_file_read(const char* source, const char* target, struct file_io_temp* output, const uint8_t key[32], uint64_t author, const uint8_t id[16], const uint8_t hash[32], struct attachment_info* info, char type[32]); -/* Flush/fsync и close проверяет владелец; этот примитив сохраняет уже закрытый файл. */ -int encrypted_file_sync(const char* path); -/* Durable rename (write-through на Windows) и сохранение каталога на POSIX. */ -int encrypted_file_publish(const char* temporary, const char* path); -int encrypted_file_sync_directory(const char* path); #ifdef __cplusplus } #endif diff --git a/src/media_async/media_async.c b/src/media_async/media_async.c index b9f5a792..3a19ebea 100644 --- a/src/media_async/media_async.c +++ b/src/media_async/media_async.c @@ -164,7 +164,7 @@ int ma_copy_file(const char* source, const char* destination) { int status = file_io_paths(source, destination); uint64_t total = 0; if (status == 0) phase = "create temporary"; - if (status == 0 && file_io_temp_open(&output, destination)) status = -1; + if (status == 0 && file_io_temp_open(&output, destination, 0)) status = -1; if (status == 0) { uint8_t buffer[65536]; size_t count; while (1) { diff --git a/src/media_delivery/media_delivery.c b/src/media_delivery/media_delivery.c index 4e705ac3..b752c9c9 100644 --- a/src/media_delivery/media_delivery.c +++ b/src/media_delivery/media_delivery.c @@ -277,7 +277,7 @@ struct media_block_source* md_block_create(struct media_delivery_ctx* md, uint64 source->entry = entry; source->md = md; source->refs = 1; source->owned = 1; source->group_id = group_id; source->chunk = chunk; source->size = size; memcpy(source->media_id, media_id, 16); memcpy(source->block_id, block_id, 16); - if (file_io_temp_open(&source->storage, destination) || file_io_temp_finish(&source->storage)) { + if (file_io_temp_open(&source->storage, destination, 0) || file_io_temp_finish(&source->storage)) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "%s: block temp creation failed chunk=%u", MD_ID, chunk); md_block_release(source); return NULL; } diff --git a/src/media_delivery/media_download.c b/src/media_delivery/media_download.c index 39c23fc1..31a6d239 100644 --- a/src/media_delivery/media_download.c +++ b/src/media_delivery/media_download.c @@ -633,7 +633,7 @@ static int md_dl_register_servable(struct media_download* dl) { /* Запись назначения начинается только после проверки блоков. До publish старое имя сохраняет прежние байты. */ static int md_dl_assemble(struct media_download* dl) { struct file_io_temp output = {0}; - int failed = file_io_temp_open(&output, dl->dest_path); + int failed = file_io_temp_open(&output, dl->dest_path, 0); uint64_t total = 0; uint8_t buffer[65536]; for (int bi = 0; bi < dl->num_blocks && !failed; bi++) { diff --git a/src/transport_layer/etcp.c b/src/transport_layer/etcp.c index e4325cd7..16f8c011 100644 --- a/src/transport_layer/etcp.c +++ b/src/transport_layer/etcp.c @@ -29,7 +29,6 @@ // Constants from spec (adjusted for completeness) #define MAX_INFLIGHT_BYTES 65536 // Initial window -#define ACK_DELAY_TB 20 // ACK timer delay (2ms in 0.1ms units) #define BURST_DELAY_FACTOR 4 // Delay before burst #define BURST_SIZE 5 // Packets in burst (1 delayed + 4 burst) #define RTT_HISTORY_SIZE 10 // For jitter calc @@ -56,7 +55,7 @@ static void etcp_schedule_send(struct ETCP_CONN* conn, int delay_tb) { if (!conn || conn->close_requested || conn->state == 2 || conn->tx_token) return; if (!conn->initialized || conn->session_required || conn->reinit_pending) return; if (conn->tx_running && !delay_tb) return; - int work = conn->input_send_q->count || conn->input_queue->count || conn->ack_till_pending || conn->ack_request_pending; + int work = conn->input_send_q->count || conn->input_queue->count || conn->ack_till_deadline_us || conn->ack_request_pending; for (struct ETCP_LINK* link = conn->links; !work && link; link = link->next) work = link->burst_active || link->ack_reply_pending || (link->ack_q && link->ack_q->count); if (!work) return; @@ -463,6 +462,7 @@ void etcp_connection_close(struct ETCP_CONN* etcp) { // Cancel active timers if (etcp->ack_resp_timer) { uasync_cancel_timeout(etcp->instance->ua, etcp->ack_resp_timer); etcp->ack_resp_timer = NULL; } + etcp->ack_resp_deadline_us = etcp->ack_till_deadline_us = 0; if (etcp->ack_window_timer) { uasync_cancel_timeout(etcp->instance->ua, etcp->ack_window_timer); etcp->ack_window_timer = NULL; } routing_del_conn(etcp); @@ -522,11 +522,12 @@ void etcp_conn_reset(struct ETCP_CONN* conn) { for (struct ETCP_LINK* link = conn->links; link; link = link->next) etcp_tx_link_reset(link, 0); etcp_cancel_send(conn); if (conn->ack_resp_timer) { uasync_cancel_timeout(conn->instance->ua, conn->ack_resp_timer); conn->ack_resp_timer = NULL; } + conn->ack_resp_deadline_us = 0; if (conn->ack_window_timer) { uasync_cancel_timeout(conn->instance->ua, conn->ack_window_timer); conn->ack_window_timer = NULL; } queue_clear(conn->input_queue); queue_clear(conn->output_queue); queue_clear(conn->input_send_q); queue_clear(conn->input_wait_ack); queue_clear(conn->recv_q); if (conn->normalizer) pn_reset(conn->normalizer); - conn->ack_till_pending = conn->ack_request_pending = 0; + conn->ack_till_deadline_us = conn->ack_request_pending = 0; conn->next_tx_id = 1; conn->last_rx_id = conn->last_delivered_id = conn->rx_ack_till = 0; conn->got_initial_pkt = 0; conn->unacked_bytes = conn->rtt_last = conn->rtt_avg_10 = conn->jitter = conn->last_rtt_cb_time = 0; @@ -794,7 +795,7 @@ static void input_queue_try_resume(struct ETCP_CONN* etcp) {// при ACK if (etcp->unacked_bytes < etcp->optimal_inflight) { DEBUG_TRACE(DEBUG_CATEGORY_ETCP, "[%s] resume input queue: unacked_bytes=%u input_len=%zu", etcp->log_name, etcp->unacked_bytes, etcp->input_queue->total_bytes); - queue_resume_callback(etcp->input_queue);// и только когда больше нечего отправлять - забираем новый пакет + queue_resume_callback(etcp->input_queue); // освобождённый общий бюджет допускает новые DATA и при непустой send_q } } @@ -962,16 +963,48 @@ void etcp_on_link_down(struct ETCP_CONN* etcp, struct ETCP_LINK* down_link) { static void ack_response_timer_cb(void* arg) { struct ETCP_CONN* conn = arg; - conn->ack_resp_timer = NULL; - etcp_conn_wake_sender(conn); + DEBUG_TRACE(DEBUG_CATEGORY_ETCP, "[%s] ACK timer fired due_us=%llu now_us=%llu", conn->log_name, + (unsigned long long)conn->ack_resp_deadline_us, (unsigned long long)get_time_us()); + conn->ack_resp_timer = NULL; conn->ack_resp_deadline_us = 0; + etcp_conn_schedule_ack(conn); } -static void ack_schedule(struct ETCP_CONN* conn) { - if (conn->ack_resp_timer || conn->close_requested) return; - conn->ack_resp_timer = uasync_set_timeout(conn->instance->ua, ACK_DELAY_TB, conn, ack_response_timer_cb, "etcp_ack_resp"); +/* Один таймер обслуживает ближайший абсолютный срок: новый RX не отодвигает старые ACK. */ +void etcp_conn_schedule_ack(struct ETCP_CONN* conn) { + if (!conn) return; + uint64_t now = get_time_us(), deadline = 0; + if (!conn->close_requested && conn->state != 2 && conn->initialized && !conn->session_required && !conn->reinit_pending) { + deadline = conn->ack_till_deadline_us; + for (struct ETCP_LINK* link = conn->links; link; link = link->next) { + if (!link->initialized || !link->link_status) continue; + if (link->ack_reply_pending) { deadline = now; break; } + const struct ACK_PACKET* first = link->ack_q ? (struct ACK_PACKET*)link->ack_q->head : NULL; + if (first && (!deadline || first->received_at_us + ETCP_ACK_DELAY_US < deadline)) + deadline = first->received_at_us + ETCP_ACK_DELAY_US; + } + } + if (conn->ack_resp_timer && conn->ack_resp_deadline_us == deadline && deadline > now) return; + if (conn->ack_resp_timer) { + DEBUG_TRACE(DEBUG_CATEGORY_ETCP, "[%s] ACK timer replace old_us=%llu next_us=%llu", conn->log_name, + (unsigned long long)conn->ack_resp_deadline_us, (unsigned long long)deadline); + uasync_cancel_timeout(conn->instance->ua, conn->ack_resp_timer); conn->ack_resp_timer = NULL; + } + conn->ack_resp_deadline_us = 0; + if (!deadline) return; + if (deadline <= now) { etcp_conn_wake_sender(conn); return; } + int delay = (int)((deadline - now + 99) / 100); + conn->ack_resp_timer = uasync_set_timeout(conn->instance->ua, delay, conn, ack_response_timer_cb, "etcp_ack_resp"); if (!conn->ack_resp_timer) { - DEBUG_ERROR(DEBUG_CATEGORY_ETCP, "[%s] ACK delay timer allocation failed, requesting immediate send", conn->log_name); + DEBUG_ERROR(DEBUG_CATEGORY_ETCP, "[%s] ACK timer OOM due_us=%llu delay_tb=%d; forcing urgent ACK", conn->log_name, + (unsigned long long)deadline, delay); + for (struct ETCP_LINK* link = conn->links; link; link = link->next) + if (link->initialized && link->link_status && link->ack_q && link->ack_q->count) link->ack_reply_pending = 1; + if (conn->ack_till_deadline_us) conn->ack_till_deadline_us = now; etcp_conn_wake_sender(conn); + } else { + conn->ack_resp_deadline_us = deadline; + DEBUG_TRACE(DEBUG_CATEGORY_ETCP, "[%s] ACK timer arm due_us=%llu now_us=%llu delay_tb=%d", conn->log_name, + (unsigned long long)deadline, (unsigned long long)now, delay); } } @@ -1099,6 +1132,8 @@ struct ETCP_DGRAM* etcp_request_pkt(struct ETCP_CONN* conn) { if (conn->tx_state) conn->tx_state = ETCP_TX_STATE_LINK_WAIT; return NULL; } + etcp_conn_schedule_ack(conn); + if (conn->close_requested) return NULL; struct ETCP_LINK* link = NULL; struct INFLIGHT_PACKET* packet = send_queue_select(conn, &link); if (!packet) { @@ -1109,14 +1144,26 @@ struct ETCP_DGRAM* etcp_request_pkt(struct ETCP_CONN* conn) { conn->session_required || conn->reinit_pending) return NULL; if (pulled) packet = send_queue_select(conn, &link); } - if (!link) { - for (struct ETCP_LINK* candidate = conn->links; candidate; candidate = candidate->next) - if (candidate->initialized && candidate->link_status && - (candidate->ack_reply_pending || (candidate->ack_q && candidate->ack_q->count))) { - link = candidate; packet = NULL; break; - } + if (!link || !link->burst_active) { + uint64_t now = get_time_us(); + struct ETCP_LINK* ack_link = NULL; + uint64_t first_due = UINT64_MAX; + for (struct ETCP_LINK* candidate = conn->links; candidate; candidate = candidate->next) { + if (!candidate->initialized || !candidate->link_status) continue; + const struct ACK_PACKET* first = candidate->ack_q ? (struct ACK_PACKET*)candidate->ack_q->head : NULL; + if (!candidate->ack_reply_pending && !first) continue; + uint64_t due = candidate->ack_reply_pending ? 0 : first->received_at_us + ETCP_ACK_DELAY_US; + if (due <= now && (!ack_link || due < first_due)) { ack_link = candidate; first_due = due; } + } + if (ack_link) { + DEBUG_TRACE(DEBUG_CATEGORY_ETCP, "[%s] ACK due path=%u deadline_us=%llu now_us=%llu piggyback=%u", conn->log_name, + ack_link->local_link_id, (unsigned long long)first_due, (unsigned long long)now, ack_link == link && packet != NULL); + if (ack_link != link) packet = NULL; + link = ack_link; + } } - if (!link && (conn->ack_till_pending || conn->ack_request_pending)) { + if (!link && (conn->ack_request_pending || + (conn->ack_till_deadline_us && conn->ack_till_deadline_us <= get_time_us()))) { link = etcp_loadbalancer_select_link(conn, 0, NULL); packet = NULL; } if (!link) { @@ -1272,7 +1319,8 @@ void etcp_output_try_assembly(struct ETCP_CONN* etcp) { } if (delivered_count > 0 && !etcp->close_requested && !etcp->reinit_pending) { - etcp->ack_till_pending = 1; ack_schedule(etcp); + if (!etcp->ack_till_deadline_us) etcp->ack_till_deadline_us = get_time_us() + ETCP_ACK_DELAY_US; + etcp_conn_schedule_ack(etcp); DEBUG_DEBUG(DEBUG_CATEGORY_ETCP, "[%s] delivered %u fragments (%u bytes), till=%u recv_q=%d", etcp->log_name, delivered_count, delivered_bytes, etcp->last_delivered_id, etcp->recv_q->count); } @@ -1475,7 +1523,7 @@ void etcp_conn_input(struct ETCP_DGRAM* pkt) { pkt->link->rx_data_bytes += payload_len; pkt->link->rx_data_time_us = get_time_us(); } etcp->got_initial_pkt = 1; - if (etcp_tx_queue_ack(pkt->link, seq, attempt_id) == 0) ack_schedule(etcp); + if (etcp_tx_queue_ack(pkt->link, seq, attempt_id) == 0) etcp_conn_schedule_ack(etcp); DEBUG_DEBUG(DEBUG_CATEGORY_ETCP, "[%s] accepted DATA seq=%u attempt=%llu path=%u bytes=%u duplicate=%d", etcp->log_name, seq, (unsigned long long)attempt_no, pkt->link->local_link_id, payload_len, duplicate); if (distance == 1) etcp_output_try_assembly(etcp); diff --git a/src/transport_layer/etcp.h b/src/transport_layer/etcp.h index 92b28dd0..4d5075ea 100644 --- a/src/transport_layer/etcp.h +++ b/src/transport_layer/etcp.h @@ -66,6 +66,7 @@ uint16_t get_current_timestamp(void); #define INFLIGHT_STATE_WAIT_SEND 1 #define INFLIGHT_INITIAL_HASH_SIZE 1024 #define ETCP_LINK_HISTORY_BYTES 32 // по одному биту для локального слота линка 0..255 +#define ETCP_ACK_DELAY_US 2000 // обычный ACK-only: срок от старейшей записи; piggyback и срочный ответ не ждут #define MAX_INFLIGHT_SIZE 16384 // максимальное число элементов в inflight приёмной очереди (для предотвращения атак) #define ETCP_ACK_GAP_MAX_PACKETS 8192 // предел кумулятивного ACK за приём: 8192 × PACKET_DATA_SIZE(2048) = 16 MB (защита от DoS) @@ -197,9 +198,10 @@ struct ETCP_CONN { uint32_t optimal_inflight; // сумма индивидуальных окон; общий бюджет подгрузки новых DATA с учётом unacked_bytes // Timers - void* ack_resp_timer; // ACK send timer + void* ack_resp_timer; // один таймер ближайшего срока ACK всех линков/логической границы + uint64_t ack_resp_deadline_us; // абсолютный срок установленного таймера; 0 без таймера void* ack_window_timer; // запрос потерянного till, только при заполненном приёмном окне - uint8_t ack_till_pending; // нужно передать текущую логическую границу по рабочему пути + uint64_t ack_till_deadline_us; // срок передачи текущей логической границы по рабочему пути; 0 — нет запроса uint8_t ack_request_pending; // нужно запросить границу у пира void* down_timer; // удаление после непрерывного DOWN, включая первоначальное ожидание uint64_t down_deadline_tb; // сохраняется при наличии NCD handles после истечения таймера @@ -342,7 +344,9 @@ void etcp_conn_input(struct ETCP_DGRAM* pkt); int etcp_int_send(struct ETCP_CONN* etcp, const void* data, uint16_t len); /* Подготовить следующий пакет из pkt_pool либо NULL при ожидании/ошибке/отсутствии работы. - * Сканирует send_q: готовые повторы имеют приоритет, заблокированные записи пропускаются. + * Наступивший/срочный ACK своего пути имеет приоритет; готовый DATA этого пути забирает его piggyback. + * Сканирует send_q: готовые повторы идут перед новыми DATA, заблокированные записи пропускаются. + * Короткая измерительная burst-серия завершается до перехода к ACK другого пути. * При отсутствии кандидата допускает одну подгрузку в рамках общего/приёмного окна; callback может сделать reset/close. * Пакет с DATA содержит резерв попытки: etcp_loadbalancer_send завершает send/rollback и возвращает пакет в пул. */ struct ETCP_DGRAM* etcp_request_pkt(struct ETCP_CONN* etcp); @@ -351,6 +355,11 @@ struct ETCP_DGRAM* etcp_request_pkt(struct ETCP_CONN* etcp); * Используется очередями, ACK/RTO, изменением окна и подтверждением сессии. close/reset отменяет запуск. */ void etcp_conn_wake_sender(struct ETCP_CONN* conn); +/* Пересчитать единственный ACK-таймер по головам очередей и ack_till_deadline_us; просроченную работу передать sender. + * Вызвать после RX/продвижения till, завершения send и смены доступных путей. NULL/закрытый conn допустимы. + * Piggyback не ждёт сроков. При OOM таймера существующие флаги срочного ACK обеспечивают немедленный fallback. */ +void etcp_conn_schedule_ack(struct ETCP_CONN* conn); + // Process ACK receipt - remove acknowledged packet from inflight queues void etcp_ack_recv(struct ETCP_CONN* etcp, uint32_t seq); diff --git a/src/transport_layer/etcp_connections.h b/src/transport_layer/etcp_connections.h index c169245e..883b0ee0 100644 --- a/src/transport_layer/etcp_connections.h +++ b/src/transport_layer/etcp_connections.h @@ -264,7 +264,7 @@ struct ETCP_LINK { void* shaper_timer; struct ll_queue* attempts; // Физические попытки, внутренний ключ (seq<<16)|attempt_no; ll.len учитывается в окне struct ll_queue* ack_q; // ACK только DATA этого линка, тот же составной ключ - uint8_t ack_reply_pending; // Ответ на запрос till возвращается тем же путём, даже при пустой ACK-очереди + uint8_t ack_reply_pending; // срочный ACK: ответ till тем же путём либо fallback при OOM таймера; обходит ACK_DELAY void* retrans_timer; // Срок старейшей принятой транспортом попытки этого канала uint64_t retrans_deadline_us; // Уже запланированная проверка: более ранний таймер можно сохранить uint32_t data_rtt_us; // Сглаженный RTT DATA->ACK этого пути; 0 до первого измерения diff --git a/src/transport_layer/etcp_tx.c b/src/transport_layer/etcp_tx.c index b6242ad3..0ee6e65d 100644 --- a/src/transport_layer/etcp_tx.c +++ b/src/transport_layer/etcp_tx.c @@ -186,16 +186,17 @@ struct ETCP_TX_ATTEMPT* etcp_tx_prepare(struct ETCP_LINK* link, struct INFLIGHT_ } void etcp_tx_complete(struct ETCP_DGRAM* dgram, int success) { + struct ETCP_CONN* conn = dgram->link->etcp; if (success) { dgram->link->ack_reply_pending = 0; - dgram->link->etcp->ack_till_pending = 0; + conn->ack_till_deadline_us = 0; if (dgram->ack_request) dgram->link->etcp->ack_request_pending = 0; } + etcp_conn_schedule_ack(conn); struct ETCP_TX_ATTEMPT* attempt = dgram->attempt; if (!attempt) return; dgram->attempt = NULL; struct ETCP_LINK* link = dgram->link; - struct ETCP_CONN* conn = link->etcp; struct INFLIGHT_PACKET* packet = logical_find(conn, attempt->seq); if (!success) { attempt_retire(link, attempt, 0, 0); return; } if (!packet || packet->state != INFLIGHT_STATE_WAIT_SEND) { diff --git a/src/transport_layer/etcp_tx.h b/src/transport_layer/etcp_tx.h index 169cd7ac..079ebb6e 100644 --- a/src/transport_layer/etcp_tx.h +++ b/src/transport_layer/etcp_tx.h @@ -54,6 +54,7 @@ uint32_t etcp_tx_rto_us(const struct ETCP_LINK* link); struct ETCP_TX_ATTEMPT* etcp_tx_prepare(struct ETCP_LINK* link, struct INFLIGHT_PACKET* packet); /* Завершить синхронный send: success=1 принят транспортом, 0 — снять резерв без сетевой потери. * Принятие DATA фиксирует историю каналов и RR; повторно использованный слот начинает новый обход. + * После потребления ACK пересчитывает ближайший срок оставшихся очередей; успешный пакет снимает pending till/urgent этого пути. * Вызвать ровно один раз до освобождения dgram и до следующего poll; dgram->attempt обнуляется. */ void etcp_tx_complete(struct ETCP_DGRAM* dgram, int success); /* Принятый ACK: seq подтверждает логическую доставку; внутренний attempt_id=(seq<<16)|номер обновляет только свой линк. @@ -66,7 +67,8 @@ void etcp_tx_complete(struct ETCP_DGRAM* dgram, int success); * Непредставимый интервал не создаёт оценку BW. Пустой flight заново начинает приёмную базу, исключая простой. */ void etcp_tx_ack(struct ETCP_LINK* link, uint32_t seq, uint64_t attempt_id, uint32_t receiver_delay_us, uint64_t peer_rx_bytes, uint64_t peer_rx_time_us); -/* Поставить ACK уже сохранённого/доставленного DATA в очередь этого линка; 0/-1, ошибка логируется. */ +/* Поставить ACK уже сохранённого/доставленного DATA в очередь этого линка; 0/-1, ошибка логируется. + * received_at_us сохраняется для RTT и срока ACK-only; вызывающий затем запускает etcp_conn_schedule_ack(). */ int etcp_tx_queue_ack(struct ETCP_LINK* link, uint32_t seq, uint64_t attempt_id); #endif diff --git a/src/video/video.c b/src/video/video.c index 93358ec4..6879f894 100644 --- a/src/video/video.c +++ b/src/video/video.c @@ -713,7 +713,7 @@ static int vc_run(struct vc_ctx* ctx) { static void vc_thread_entry(void* raw) { struct vc_ctx* ctx = raw; video_ffmpeg_init(); - if (file_io_paths(ctx->source, ctx->destination) || file_io_temp_open(&ctx->file, ctx->destination)) { ctx->error = -1; return; } + if (file_io_paths(ctx->source, ctx->destination) || file_io_temp_open(&ctx->file, ctx->destination, 0)) { ctx->error = -1; return; } struct stat info; if (!stat(ctx->source, &info)) ctx->source_size = info.st_size; int error = vc_run(ctx); @@ -844,7 +844,7 @@ int video_extract_thumbnail(const char* src, const char* dst, int max_w) { } if (error < 0) goto done; phase = "write JPEG"; - if (file_io_temp_open(&output, dst)) { error = AVERROR(EIO); goto done; } + if (file_io_temp_open(&output, dst, 0)) { error = AVERROR(EIO); goto done; } if (fwrite(packet->data, 1, packet->size, output.file) != (size_t)packet->size) { error = AVERROR(errno ? errno : EIO); goto done; } diff --git a/tests/Makefile.am b/tests/Makefile.am index fa073cad..9b9ae8eb 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -390,6 +390,11 @@ test_attachment_SOURCES = test_attachment.c test_attachment_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) test_encrypted_file_SOURCES = test_encrypted_file.c test_encrypted_file_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) +if OS_LINUX +test_encrypted_file_LDFLAGS = -Wl,--wrap=fopen -Wl,--wrap=fdopen -Wl,--wrap=fread -Wl,--wrap=__fread_chk \ + -Wl,--wrap=fwrite -Wl,--wrap=ferror -Wl,--wrap=fflush -Wl,--wrap=fclose -Wl,--wrap=fseeko \ + -Wl,--wrap=fsync -Wl,--wrap=rename -Wl,--wrap=random_bytes +endif test_encrypted_attachment_SOURCES = test_encrypted_attachment.c $(test_clock_sources) test_encrypted_attachment_CFLAGS = $(test_dm_e2e_CFLAGS) test_encrypted_attachment_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) @@ -399,6 +404,9 @@ test_encrypted_attachment_LDFLAGS += -Wl,--wrap=uasync_set_timeout -Wl,--wrap=me endif test_attachment_send_SOURCES = test_attachment_send.c test_attachment_send_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) +if OS_LINUX +test_attachment_send_LDFLAGS = -Wl,--wrap=media_async_submit +endif test_dm_CFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/src/transport_layer -I$(top_srcdir)/lib test_dm_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) @@ -900,7 +908,7 @@ if OS_LINUX test_media_primitives_LDFLAGS = -Wl,--wrap=fopen -Wl,--wrap=fdopen -Wl,--wrap=fread -Wl,--wrap=__fread_chk -Wl,--wrap=ferror \ -Wl,--wrap=fwrite -Wl,--wrap=fflush -Wl,--wrap=fclose -Wl,--wrap=rename -Wl,--wrap=RAND_bytes \ -Wl,--wrap=opendir -Wl,--wrap=readdir -Wl,--wrap=closedir -Wl,--wrap=fseeko \ - -Wl,--wrap=pthread_create -Wl,--wrap=u_calloc_impl -Wl,--wrap=uasync_post_reserved + -Wl,--wrap=pthread_create -Wl,--wrap=u_calloc_impl -Wl,--wrap=uasync_post_reserved -Wl,--wrap=fsync endif test_media_index_SOURCES = test_media_index.c @@ -1065,7 +1073,8 @@ test_etcp_multipath_LDFLAGS = $(test_clock_ldflags) -Wl,--wrap=etcp_dump_pkt_sec test_stcp_sigpipe_SOURCES = test_stcp_sigpipe.c test_stcp_sigpipe_LDADD = $(LIBUTUN) $(COMMON_LIBS) test_etcp_windows_LDADD = $(LIBUTUN) $(COMMON_LIBS) -test_etcp_windows_LDFLAGS = $(test_clock_ldflags) -Wl,--wrap=get_time_tb -Wl,--wrap=etcp_encrypt_send -Wl,--wrap=memory_pool_alloc_impl \ +test_etcp_windows_LDFLAGS = $(test_clock_ldflags) -Wl,--wrap=get_time_tb -Wl,--wrap=get_time_us \ + -Wl,--wrap=etcp_encrypt_send -Wl,--wrap=memory_pool_alloc_impl \ -Wl,--wrap=bbr_main -Wl,--wrap=uasync_set_timeout -Wl,--wrap=uasync_call_soon -Wl,--wrap=ll_alloc_lldgram \ -Wl,--wrap=u_malloc_impl -Wl,--wrap=u_calloc_impl -Wl,--wrap=u_realloc_impl -Wl,--wrap=random_bytes diff --git a/tests/test_attachment_send.c b/tests/test_attachment_send.c index c764e32c..8b99738d 100644 --- a/tests/test_attachment_send.c +++ b/tests/test_attachment_send.c @@ -11,6 +11,7 @@ #include "../src/dm/dm_core.h" #include "../src/dm/dm_crypto.h" #include "../src/media_async/attachment_send.h" +#include "../src/media_async/media_async.h" #include "test_utils.h" #define REQUIRE(value) do { if (!(value)) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment send check line=%d", __LINE__); return 1; } } while (0) @@ -22,6 +23,27 @@ struct test_state { }; static struct test_state test; +#ifdef __linux__ +static pthread_barrier_t prepared_barrier; +static int capture_work; +static ma_work_fn captured_work; +void __real_media_async_submit(struct media_async*, struct UASYNC*, ma_work_fn, void*, ma_done_fn, void*); +static void prepared_work(void* arg) { + captured_work(arg); + pthread_barrier_wait(&prepared_barrier); +} +/* Завершить настоящий crypto worker, сохранив completion до остановки сервиса. */ +void __wrap_media_async_submit(struct media_async* ma, struct UASYNC* ua, ma_work_fn work, void* data, ma_done_fn done, void* arg) { + if (capture_work) { capture_work = 0; captured_work = work; work = prepared_work; } + __real_media_async_submit(ma, ua, work, data, done, arg); +} +#endif + +static void count_temps(void* arg, const char* path, int64_t size) { + (void)size; + if (strstr(path, ".tmp.")) (*(unsigned*)arg)++; +} + static void release_pcm(void* owner) { struct test_state* state = owner; state->released++; state->release_in_worker = !pthread_equal(pthread_self(), state->event_thread); @@ -89,17 +111,35 @@ int main(void) { char prepared[1024]; snprintf(prepared, sizeof(prepared), "%s/cancel-encrypted.bin", dir); FILE* prepared_file = fopen(prepared, "wb"); REQUIRE(prepared_file && fputc(42, prepared_file) != EOF && !fclose(prepared_file)); struct attachment_info encrypted_info = { .kind = ATTACHMENT_FILE, .name = "cancel.bin" }; uint8_t no_reply[32] = {0}; +#ifdef __linux__ + REQUIRE(!pthread_barrier_init(&prepared_barrier, NULL, 2)); capture_work = 1; +#endif REQUIRE(!chat_attachment_send(inst, channel_id, prepared, &encrypted_info, "application/octet-stream", no_reply, 0)); + sqlite3_stmt* paths = NULL; + REQUIRE(!sqlite3_prepare_v2(inst->topo_sqlite_db, "SELECT cipher_path,plain_path FROM attachment_keys", -1, &paths, NULL)); + REQUIRE(sqlite3_step(paths) == SQLITE_ROW); + char cancelled_cipher[1024], cancelled_plain[1024]; + snprintf(cancelled_cipher, sizeof(cancelled_cipher), "%s", sqlite3_column_text(paths, 0)); + snprintf(cancelled_plain, sizeof(cancelled_plain), "%s", sqlite3_column_text(paths, 1)); sqlite3_finalize(paths); +#ifdef __linux__ + pthread_barrier_wait(&prepared_barrier); + REQUIRE(ma_file_size(cancelled_cipher) > 0 && ma_file_size(cancelled_plain) == 1); +#endif req = u_calloc(1, sizeof(*req)); REQUIRE(req); req->inst = inst; req->is_dm = 1; req->info.kind = ATTACHMENT_VOICE; req->preset = 1; snprintf(req->target, sizeof(req->target), "%s", conv); req->pcm = test.pcm; req->pcm_count = 48000; req->pcm_owner = &test; req->pcm_release = release_pcm; attachment_send_trampoline(req); chat_service_stop(inst); +#ifdef __linux__ + REQUIRE(!pthread_barrier_destroy(&prepared_barrier)); +#endif REQUIRE(test.released == 2 && test.failed == 1 && count(inst) == 1); sqlite3_stmt* cancelled_key = NULL; REQUIRE(!sqlite3_prepare_v2(inst->topo_sqlite_db, "SELECT count(*) FROM attachment_keys", -1, &cancelled_key, NULL)); REQUIRE(sqlite3_step(cancelled_key) == SQLITE_ROW && sqlite3_column_int(cancelled_key, 0) == 0); sqlite3_finalize(cancelled_key); + REQUIRE(ma_file_size(cancelled_cipher) < 0 && ma_file_size(cancelled_plain) < 0 && ma_file_size(prepared) < 0); + unsigned temps = 0; REQUIRE(!ma_dir_walk(dir, count_temps, &temps) && !temps); uasync_poll(ua, 0); REQUIRE(test.failed == 1); /* Отклонение после stop удаляет только переданный собственный cache. */ diff --git a/tests/test_encrypted_attachment.c b/tests/test_encrypted_attachment.c index b30ba997..846be8ec 100644 --- a/tests/test_encrypted_attachment.c +++ b/tests/test_encrypted_attachment.c @@ -96,6 +96,46 @@ void __wrap_media_download_handle_chunk(struct UTUN_INSTANCE* inst, uint64_t gro static void hex(const uint8_t* p, size_t len, char* out) { for (size_t i = 0; i < len; i++) snprintf(out + i * 2, 3, "%02x", p[i]); } + +/* Startup/restart удаляет только строгие собственные stale temp; живые owners обоих деревьев сохраняются. */ +static int start_with_recovery(struct UTUN_INSTANCE* inst, const char* base) { + struct file_io_temp live[2] = {0}; + char target[2][1024], stale[2][1100], foreign[2][1100]; + const char* trees[] = {"media", "decrypted"}; + const char* extensions[] = {"uenc", "file"}; + int result = -1; + for (unsigned i = 0; i < 2; i++) { + char directory[900]; snprintf(directory, sizeof(directory), "%s/%s", base, trees[i]); + if (utun_mkdir(directory, 0700) && errno != EEXIST) goto done; + snprintf(directory, sizeof(directory), "%s/%s/123", base, trees[i]); + if (utun_mkdir(directory, 0700) && errno != EEXIST) goto done; + snprintf(target[i], sizeof(target[i]), "%s/00000000000000000000000000000001.%s", directory, extensions[i]); + snprintf(stale[i], sizeof(stale[i]), "%s.tmp.00000000000000000000000000000002", target[i]); + snprintf(foreign[i], sizeof(foreign[i]), "%s.tmp.foreign", target[i]); + const char* paths[] = {target[i], stale[i], foreign[i]}; + for (unsigned j = 0; j < 3; j++) { + FILE* file = fopen(paths[j], "wb"); + if (!file) goto done; + int failed = fputs("retained", file) < 0; + if (fclose(file)) failed = 1; + if (failed) goto done; + } + if (file_io_temp_open(&live[i], target[i], FILE_IO_DURABLE) || fputs("live", live[i].file) < 0) goto done; + if (i ? file_io_temp_finish(&live[i]) : fflush(live[i].file)) goto done; + } + if (utun_instance_init(inst)) goto done; + for (unsigned i = 0; i < 2; i++) { + if (ma_file_size(stale[i]) >= 0 || ma_file_size(target[i]) != 8 || ma_file_size(foreign[i]) != 8 || + ma_file_size(live[i].temporary) != 4 || file_io_remove_orphan(live[i].temporary) != 0) goto done; + } + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS attachment restart recovery: both trees, open/finished owners, final and foreign files"); + result = 0; +done: + for (unsigned i = 0; i < 2; i++) file_io_temp_discard(&live[i]); + if (result) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment recovery fixture failed base=%s errno=%d", base, errno); + return result; +} + static int start_node(struct media_test* t, int index) { char path[768], pub[65], priv[65], server[65], db[768]; hex(t->keys[index].public_key, 32, pub); hex(t->keys[index].private_key, 32, priv); @@ -117,7 +157,7 @@ static int start_node(struct media_test* t, int index) { DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "encrypted attachment fixture: node=%d port=%u retained for restart", index, t->ports[index]); } if (t->nodes[index] && index != S) test_etcp_set_peer(t->nodes[index], "to_s", t->nodes[S], "s1"); - return t->nodes[index] ? utun_instance_init(t->nodes[index]) : -1; + return t->nodes[index] ? start_with_recovery(t->nodes[index], db) : -1; } /* Фикстура создаёт канал напрямую, поэтому сама снимает gate после реального Merkle round. */ diff --git a/tests/test_encrypted_file.c b/tests/test_encrypted_file.c index 66c153e8..063281d7 100644 --- a/tests/test_encrypted_file.c +++ b/tests/test_encrypted_file.c @@ -6,6 +6,79 @@ #include "test_utils.h" #include #include +#include +#ifdef __linux__ +#include + +enum fault { F_NONE, F_OPEN, F_FDOPEN, F_READ, F_WRITE, F_SEEK, F_FLUSH, F_INPUT_CLOSE, F_OUTPUT_CLOSE, + F_SYNC, F_RENAME, F_DIRECTORY, F_RANDOM }; +static enum fault fault; +static int injected, fault_after, read_failed; +static FILE *input_file, *output_file; +FILE* __real_fopen(const char*, const char*); +FILE* __real_fdopen(int, const char*); +size_t __real_fread(void*, size_t, size_t, FILE*), __real_fwrite(const void*, size_t, size_t, FILE*); +#ifdef __GLIBC__ +size_t __real___fread_chk(void*, size_t, size_t, size_t, FILE*); +#endif +int __real_ferror(FILE*), __real_fflush(FILE*), __real_fclose(FILE*), __real_fsync(int); +int __real_fseeko(FILE*, off_t, int), __real_rename(const char*, const char*); +int __real_random_bytes(uint8_t*, size_t); +static int inject(enum fault value) { + if (fault != value || injected || --fault_after > 0) return 0; + injected = 1; errno = EIO; return 1; +} +FILE* __wrap_fopen(const char* path, const char* mode) { + if (!strcmp(mode, "rb") && inject(F_OPEN)) return NULL; + FILE* file = __real_fopen(path, mode); + if (!strcmp(mode, "rb")) input_file = file; + return file; +} +FILE* __wrap_fdopen(int fd, const char* mode) { + if (inject(F_FDOPEN)) return NULL; + return output_file = __real_fdopen(fd, mode); +} +size_t __wrap_fread(void* data, size_t size, size_t count, FILE* file) { + if (file == input_file && inject(F_READ)) { read_failed = 1; return 0; } + return __real_fread(data, size, count, file); +} +#ifdef __GLIBC__ +size_t __wrap___fread_chk(void* data, size_t cap, size_t size, size_t count, FILE* file) { + if (file == input_file && inject(F_READ)) { read_failed = 1; return 0; } + return __real___fread_chk(data, cap, size, count, file); +} +#endif +int __wrap_ferror(FILE* file) { return file == input_file && read_failed ? 1 : __real_ferror(file); } +size_t __wrap_fwrite(const void* data, size_t size, size_t count, FILE* file) { + return file == output_file && inject(F_WRITE) ? 0 : __real_fwrite(data, size, count, file); +} +int __wrap_fflush(FILE* file) { return file == output_file && inject(F_FLUSH) ? EOF : __real_fflush(file); } +int __wrap_fseeko(FILE* file, off_t offset, int whence) { return inject(F_SEEK) ? -1 : __real_fseeko(file, offset, whence); } +int __wrap_fclose(FILE* file) { + int error = (file == input_file && inject(F_INPUT_CLOSE)) || (file == output_file && inject(F_OUTPUT_CLOSE)); + if (file == input_file) { input_file = NULL; read_failed = 0; } + if (file == output_file) output_file = NULL; + int result = __real_fclose(file); + if (error) { errno = EIO; return EOF; } + return result; +} +int __wrap_fsync(int fd) { + struct stat data; assert(!fstat(fd, &data)); + return inject(S_ISDIR(data.st_mode) ? F_DIRECTORY : F_SYNC) ? -1 : __real_fsync(fd); +} +int __wrap_rename(const char* from, const char* to) { return inject(F_RENAME) ? -1 : __real_rename(from, to); } +int __wrap_random_bytes(uint8_t* data, size_t len) { return inject(F_RANDOM) ? -1 : __real_random_bytes(data, len); } +static unsigned count_fds(void) { + DIR* dir = opendir("/proc/self/fd"); assert(dir); + unsigned count = 0; + while (readdir(dir)) count++; + assert(!closedir(dir)); return count; +} +#endif + +static void hash_same(const char* path, const uint8_t expected[32]) { + uint8_t actual[32]; assert(!ma_sha256_file(path, actual) && !memcmp(actual, expected, 32)); +} static void roundtrip(const char* dir, unsigned size) { char source[768], cipher[768], output[768]; @@ -17,37 +90,138 @@ static void roundtrip(const char* dir, unsigned size) { uint8_t key[32], wrong[32], id[16], hash[32]; assert(RAND_bytes(key, 32) == 1 && RAND_bytes(wrong, 32) == 1 && RAND_bytes(id, 16) == 1); struct attachment_info info = { .kind = ATTACHMENT_FILE, .name = "секретный-документ.bin" }, result; + struct file_io_temp temporary = {0}; char type[32]; - assert(!encrypted_file_write(source, cipher, key, 42, id, &info, "application/octet-stream")); + assert(!encrypted_file_write(source, cipher, &temporary, key, 42, id, &info, "application/octet-stream")); + assert(file_io_remove_orphan(temporary.temporary) == 0); + assert(!file_io_temp_publish(&temporary, source, cipher)); file_io_temp_discard(&temporary); assert(!ma_sha256_file(cipher, hash)); - assert(!encrypted_file_read(cipher, output, key, 42, id, hash, &result, type)); + assert(!encrypted_file_read(cipher, output, &temporary, key, 42, id, hash, &result, type)); + assert(ma_file_size(output) < 0 && file_io_remove_orphan(temporary.temporary) == 0); + assert(!file_io_temp_publish(&temporary, cipher, output)); file_io_temp_discard(&temporary); assert(!strcmp(info.name, result.name) && !strcmp(type, "application/octet-stream")); f = fopen(output, "rb"); assert(f); for (unsigned i = 0; i < size; i++) assert(fgetc(f) == (int)((i * 73 + 19) & 255)); assert(fgetc(f) == EOF); assert(!fclose(f)); assert(!remove(output)); - assert(encrypted_file_read(cipher, output, wrong, 42, id, hash, &result, type) < 0); + assert(encrypted_file_read(cipher, output, &temporary, wrong, 42, id, hash, &result, type) < 0); assert(ma_file_size(output) < 0); - assert(encrypted_file_read(cipher, output, key, 43, id, hash, &result, type) < 0); + assert(encrypted_file_read(cipher, output, &temporary, key, 43, id, hash, &result, type) < 0); id[0] ^= 1; - assert(encrypted_file_read(cipher, output, key, 42, id, hash, &result, type) < 0); id[0] ^= 1; + assert(encrypted_file_read(cipher, output, &temporary, key, 42, id, hash, &result, type) < 0); id[0] ^= 1; /* Подмена metadata даже с заново вычисленным внешним hash не проходит CCM. */ f = fopen(cipher, "rb+"); assert(f && !fseek(f, 70, SEEK_SET)); int c = fgetc(f); assert(c != EOF && !fseek(f, 70, SEEK_SET) && fputc(c ^ 1, f) != EOF && !fclose(f)); - assert(encrypted_file_read(cipher, output, key, 42, id, hash, &result, type) < 0); + assert(encrypted_file_read(cipher, output, &temporary, key, 42, id, hash, &result, type) < 0); assert(!ma_sha256_file(cipher, hash)); - assert(encrypted_file_read(cipher, output, key, 42, id, hash, &result, type) < 0 && ma_file_size(output) < 0); - assert(!encrypted_file_write(source, cipher, key, 42, id, &info, "application/octet-stream")); + assert(encrypted_file_read(cipher, output, &temporary, key, 42, id, hash, &result, type) < 0 && ma_file_size(output) < 0); + assert(!encrypted_file_write(source, cipher, &temporary, key, 42, id, &info, "application/octet-stream")); + assert(!file_io_temp_publish(&temporary, source, cipher)); file_io_temp_discard(&temporary); /* Обрезанный контейнер и лишние байты отвергаются независимо от manifest hash. */ f = fopen(cipher, "ab"); assert(f && fputc(0, f) != EOF && !fclose(f)); assert(!ma_sha256_file(cipher, hash)); - assert(encrypted_file_read(cipher, output, key, 42, id, hash, &result, type) < 0 && ma_file_size(output) < 0); + assert(encrypted_file_read(cipher, output, &temporary, key, 42, id, hash, &result, type) < 0 && ma_file_size(output) < 0); printf("PASS: encrypted container size=%u, metadata, identity, key and integrity checks\n", size); } +/* Оба API отвергают разные имена одного файла, не меняя содержимое и не удаляя alias. */ +static void aliases(const char* dir) { + char source[768], cipher[768], target[768]; + snprintf(source, sizeof(source), "%s/source", dir); snprintf(cipher, sizeof(cipher), "%s/cipher", dir); + uint8_t key[32] = {1}, id[16] = {2}, hash[32], before[32]; + struct attachment_info info = {.kind = ATTACHMENT_FILE, .name = "example.bin"}, result; + struct file_io_temp temporary = {0}; + char type[32]; + assert(!encrypted_file_write(source, cipher, &temporary, key, 42, id, &info, "application/octet-stream")); + assert(!file_io_temp_publish(&temporary, source, cipher)); + for (int decrypt = 0; decrypt < 2; decrypt++) { + const char* input = decrypt ? cipher : source; + assert(!ma_sha256_file(input, before)); + for (int kind = 0; kind < 4; kind++) { + if (!kind) snprintf(target, sizeof(target), "%s", input); + else if (kind == 1) snprintf(target, sizeof(target), "%s/./%s", dir, decrypt ? "cipher" : "source"); + else { +#ifndef _WIN32 + snprintf(target, sizeof(target), "%s/alias", dir); + assert(kind == 2 ? !link(input, target) : !symlink(input, target)); +#else + continue; +#endif + } + int error = decrypt ? encrypted_file_read(input, target, &temporary, key, 42, id, before, &result, type) + : encrypted_file_write(input, target, &temporary, key, 42, id, &info, "application/octet-stream"); + assert(error < 0 && !temporary.file && !temporary.temporary[0]); + hash_same(input, before); hash_same(target, before); + if (kind >= 2) assert(!remove(target)); + } + } + /* Активный output чужой операции не теряется даже при ошибочных аргументах. */ + snprintf(target, sizeof(target), "%s/output", dir); + assert(!file_io_temp_open(&temporary, target, 0)); + assert(encrypted_file_write(source, target, &temporary, key, 42, id, &info, "application/octet-stream") < 0); + assert(encrypted_file_read(cipher, target, &temporary, key, 42, id, before, &result, type) < 0); + assert(temporary.file && temporary.temporary[0] && file_io_remove_orphan(temporary.temporary) == 0); + file_io_temp_discard(&temporary); + /* Между подготовкой и publish цель стала alias исходника: второй контроль сохраняет его. */ +#ifndef _WIN32 + assert(!ma_sha256_file(source, before)); + assert(!encrypted_file_write(source, target, &temporary, key, 42, id, &info, "application/octet-stream")); + assert(!link(source, target)); + assert(file_io_temp_publish(&temporary, source, target) < 0 && temporary.temporary[0]); + file_io_temp_discard(&temporary); hash_same(source, before); hash_same(target, before); assert(!remove(target)); +#endif + assert(!ma_sha256_file(cipher, hash)); + printf("PASS: encrypt/decrypt aliases, active output and alias before publication\n"); +} + +#ifdef __linux__ +static void temp_count(void* arg, const char* path, int64_t size) { + (void)size; if (strstr(path, ".tmp.")) (*(unsigned*)arg)++; +} +/* Реальные файловые операции и CCM; каждый отказ сохраняет source и прежний target до rename. */ +static void failures(const char* dir) { + char source[768], cipher[768], target[768]; + snprintf(source, sizeof(source), "%s/source", dir); snprintf(cipher, sizeof(cipher), "%s/cipher", dir); + snprintf(target, sizeof(target), "%s/target", dir); + uint8_t key[32] = {1}, id[16] = {2}, hash[32], before[32], previous[32]; + struct attachment_info info = {.kind = ATTACHMENT_FILE, .name = "example.bin"}; + struct file_io_temp output = {0}; + assert(!ma_sha256_file(cipher, hash)); + for (int decrypt = 0; decrypt < 2; decrypt++) for (enum fault test = F_OPEN; test <= F_RANDOM; test++) { + if (decrypt && test == F_SEEK) continue; + const char* input = decrypt ? cipher : source; + assert(!ma_sha256_file(input, before)); + FILE* file = fopen(target, "wb"); assert(file && fputs("previous", file) >= 0 && !fclose(file)); + assert(!ma_sha256_file(target, previous)); + struct attachment_info result, unchanged; memset(&result, 0xa5, sizeof(result)); unchanged = result; + char type[32], old_type[32]; memset(type, 0xa5, sizeof(type)); memcpy(old_type, type, sizeof(type)); + unsigned fds = count_fds(); + fault = test; injected = 0; fault_after = decrypt && test == F_INPUT_CLOSE ? 2 : 1; + int error = decrypt ? encrypted_file_read(input, target, &output, key, 42, id, hash, &result, type) + : encrypted_file_write(input, target, &output, key, 42, id, &info, "application/octet-stream"); + int prepared = !error; + if (prepared) error = file_io_temp_publish(&output, input, target); + fault = F_NONE; + assert(error < 0 && injected); + if (decrypt && !prepared) assert(!memcmp(&result, &unchanged, sizeof(result)) && !memcmp(type, old_type, sizeof(type))); + if (test == F_DIRECTORY) assert(!output.temporary[0]); + file_io_temp_discard(&output); + assert(count_fds() == fds); hash_same(input, before); + if (test != F_DIRECTORY) hash_same(target, previous); + unsigned temps = 0; assert(!ma_dir_walk(dir, temp_count, &temps) && !temps); + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS encrypted I/O failure decrypt=%d phase=%d installed=%d", decrypt, test, test == F_DIRECTORY); + } + assert(!remove(target)); +} +#endif + int main(void) { debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR); char dir[512] = "/tmp/utun_encrypted_file_XXXXXX"; assert(!test_mkdtemp(dir)); const unsigned sizes[] = { 0, 1, 32767, 32768, 32769, 98321 }; for (unsigned i = 0; i < sizeof(sizes) / sizeof(sizes[0]); i++) roundtrip(dir, sizes[i]); + aliases(dir); +#ifdef __linux__ + failures(dir); +#endif return 0; } diff --git a/tests/test_etcp_multipath.c b/tests/test_etcp_multipath.c index 35c7578a..85d9987a 100644 --- a/tests/test_etcp_multipath.c +++ b/tests/test_etcp_multipath.c @@ -2,9 +2,11 @@ #include #include #include +#include #include "etcp.h" #include "etcp_api.h" #include "etcp_session.h" +#include "etcp_loadbalancer.h" #include "node_conn_direct.h" #include "topo_node.h" #include "utun_instance.h" @@ -13,7 +15,13 @@ #include "test_clock.h" #include "../lib/mem.h" -#define CHECK(x) do { if (!(x)) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "FAIL %s:%d: %s", __func__, __LINE__, #x); exit(1); } } while (0) +static void diagnostic_flush(void); +static int diagnostic_flushing; +#define CHECK(x) do { if (!(x)) { \ + DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "FAIL %s:%d: %s", __func__, __LINE__, #x); \ + if (!diagnostic_flushing) diagnostic_flush(); \ + exit(1); \ +} } while (0) #define PATHS 3 #define TRACE_COUNT 131072 #define CMD 253 @@ -78,11 +86,194 @@ static const char* privkeys[2] = { "38240cb82199e504686507f11f6eaa4f740fde6f0c425c495e49a523019a5d68" }; +/* Диагностика включается UTUN_MULTIPATH_DIAG=path.csv. Срезы и счётчики пишутся в память, + * файл/лог — только после drain каждого стенда. Пики наблюдаются на TX/RX/ACK/выборе пути. + * Интервал по умолчанию 100мс; UTUN_MULTIPATH_DIAG_MS=10..1000 позволяет уточнить короткий участок. */ +struct diagnostic_interval { + uint64_t tx_data, fresh_rx, data_packets, retries, ack_only_packets, ack_only_wire, piggy_packets, piggy_bytes; + uint64_t ack_entries, ack_delay_sum, ack_delay_max, ack_only_entries, ack_only_delay_sum, early_ack_entries; + uint64_t ordinary_ack_packets, early_ack_packets; // молодые entries допустимы в пакете, где старейший ACK уже готов + uint64_t control_packets, samples, limited, app_limited; + uint64_t rtt_min, rtt_max, rtt_sum, rtt_samples, searches, window_blocked, pacer_blocked, observations; + uint32_t flight_min, flight_max, cwnd_min, cwnd_max, send_max, ack_max; + int64_t debt_min_us, debt_max_us; +}; +struct diagnostic_sample { + const char* phase; + uint64_t time_us, interval_us, wire_bytes, channel_received, channel_lost, channel_dropped, app_bytes, ack_age_us, attempt_age_us; + uint64_t pacer_ready_us; + uint64_t queue_bytes[5]; + unsigned queue_count[5], side, path, fresh, retries, pending_bytes, normalizer_bytes, normalizer_waiter, normalizer_flush; + unsigned unacked, budget, flight, attempts, cwnd, pace, mode, cycle, min_rtt, rtt, jitter, rto, bw, queue_cap; + unsigned ack_count, ack_timer, ack_till, urgent_ack, pacer_timer, burst, stalled, rto_streak; + unsigned channel_pending, channel_peak; + unsigned app_suspended, app_resume, fragment_suspended, fragment_resume, waiter_deferred; + unsigned tx_state, tx_scheduled, tx_delayed, tx_running, active_attempts, next_seq, peer_till, local_till; + uint64_t channel_delay_max_us; + int64_t debt_us; + struct diagnostic_interval stats; +}; +static FILE* diagnostic_file; +static struct diagnostic_sample diagnostic_rows[16384]; // хватает и для трёх путей/10мс с полным drain +static struct diagnostic_interval diagnostic_stats[2][PATHS]; +static struct dummynet_stats diagnostic_previous[2][PATHS]; +static unsigned diagnostic_count, diagnostic_active, diagnostic_period_us = 100000; +static uint64_t diagnostic_last_us; + +/* Состояние pacer в микросекундах: отрицательное значение — накопленный кредит, положительное — долг. */ +static int64_t diagnostic_debt(const struct ETCP_LINK* link) { + return ((int64_t)link->shaper_load_time_tb - (int64_t)get_time_tb()) * 100 + (int64_t)(link->shaper_sub_nanotime / 10000); +} + +static void diagnostic_observe(struct diagnostic_interval* d, struct ETCP_LINK* link) { + uint32_t flight = link->inflight_bytes, cwnd = link->inflight_lim_bytes; + int64_t debt = diagnostic_debt(link); + if (!d->observations || flight < d->flight_min) d->flight_min = flight; + if (!d->observations || cwnd < d->cwnd_min) d->cwnd_min = cwnd; + if (!d->observations || debt < d->debt_min_us) d->debt_min_us = debt; + if (!d->observations || debt > d->debt_max_us) d->debt_max_us = debt; + if (flight > d->flight_max) d->flight_max = flight; + if (cwnd > d->cwnd_max) d->cwnd_max = cwnd; + if ((unsigned)link->etcp->input_send_q->count > d->send_max) d->send_max = link->etcp->input_send_q->count; + if ((unsigned)link->ack_q->count > d->ack_max) d->ack_max = link->ack_q->count; + d->observations++; +} + +static struct diagnostic_interval* diagnostic_for(struct ETCP_LINK* link) { + if (!diagnostic_active) return NULL; + for (unsigned p = 0; p < path_count; p++) + if (ss_get_port(&link->remote_addr) == dummynet_get_listen_port(channels[p])) { + struct diagnostic_interval* d = &diagnostic_stats[link->etcp->instance == ep[1].inst][p]; + diagnostic_observe(d, link); return d; + } + return NULL; +} + +/* Общие очереди соединения повторяются в строках его линков; интервальные счётчики относятся к одному пути. */ +static void diagnostic_snapshot(const char* phase) { + if (!diagnostic_active) return; + uint64_t now = get_time_us(); + if (now == diagnostic_last_us) return; + for (unsigned side = 0; side < 2; side++) for (struct ETCP_LINK* l = ep[side].conn->links; l; l = l->next) { + struct diagnostic_interval* d = diagnostic_for(l); + if (!d) continue; + unsigned p = (unsigned)(d - diagnostic_stats[side]); + CHECK(diagnostic_count < sizeof(diagnostic_rows) / sizeof(diagnostic_rows[0])); + struct diagnostic_sample* s = &diagnostic_rows[diagnostic_count++]; + memset(s, 0, sizeof(*s)); + struct ETCP_CONN* c = l->etcp; + struct PKTNORM* pn = c->normalizer; + const struct ll_queue* queues[] = {c->send_input_q, c->input_queue, c->input_send_q, c->input_wait_ack, c->recv_q}; + s->phase = phase; s->time_us = now - started; s->interval_us = now - diagnostic_last_us; s->side = side; s->path = p; + for (unsigned q = 0; q < 5; q++) { s->queue_count[q] = queues[q]->count; s->queue_bytes[q] = queues[q]->total_bytes; } + for (struct ll_entry* e = c->input_send_q->head; e; e = e->next) + if (((struct INFLIGHT_PACKET*)e)->last_attempt_id) s->retries++; else s->fresh++; + s->normalizer_bytes = pn->data_ptr; s->normalizer_waiter = !!pn->input_waiter_handle.internal; + s->waiter_deferred = !!pn->input_waiter_handle.call_soon_id; + s->normalizer_flush = !!pn->flush_timer; s->pending_bytes = pn->pending ? pn->pending->len - pn->pending_in_ptr : 0; + s->app_suspended = !!c->send_input_q->callback_suspended; s->app_resume = !!c->send_input_q->resume_timeout_id; + s->fragment_suspended = !!c->input_queue->callback_suspended; s->fragment_resume = !!c->input_queue->resume_timeout_id; + s->tx_state = c->tx_state; s->tx_scheduled = c->tx_token != NULL; s->tx_delayed = c->tx_delayed; + s->tx_running = c->tx_running; s->active_attempts = c->active_attempts; + s->next_seq = c->next_tx_id; s->peer_till = c->rx_ack_till; s->local_till = c->last_delivered_id; + s->unacked = c->unacked_bytes; s->budget = c->optimal_inflight; + s->flight = l->inflight_bytes; s->attempts = l->inflight_packets; s->cwnd = l->inflight_lim_bytes; s->pace = l->bbr_pacing_rate; + s->mode = l->bbr->mode; s->cycle = l->bbr->cycle_idx; s->min_rtt = l->bbr->min_rtt_us; + s->rtt = l->data_rtt_us; s->jitter = l->data_jitter_us; s->rto = etcp_tx_rto_us(l); + s->bw = l->bbr->bw_hi[0] > l->bbr->bw_hi[1] ? l->bbr->bw_hi[0] : l->bbr->bw_hi[1]; + if (s->bw > l->bbr->bw_lo) s->bw = l->bbr->bw_lo; + s->queue_cap = l->bbr->queue_cwnd_cap; s->ack_count = l->ack_q->count; + s->ack_timer = !!c->ack_resp_timer; s->ack_till = !!c->ack_till_deadline_us; s->urgent_ack = l->ack_reply_pending; + if (l->ack_q->head) s->ack_age_us = now - ((struct ACK_PACKET*)l->ack_q->head)->received_at_us; + if (l->attempts->head && ((struct ETCP_TX_ATTEMPT*)l->attempts->head)->sent_at_us) + s->attempt_age_us = now - ((struct ETCP_TX_ATTEMPT*)l->attempts->head)->sent_at_us; + s->debt_us = diagnostic_debt(l); s->pacer_timer = !!l->shaper_timer; s->burst = l->burst_active; + uint64_t tb = get_time_tb(); + s->pacer_ready_us = l->shaper_load_time_tb >= tb + SHAPER_BURST_DELAY_TB ? + (l->shaper_load_time_tb - tb - SHAPER_BURST_DELAY_TB + 1) * 100 : 0; + s->stalled = l->tx_stalled; s->rto_streak = l->data_rto_streak; s->app_bytes = ep[1-side].bytes; + const struct dummynet_stats* ds = dummynet_get_stats(channels[p], side); + struct dummynet_stats* previous = &diagnostic_previous[side][p]; + CHECK(ds->sent_bytes >= previous->sent_bytes && ds->recv_bytes >= previous->recv_bytes); + s->wire_bytes = ds->sent_bytes - previous->sent_bytes; s->channel_received = ds->recv_bytes - previous->recv_bytes; + s->channel_lost = ds->lost - previous->lost; s->channel_dropped = ds->dropped - previous->dropped; + s->channel_pending = ds->queue_size; s->channel_peak = ds->queue_max; s->channel_delay_max_us = ds->max_queue_delay_us; + *previous = *ds; s->stats = *d; memset(d, 0, sizeof(*d)); + } + diagnostic_last_us = now; +} + +/* Вывод после замера: dt_us обязателен для скоростей, особенно в коротком последнем срезе фазы. */ +static void diagnostic_flush(void) { + if (!diagnostic_file) return; + diagnostic_flushing = 1; + for (unsigned n = 0; n < diagnostic_count; n++) { + const struct diagnostic_sample* s = &diagnostic_rows[n]; + const struct diagnostic_interval* d = &s->stats; + CHECK(fprintf(diagnostic_file, "%s,%x,%s,%llu,%llu,%u,%u", case_name, enabled_mask, s->phase, + (unsigned long long)s->time_us, (unsigned long long)s->interval_us, s->side, s->path) > 0); + for (unsigned q = 0; q < 5; q++) + CHECK(fprintf(diagnostic_file, ",%u,%llu", s->queue_count[q], (unsigned long long)s->queue_bytes[q]) > 0); + CHECK(fprintf(diagnostic_file, ",%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u", + s->fresh, s->retries, s->normalizer_bytes, s->pending_bytes, s->normalizer_waiter, s->normalizer_flush, + s->unacked, s->budget, s->flight, s->attempts, s->cwnd, s->pace, s->mode, s->cycle, s->min_rtt, + s->rtt, s->jitter, s->rto, s->bw, s->queue_cap, s->ack_count, s->ack_timer) > 0); + CHECK(fprintf(diagnostic_file, ",%u,%u,%llu,%llu,%lld,%u,%llu,%u,%u,%u,%u,%u,%llu,%llu,%llu,%llu,%llu,%llu", + s->ack_till, s->urgent_ack, (unsigned long long)s->ack_age_us, (unsigned long long)s->attempt_age_us, + (long long)s->debt_us, s->pacer_timer, (unsigned long long)s->pacer_ready_us, s->burst, s->stalled, s->rto_streak, + s->channel_pending, s->channel_peak, (unsigned long long)s->channel_delay_max_us, + (unsigned long long)s->wire_bytes, (unsigned long long)s->channel_received, (unsigned long long)s->channel_lost, + (unsigned long long)s->channel_dropped, (unsigned long long)s->app_bytes) > 0); + CHECK(fprintf(diagnostic_file, ",%llu,%llu,%llu,%llu,%llu,%llu,%llu,%llu,%llu,%llu,%llu,%llu,%llu,%llu,%llu,%llu,%llu,%llu", + (unsigned long long)d->tx_data, (unsigned long long)d->fresh_rx, (unsigned long long)d->data_packets, + (unsigned long long)d->retries, (unsigned long long)d->ack_only_packets, (unsigned long long)d->ack_only_wire, + (unsigned long long)d->piggy_packets, (unsigned long long)d->piggy_bytes, (unsigned long long)d->ack_entries, + (unsigned long long)d->ack_delay_sum, (unsigned long long)d->ack_delay_max, (unsigned long long)d->early_ack_entries, + (unsigned long long)d->control_packets, (unsigned long long)d->samples, (unsigned long long)d->limited, + (unsigned long long)d->app_limited, (unsigned long long)d->rtt_min, (unsigned long long)d->rtt_max) > 0); + CHECK(fprintf(diagnostic_file, ",%llu,%llu,%llu,%llu,%llu,%llu,%llu,%llu,%u,%u,%u,%u,%u,%u,%lld,%lld", + (unsigned long long)d->ack_only_entries, (unsigned long long)d->ack_only_delay_sum, + (unsigned long long)d->rtt_sum, (unsigned long long)d->rtt_samples, + (unsigned long long)d->searches, (unsigned long long)d->window_blocked, + (unsigned long long)d->pacer_blocked, (unsigned long long)d->observations, d->flight_min, d->flight_max, + d->cwnd_min, d->cwnd_max, d->send_max, d->ack_max, (long long)d->debt_min_us, (long long)d->debt_max_us) > 0); + CHECK(fprintf(diagnostic_file, ",%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%llu,%llu\n", s->app_suspended, s->app_resume, + s->fragment_suspended, s->fragment_resume, s->waiter_deferred, s->tx_state, s->tx_scheduled, s->tx_delayed, + s->tx_running, s->active_attempts, s->next_seq, s->peer_till, s->local_till, + (unsigned long long)d->ordinary_ack_packets, (unsigned long long)d->early_ack_packets) > 0); + DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "MPATH %s mask=%x %s t_ms=%llu dt_us=%llu side=%u path=%u " + "Q_app=%u Q_fragment=%u Q_send=%u(new=%u retry=%u) Q_ACK=%u age_us=%llu flight=%u/%u " + "pace_Bps=%u debt_us=%lld ready_us=%llu timer=%u window_blocked_checks=%llu pacer_blocked_checks=%llu " + "channel_pending=%u mode=%u/%u DATA_B=%llu ACKonly=%llu early_ACK_entries=%llu early_ACK_packets=%llu", + case_name, enabled_mask, s->phase, (unsigned long long)(s->time_us/1000), (unsigned long long)s->interval_us, + s->side, s->path, s->queue_count[0], s->queue_count[1], s->queue_count[2], s->fresh, s->retries, + s->ack_count, (unsigned long long)s->ack_age_us, s->flight, s->cwnd, s->pace, (long long)s->debt_us, + (unsigned long long)s->pacer_ready_us, s->pacer_timer, + (unsigned long long)d->window_blocked, (unsigned long long)d->pacer_blocked, + s->channel_pending, s->mode, s->cycle, (unsigned long long)d->tx_data, + (unsigned long long)d->ack_only_packets, (unsigned long long)d->early_ack_entries, (unsigned long long)d->early_ack_packets); + } + CHECK(fflush(diagnostic_file) == 0); + diagnostic_count = 0; diagnostic_flushing = 0; +} + /* Выборочная диагностика модели: один sample на линк/100мс, без packet-level спама всего ETCP. */ void __real_bbr_main(struct bbr*, const struct bbr_rate_sample*, uint32_t*, uint32_t*, uint32_t, uint32_t, int); void __wrap_bbr_main(struct bbr* model, const struct bbr_rate_sample* sample, uint32_t* cwnd, uint32_t* pacing, uint32_t mss, uint32_t inflight, int limited) { __real_bbr_main(model, sample, cwnd, pacing, mss, inflight, limited); + if (diagnostic_active) for (int side = 0; side < 2; side++) + for (struct ETCP_LINK* l = ep[side].conn->links; l; l = l->next) { + if (l->bbr != model) continue; + struct diagnostic_interval* d = diagnostic_for(l); + if (!d) continue; + d->samples++; d->limited += !!limited; d->app_limited += !!sample->is_app_limited; + if (sample->rtt_us) { + if (!d->rtt_min || sample->rtt_us < d->rtt_min) d->rtt_min = sample->rtt_us; + if (sample->rtt_us > d->rtt_max) d->rtt_max = sample->rtt_us; + d->rtt_sum += sample->rtt_us; d->rtt_samples++; + } + } if (!log_bbr_samples) return; uint64_t now = get_time_us(); for (int side = 0; side < 2; side++) for (struct ETCP_LINK* l = ep[side].conn ? ep[side].conn->links : NULL; l; l = l->next) { @@ -103,16 +294,55 @@ void __wrap_bbr_main(struct bbr* model, const struct bbr_rate_sample* sample, ui } } +/* Обычный ACK-only может нести timestamp; запрос till и секции измерительной серии относятся к control. */ +static int diagnostic_ack_only(const struct ETCP_DGRAM* pkt, unsigned ack_size) { + if (pkt->payload_len || pkt->burst_packet) return 0; + for (unsigned off = ack_size; off < pkt->data_len; off += 5) + if (pkt->data_len - off < 5 || pkt->data[off] != ETCP_SECTION_TIMESTAMP) return 0; + return 1; +} + /* Статистика TX снимается только после принятия send; RX — перед разбором уже расшифрованного пакета. */ static void observe_packet(struct ETCP_DGRAM* pkt, struct ETCP_LINK* link, int sending) { int side = link->etcp->instance == ep[0].inst ? 0 : 1, path = -1; for (unsigned p = 0; p < path_count; p++) if (ss_get_port(&link->remote_addr) == dummynet_get_listen_port(channels[p])) path = p; + if (path >= 0 && !sending) diagnostic_for(link); if (path >= 0) for (unsigned offset = 0; offset < pkt->data_len;) { const uint8_t* data = pkt->data + offset; unsigned remaining = pkt->data_len - offset, len; switch (data[0]) { - case ETCP_SECTION_ACK: CHECK(remaining >= ETCP_ACK_BASE_SIZE); len = etcp_ack_section_size(data[1]); break; + case ETCP_SECTION_ACK: { + CHECK(remaining >= ETCP_ACK_BASE_SIZE); len = etcp_ack_section_size(data[1]); CHECK(len <= remaining); + struct diagnostic_interval* d = sending ? diagnostic_for(link) : NULL; + if (d) { + int ack_only = diagnostic_ack_only(pkt, len); + if (pkt->payload_len && data[1]) { d->piggy_packets++; d->piggy_bytes += len; } + else if (ack_only) { + d->ack_only_packets++; + d->ack_only_wire += pkt->data_len + ETCP_ENCRYPTED_HDR_SIZE + ETCP_SESSION_HEADER_SIZE + + SC_NONCE_SIZE + SC_CRC32_SIZE + SC_TAG_SIZE; + } else if (!pkt->payload_len) d->control_packets++; + uint32_t oldest_delay = 0; + for (unsigned n = 0; n < data[1]; n++) { + const uint8_t* a = data + ETCP_ACK_BASE_SIZE + n * ETCP_ACK_ENTRY_SIZE; + uint32_t delay = (uint32_t)a[6] | ((uint32_t)a[7] << 8) | ((uint32_t)a[8] << 16) | ((uint32_t)a[9] << 24); + d->ack_entries++; d->ack_delay_sum += delay; + if (delay > oldest_delay) oldest_delay = delay; + if (delay > d->ack_delay_max) d->ack_delay_max = delay; + if (ack_only) { + d->ack_only_entries++; d->ack_only_delay_sum += delay; + if (!link->ack_reply_pending && delay < 2000) d->early_ack_entries++; + } + } + if (ack_only && data[1] && !link->ack_reply_pending) { + d->ordinary_ack_packets++; + int till_due = link->etcp->ack_till_deadline_us && link->etcp->ack_till_deadline_us <= get_time_us(); + if (!till_due && oldest_delay < ETCP_ACK_DELAY_US) d->early_ack_packets++; + } + } + break; + } case ETCP_SECTION_ACK_REQUEST: len = 1; break; case ETCP_SECTION_TIMESTAMP: len = 5; break; case ETCP_SECTION_MEAS_TS: len = 9; break; @@ -127,6 +357,11 @@ static void observe_packet(struct ETCP_DGRAM* pkt, struct ETCP_LINK* link, int s uint16_t number = (uint16_t)data[5] | ((uint16_t)data[6] << 8); if (!sending) rx_trigger[side] = seq; if (sending) { + struct diagnostic_interval* d = diagnostic_for(link); + if (d) { + d->tx_data += remaining - ETCP_PAYLOAD_HEADER_SIZE; d->data_packets++; + if (trace->attempts) d->retries++; + } if (measuring && fault_start && !side && trace->retry_ready) { uint64_t wait = now - trace->retry_ready; recovery.retry_count++; if (wait > recovery.ready_wait_us) { recovery.ready_wait_us = wait; recovery.ready_seq = seq; } @@ -139,6 +374,7 @@ static void observe_packet(struct ETCP_DGRAM* pkt, struct ETCP_LINK* link, int s fault_rto_us[path] = etcp_tx_rto_us(link); if (measuring) traffic[side][path].data += remaining - ETCP_PAYLOAD_HEADER_SIZE; } else if (!trace->first_rx) { + if (diagnostic_active) diagnostic_stats[1-side][path].fresh_rx += remaining - ETCP_PAYLOAD_HEADER_SIZE; trace->first_rx = now; if (measuring) traffic[1-side][path].rx_unique += remaining - ETCP_PAYLOAD_HEADER_SIZE; if (measuring && fault_start && side && trace->last_number == number && trace->last_path == path) { @@ -192,12 +428,23 @@ int __wrap_queue_data_put_with_index(struct ll_queue* q, struct ll_entry* entry) } } } - return __real_queue_data_put_with_index(q, entry); + int result = __real_queue_data_put_with_index(q, entry); + if (!result && diagnostic_active) for (unsigned side = 0; side < 2; side++) + for (struct ETCP_LINK* l = ep[side].conn->links; l; l = l->next) + if (q == l->ack_q) diagnostic_for(l); // ACK-пик виден до немедленной отправки/извлечения из очереди. + return result; } struct ETCP_LINK* __real_etcp_loadbalancer_select_link(struct ETCP_CONN*, uint32_t, const struct INFLIGHT_PACKET*); struct ETCP_LINK* __wrap_etcp_loadbalancer_select_link(struct ETCP_CONN* conn, uint32_t bytes, const struct INFLIGHT_PACKET* retry) { struct ETCP_LINK* selected = __real_etcp_loadbalancer_select_link(conn, bytes, retry); + if (bytes && diagnostic_active) for (struct ETCP_LINK* l = conn->links; l; l = l->next) { + struct diagnostic_interval* d = diagnostic_for(l); + if (!d) continue; + d->searches++; + d->window_blocked += bytes > l->inflight_lim_bytes || l->inflight_bytes > l->inflight_lim_bytes - bytes; + d->pacer_blocked += l->shaper_timer != NULL; + } if (!measuring || !fault_start || conn != ep[0].conn || !retry || !selected || !retry->last_link) return selected; int previous = -1, chosen = -1; for (unsigned p = 0; p < path_count; p++) { @@ -312,6 +559,10 @@ static void setup(const struct profile* settings, unsigned count, unsigned mask) CHECK(node_conn_direct_open_node(ep[1].inst, peer.node_id, NULL, NULL, &ep[1].owner, &peer, NULL) >= 0); started = get_time_us(); for (int i = 0; i < 2; i++) ep[i].last_delivery = started; + memset(diagnostic_stats, 0, sizeof(diagnostic_stats)); memset(diagnostic_previous, 0, sizeof(diagnostic_previous)); + diagnostic_count = 0; diagnostic_last_us = started; diagnostic_active = diagnostic_file != NULL; + if (diagnostic_active) for (unsigned side = 0; side < 2; side++) for (unsigned p = 0; p < path_count; p++) + if (channels[p]) diagnostic_previous[side][p] = *dummynet_get_stats(channels[p], side); } /* Ограниченная очередь источника: latency не скрывается за неограниченным накоплением тестовых сообщений. */ @@ -331,6 +582,7 @@ static void produce(unsigned side) { } static void snapshot(const char* phase) { + diagnostic_snapshot(phase); uint64_t now = get_time_us(); for (int side = 0; side < 2; side++) { struct ETCP_CONN* conn = ep[side].conn; @@ -361,10 +613,13 @@ static void run_for(unsigned ms, int sending, const char* phase) { if (sending) { produce(0); if (bidirectional) produce(1); } test_clock_poll(ua, 10); for (int i = 0; i < 2; i++) CHECK(ep[i].conn->state != 2 && !ep[i].conn->reinit_pending); - if (get_time_us() >= next_snapshot) { snapshot(phase); next_snapshot = get_time_us() + 100000; } + if (get_time_us() >= next_snapshot) { + snapshot(phase); next_snapshot = get_time_us() + (diagnostic_file ? diagnostic_period_us : 100000); + } if (!sending && ep[0].sent == ep[1].received && ep[1].sent == ep[0].received && !ep[0].conn->unacked_bytes && !ep[1].conn->unacked_bytes) break; } + diagnostic_snapshot(phase); } static void begin_measure(void) { @@ -375,6 +630,7 @@ static void begin_measure(void) { memset(ep[i].latency_hist, 0, sizeof(ep[i].latency_hist)); } for (unsigned p = 0; p < path_count; p++) if (channels[p]) dummynet_reset_stats(channels[p], -1); + memset(diagnostic_previous, 0, sizeof(diagnostic_previous)); } static void expect(int passed, const char* requirement, uint64_t actual, uint64_t limit) { @@ -396,6 +652,7 @@ static uint64_t percentile(const struct endpoint* endpoint, unsigned percent) { static void teardown(void) { measuring = 0; + diagnostic_active = 0; diagnostic_flush(); // Уничтожаем и активные delay/HOLD пакеты: эмуляторы не должны пережить свои endpoints. for (unsigned p = 0; p < path_count; p++) { dummynet_destroy(channels[p]); channels[p] = NULL; } for (int i = 0; i < 2; i++) node_conn_direct_close(ep[i].owner); @@ -570,6 +827,41 @@ int main(int argc, char** argv) { if (output) { csv = fopen(output, "w"); CHECK(csv); fprintf(csv, "case,mask,phase,time_ms,direction,path,rtt_tb,pacing_kbps,inflight,cwnd,conn_rtt_tb,jitter,send_q,local_recv_q,wire_bytes," "unique_bytes,retries,channel_q,app_bytes,max_gap_us,dest_recv_q,needed_seq,queue_drops\n"); } + const char* detail = getenv("UTUN_MULTIPATH_DIAG"); + if (detail) { + const char* period = getenv("UTUN_MULTIPATH_DIAG_MS"); + if (period) { + char* end = NULL; errno = 0; + unsigned long ms = strtoul(period, &end, 10); + if (errno || end == period || *end || ms < 10 || ms > 1000) { + DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "Invalid UTUN_MULTIPATH_DIAG_MS='%s': expected 10..1000", period); return 1; + } + diagnostic_period_us = ms * 1000; + } + diagnostic_file = fopen(detail, "w"); + if (!diagnostic_file) { + DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "Cannot open multipath diagnostic file '%s': errno=%d", detail, errno); return 1; + } + CHECK(fprintf(diagnostic_file, + "case,mask,phase,time_us,dt_us,side,path,app_count,app_bytes,fragment_count,fragment_bytes,send_count,send_bytes," + "wait_ack_count,wait_ack_bytes,recv_count,recv_bytes,send_fresh,send_retries,normalizer_bytes,pending_bytes," + "normalizer_waiter,normalizer_flush,unacked_bytes,admission_budget,flight_bytes,attempts,cwnd_bytes,pacing_Bps," + "bbr_mode,bbr_cycle,min_rtt_us,srtt_us,jitter_us,rto_us,bw_q16,queue_cap_bytes,ack_count,ack_timer," + "till_pending,urgent_reply,ack_oldest_us,attempt_oldest_us,pacer_debt_us,pacer_timer,pacer_ready_us,burst,stalled," + "rto_streak,channel_pending,channel_peak_since_reset,channel_delay_max_since_reset_us,wire_sent_bytes," + "channel_received_bytes,channel_lost,channel_dropped,app_delivered_bytes,data_tx_bytes,fresh_rx_bytes," + "data_packets,retries,ack_only_packets,ack_only_wire_bytes,ack_piggy_packets,ack_piggy_bytes,ack_entries," + "ack_delay_sum_us,ack_delay_max_us,early_ordinary_ack_entries,control_packets,bbr_samples,cwnd_limited_samples," + "app_limited_samples,rtt_min_us,rtt_max_us,ack_only_entries,ack_only_delay_sum_us,rtt_sum_us,rtt_samples," + "selection_checks,selection_window_blocked,selection_pacer_blocked,observed_states,flight_min,flight_max," + "cwnd_min,cwnd_max,send_peak_observed,ack_peak_observed,debt_min_us,debt_max_us,app_callback_suspended," + "app_resume_scheduled,fragment_callback_suspended,fragment_resume_scheduled,normalizer_waiter_deferred," + "tx_state,tx_scheduled,tx_delayed,tx_running,active_attempts,next_tx_seq,peer_ack_till,local_delivered_till," + "ordinary_ack_only_packets,early_ordinary_ack_packets\n") > 0); + debug_set_category_level(DEBUG_CATEGORY_DEBUG, DEBUG_LEVEL_INFO); + DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "Multipath diagnostics: period_us=%u file=%s; snapshots buffered until drain", + diagnostic_period_us, detail); + } struct profile matrix[][3] = { {{20,8000},{20,8000}}, {{20,2000},{20,8000}}, {{20,8000},{200,8000}}, {{20,2000},{200,8000}}, {{20,8000},{200,2000}}, {{20,2000},{80,8000},{200,2000}}, {{20,2000},{80,8000}} @@ -608,6 +900,10 @@ int main(int argc, char** argv) { } CHECK(selected && argc <= 2); if (csv) CHECK(fclose(csv) == 0); + if (diagnostic_file) { + FILE* completed = diagnostic_file; diagnostic_file = NULL; + CHECK(fclose(completed) == 0); + } DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "Multipath completed: failures=%u", failures); test_clock_finish(); socket_platform_cleanup(); return failures ? 1 : 0; } diff --git a/tests/test_etcp_windows.c b/tests/test_etcp_windows.c index 993df7f8..d7115687 100644 --- a/tests/test_etcp_windows.c +++ b/tests/test_etcp_windows.c @@ -33,13 +33,21 @@ static uint32_t last_mss; static unsigned fail_rx_message_at, fail_tx_inflight, fail_normalizer_timer, fail_normalizer_waiter, fail_session_timer_at; static unsigned no_alloc, unexpected_alloc, fail_rng, session_sends, received, receive_action; static uint8_t received_values[8]; +static unsigned ack_clock_active, capture_ack_timer, fail_ack_timer; +static uint64_t ack_now_us; +static timeout_callback_t captured_ack_callback; +static void* captured_ack_arg; /* Фиксируем только TB на время синхронной проверки pacer; uasync при этих часах не опрашивается. */ uint64_t __real_get_time_tb(void); uint64_t __wrap_get_time_tb(void) { + if (ack_clock_active) return ack_now_us / 100; return pacer_clock_active ? pacer_now_tb : __real_get_time_tb(); } +uint64_t __real_get_time_us(void); +uint64_t __wrap_get_time_us(void) { return ack_clock_active ? ack_now_us : __real_get_time_us(); } + void* __real_uasync_call_soon(struct UASYNC*, void*, timeout_callback_t); void* __wrap_uasync_call_soon(struct UASYNC* loop, void* arg, timeout_callback_t callback) { if (fail_normalizer_timer && conn && arg == conn->normalizer) { fail_normalizer_timer--; return NULL; } @@ -52,6 +60,8 @@ void* __wrap_uasync_call_soon(struct UASYNC* loop, void* arg, timeout_callback_t void* __real_uasync_set_timeout(struct UASYNC*, int, void*, timeout_callback_t, const char*); void* __wrap_uasync_set_timeout(struct UASYNC* loop, int delay, void* arg, timeout_callback_t callback, const char* name) { + if (capture_ack_timer && !strcmp(name, "etcp_ack_resp")) { captured_ack_callback = callback; captured_ack_arg = arg; } + if (fail_ack_timer && !strcmp(name, "etcp_ack_resp")) { fail_ack_timer--; return NULL; } if (capture_shaper && name && !strcmp(name, "etcp_shaper")) { shaper_arm_count++; captured_shaper_delay = delay; captured_shaper_callback = callback; captured_shaper_arg = arg; @@ -152,6 +162,8 @@ static void setup(void) { fail_rx_message_at = fail_tx_inflight = fail_normalizer_timer = fail_normalizer_waiter = fail_session_timer_at = 0; no_alloc = unexpected_alloc = fail_rng = session_sends = received = receive_action = 0; memset(received_values, 0, sizeof(received_values)); + ack_clock_active = capture_ack_timer = fail_ack_timer = 0; + captured_ack_callback = NULL; captured_ack_arg = NULL; last_sent_link = NULL; last_sent_request = last_sent_payload = 0; ua = uasync_create(); CHECK(ua); inst.ua = ua; inst.node_id = 1; inst.config = &cfg; cfg.global.bbr_max_cwnd = INFLIGHT_LIM_MAX; @@ -175,6 +187,7 @@ static void setup(void) { } static void teardown(void) { + ack_clock_active = 0; check_accounting(); etcp_connection_close(conn); conn = NULL; uasync_poll(ua, 0); @@ -204,6 +217,12 @@ static struct ETCP_DGRAM* request(void) { return pkt; } +/* Wire/window-тесты моделируют уже ожидающий ACK, не меняя часы и не запуская sender через uasync. */ +static void mature_acks(struct ETCP_LINK* link) { + for (struct ll_entry* e = link->ack_q->head; e; e = e->next) + ((struct ACK_PACKET*)e)->received_at_us = get_time_us() - ETCP_ACK_DELAY_US; +} + static void fill(struct ETCP_LINK* target, unsigned count, unsigned len) { int up_a = a->link_status, up_b = b->link_status; a->link_status = target == a; b->link_status = target == b; @@ -788,6 +807,7 @@ static void retry_waits_for_alternative(void) { CHECK(!sends && packet->attempt_count == 1 && !packet->active_attempts && !a->retrans_timer && !b->retrans_timer); CHECK(packet->state == INFLIGHT_STATE_WAIT_SEND && packet->last_link == a && ua->timer_alloc_count == allocated); CHECK(etcp_tx_queue_ack(a, 99, etcp_tx_attempt_id(99, 1)) == 0); + mature_acks(a); struct ETCP_DGRAM* pkt = request(); CHECK(pkt && pkt->link == a && !pkt->payload_len && !pkt->attempt); memory_pool_free(inst.pkt_pool, pkt); a->burst_active = 1; a->burst_count = 1; @@ -1124,6 +1144,162 @@ static void independent_attempt_acks(void) { check_accounting(); teardown(); } +/* Посторонний wake не снимает задержку ACK; точные границы проверяются без опроса uasync при ручных часах. */ +static void ack_absolute_deadline(void) { + setup(); conn->got_initial_pkt = 1; + ack_now_us = get_time_us(); ack_clock_active = capture_ack_timer = 1; + receive_fragment(a, 2, 1); CHECK(a->ack_q->count == 1 && conn->ack_resp_timer); + etcp_conn_wake_sender(conn); + struct ETCP_DGRAM* pkt = request(); + DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "ACK deadline: age_us=0 queued=%d sent_early=%u", a->ack_q->count, pkt != NULL); + CHECK(!pkt && a->ack_q->count == 1); + ack_now_us += 1999; CHECK(!request() && a->ack_q->count == 1); + ack_now_us++; pkt = request(); CHECK(pkt && pkt->link == a && pkt->data[1] == 1); + memory_pool_free(inst.pkt_pool, pkt); CHECK(!a->ack_q->count && !conn->ack_resp_timer); + teardown(); +} + +static void ack_piggyback_deadlines(void) { + setup(); conn->got_initial_pkt = 1; + ack_now_us = get_time_us(); ack_clock_active = capture_ack_timer = 1; + uint64_t begin = ack_now_us; + receive_fragment(a, 2, 1); ack_now_us += 1000; receive_fragment(b, 3, 1); + CHECK(conn->ack_resp_deadline_us == begin + ETCP_ACK_DELAY_US); + enqueue(100); + struct ETCP_DGRAM* pkt = request(); CHECK(pkt && pkt->link == a && pkt->payload_len && pkt->data[1] == 1); + memory_pool_free(inst.pkt_pool, pkt); CHECK(!a->ack_q->count && b->ack_q->count == 1); + CHECK(conn->ack_resp_timer && conn->ack_resp_deadline_us == begin + 3000); + ack_now_us = begin + 2000; CHECK(!request()); + ack_now_us = begin + 2999; CHECK(!request()); + ack_now_us++; pkt = request(); CHECK(pkt && pkt->link == b && !pkt->payload_len && pkt->data[1] == 1); + memory_pool_free(inst.pkt_pool, pkt); CHECK(!conn->ack_resp_timer && !conn->ack_resp_deadline_us); + ack_all(); teardown(); +} + +/* Частичный piggyback меняет голову: прежний deadline больше не делает молодой ACK готовым. */ +static void ack_partial_piggyback(void) { + setup(); conn->got_initial_pkt = 1; b->link_status = 0; + ack_now_us = get_time_us(); ack_clock_active = capture_ack_timer = 1; + uint64_t begin = ack_now_us; + receive_fragment(a, 2, 1); ack_now_us += 500; receive_fragment(a, 3, 1); + unsigned max_body = a->mtu - WIRE_OVERHEAD(AF_INET); + enqueue(max_body - ETCP_ACK_BASE_SIZE - ETCP_PAYLOAD_HEADER_SIZE - ETCP_ACK_ENTRY_SIZE - ETCP_ACK_RX_POINT_SIZE); + ack_now_us = begin + 1999; + struct ETCP_DGRAM* pkt = request(); CHECK(pkt && pkt->payload_len && pkt->data[1] == 1 && a->ack_q->count == 1); + memory_pool_free(inst.pkt_pool, pkt); CHECK(conn->ack_resp_deadline_us == begin + 2500); + timeout_callback_t callback = captured_ack_callback; void* arg = captured_ack_arg; + ack_now_us = begin + 2000; uasync_cancel_timeout(ua, conn->ack_resp_timer); callback(arg); + CHECK(conn->ack_resp_timer && conn->ack_resp_deadline_us == begin + 2500 && !request()); + ack_now_us = begin + 2100; receive_fragment(a, 4, 1); + CHECK(conn->ack_resp_deadline_us == begin + 2500); + ack_now_us = begin + 2500; pkt = request(); CHECK(pkt && !pkt->payload_len && pkt->data[1] == 2); + memory_pool_free(inst.pkt_pool, pkt); CHECK(!a->ack_q->count && !conn->ack_resp_timer); + ack_all(); teardown(); +} + +/* Новый RX между timer и отложенным sender не запускает для старой головы очередные 2мс. */ +static void ack_timer_interleaving(void) { + setup(); conn->got_initial_pkt = 1; + ack_now_us = get_time_us(); ack_clock_active = capture_ack_timer = 1; + uint64_t begin = ack_now_us; + receive_fragment(a, 2, 1); + timeout_callback_t callback = captured_ack_callback; void* arg = captured_ack_arg; + ack_now_us += ETCP_ACK_DELAY_US; + uasync_cancel_timeout(ua, conn->ack_resp_timer); callback(arg); + CHECK(!conn->ack_resp_timer && conn->tx_token); + receive_fragment(a, 3, 1); CHECK(!conn->ack_resp_timer && conn->tx_token); + struct ETCP_DGRAM* pkt = request(); CHECK(pkt && pkt->data[1] == 2 && !pkt->payload_len); + const uint8_t* entry = pkt->data + ETCP_ACK_BASE_SIZE; + uint32_t delay = (uint32_t)entry[6] | ((uint32_t)entry[7] << 8) | ((uint32_t)entry[8] << 16) | ((uint32_t)entry[9] << 24); + CHECK(delay == ETCP_ACK_DELAY_US && ack_now_us == begin + ETCP_ACK_DELAY_US); + memory_pool_free(inst.pkt_pool, pkt); teardown(); +} + +static void ack_urgent_and_oom(void) { + for (unsigned mode = 0; mode < 2; mode++) { + setup(); conn->got_initial_pkt = 1; + ack_now_us = get_time_us(); ack_clock_active = 1; + fail_ack_timer = mode; + receive_fragment(a, 2, 1); + if (!mode) receive_till(a, 0, 1); + CHECK(a->ack_reply_pending && !fail_ack_timer); + CHECK(((struct ACK_PACKET*)a->ack_q->head)->received_at_us == ack_now_us); + struct ETCP_DGRAM* pkt = request(); CHECK(pkt && pkt->data[1] == 1 && !pkt->payload_len); + memory_pool_free(inst.pkt_pool, pkt); + CHECK(!a->ack_reply_pending && !conn->ack_resp_timer && !conn->ack_resp_deadline_us); + teardown(); + } +} + +static void ack_partial_frame_and_lifecycle(void) { + setup(); ack_now_us = get_time_us(); ack_clock_active = 1; + for (unsigned n = 0; n < 200; n++) CHECK(etcp_tx_queue_ack(a, 100+n, etcp_tx_attempt_id(100+n, 1)) == 0); + ack_now_us += ETCP_ACK_DELAY_US; + struct ETCP_DGRAM* pkt = request(); CHECK(pkt && pkt->data[1] && a->ack_q->count); + memory_pool_free(inst.pkt_pool, pkt); CHECK(!conn->ack_resp_timer); + pkt = request(); CHECK(pkt && pkt->data[1] && !a->ack_q->count); + memory_pool_free(inst.pkt_pool, pkt); teardown(); + + setup(); ack_now_us = get_time_us(); ack_clock_active = 1; + conn->ack_till_deadline_us = ack_now_us + ETCP_ACK_DELAY_US; conn->last_delivered_id = 17; + CHECK(!request() && conn->ack_resp_timer); + ack_now_us += ETCP_ACK_DELAY_US; + pkt = request(); CHECK(pkt && !pkt->data[1] && pkt->data[2] == 17); + memory_pool_free(inst.pkt_pool, pkt); CHECK(!conn->ack_till_deadline_us && !conn->ack_resp_timer); + CHECK(etcp_tx_queue_ack(a, 99, etcp_tx_attempt_id(99, 1)) == 0); CHECK(!request() && conn->ack_resp_timer); + etcp_conn_reset(conn); CHECK(!conn->ack_resp_timer && !conn->ack_resp_deadline_us && !conn->ack_till_deadline_us); + teardown(); +} + +static void ack_sender_failure(void) { + setup(); conn->got_initial_pkt = 1; CHECK(etcp_conn_ref_take(conn) == 0); + ack_now_us = get_time_us(); ack_clock_active = capture_ack_timer = 1; + receive_fragment(a, 2, 1); + timeout_callback_t callback = captured_ack_callback; void* arg = captured_ack_arg; + ack_now_us += ETCP_ACK_DELAY_US; fail_tx_timer = 1; + uasync_cancel_timeout(ua, conn->ack_resp_timer); callback(arg); + CHECK(!fail_tx_timer && conn->close_requested && !conn->ack_resp_timer); + ack_clock_active = 0; uasync_drain_immediate(ua); + CHECK(conn->state == 2 && !conn->links && !conn->ack_resp_deadline_us && !conn->ack_till_deadline_us); + etcp_conn_ref_free(conn); teardown(); +} + +/* DOWN не мешает срокам других путей; UP возвращает старые ACK сразу. Удаление головы не оставляет чужой deadline. */ +static void ack_link_lifecycle(void) { + for (unsigned remove = 0; remove < 2; remove++) { + setup(); conn->got_initial_pkt = 1; + ack_now_us = get_time_us(); ack_clock_active = 1; + uint64_t begin = ack_now_us; + receive_fragment(a, 2, 1); ack_now_us += 1000; receive_fragment(b, 3, 1); + if (remove) { etcp_link_close(a); a = NULL; } + else a->link_status = 0; + ack_now_us = begin + 2000; CHECK(!request() && conn->ack_resp_deadline_us == begin + 3000); + ack_now_us = begin + 3000; + struct ETCP_DGRAM* pkt = request(); CHECK(pkt && pkt->link == b && pkt->data[1] == 1); + memory_pool_free(inst.pkt_pool, pkt); CHECK(!conn->ack_resp_timer); + if (!remove) { + a->link_status = 1; etcp_conn_wake_sender(conn); + pkt = request(); CHECK(pkt && pkt->link == a && pkt->data[1] == 1); + memory_pool_free(inst.pkt_pool, pkt); + } + teardown(); + } +} + +/* Готовый DATA другого пути не должен обходить уже наступивший срок ACK. */ +static void ack_due_other_link(void) { + setup(); conn->got_initial_pkt = 1; + ack_now_us = get_time_us(); ack_clock_active = 1; + receive_fragment(b, 2, 1); b->inflight_lim_bytes = 1; + ack_now_us += ETCP_ACK_DELAY_US; + struct INFLIGHT_PACKET* fresh = enqueue(100); + struct ETCP_DGRAM* pkt = request(); + CHECK(pkt && pkt->link == b && !pkt->payload_len && pkt->data[1] == 1 && fresh->state == INFLIGHT_STATE_WAIT_SEND); + memory_pool_free(inst.pkt_pool, pkt); + pkt = request(); CHECK(pkt && pkt->link == a && pkt->payload_len == 100); + memory_pool_free(inst.pkt_pool, pkt); ack_all(); teardown(); +} + static void receive_admission_and_path_acks(void) { setup(); conn->got_initial_pkt = 1; fail_rx_data = 1; receive_fragment(a, 2, 1); @@ -1250,6 +1426,7 @@ static void receiver_point_wire(void) { CHECK(a->rx_data_bytes == 2 && a->ack_q->count == 1 && conn->recv_q->count == 1); a->rx_data_bytes = UINT64_C(0xfedcba9876543210); a->rx_data_time_us = UINT64_C(0x0011223344556677); const uint8_t expected[] = {0x10,0x32,0x54,0x76,0x98,0xba,0xdc,0xfe,0x77,0x66,0x55,0x44,0x33,0x22,0x11,0x00}; + mature_acks(a); struct ETCP_DGRAM* pkt = request(); CHECK(pkt && pkt->data[1] == 1 && !pkt->payload_len); CHECK(pkt->data_len == etcp_ack_section_size(1) && !memcmp(pkt->data+ETCP_ACK_BASE_SIZE+ETCP_ACK_ENTRY_SIZE,expected,16)); memory_pool_free(inst.pkt_pool,pkt); @@ -1266,12 +1443,14 @@ static void receiver_point_wire(void) { else CHECK(!pkt->data[1] && a->ack_q->count == 1); memory_pool_free(inst.pkt_pool,pkt); if (a->ack_q->count) { + mature_acks(a); pkt = request(); CHECK(pkt && pkt->data[1] == 1 && !pkt->payload_len); memory_pool_free(inst.pkt_pool,pkt); } ack(seq); } for (unsigned i = 100; i < 300; i++) receive_fragment(a,i,1000+i); + mature_acks(a); unsigned count = (max_body-ETCP_ACK_BASE_SIZE-ETCP_ACK_RX_POINT_SIZE)/ETCP_ACK_ENTRY_SIZE; pkt = request(); CHECK(pkt && pkt->data[1] == count && pkt->data_len <= max_body && a->ack_q->count == 200-(int)count); memory_pool_free(inst.pkt_pool,pkt); @@ -1472,6 +1651,7 @@ static void compact_attempt_identity(void) { conn->got_initial_pkt = 1; receive_fragment(a,2,1); receive_fragment(a,3,1); receive_fragment(a,2,1); CHECK(a->ack_q->count == 2 && a->rx_data_bytes == 3); + mature_acks(a); struct ETCP_DGRAM* pkt = request(); CHECK(pkt && !pkt->payload_len && pkt->data[1] == 2); const uint8_t entry1[] = {2,0,0,0,1,0}, entry2[] = {3,0,0,0,1,0}; CHECK(!memcmp(pkt->data+ETCP_ACK_BASE_SIZE,entry1,sizeof(entry1))); @@ -1545,6 +1725,8 @@ int main(void) { retry_waits_for_alternative, retry_alternative_lifecycle, send_queue_scan, send_queue_input_admission, retry_full_round_history, retry_history_slot_reuse, fast_stall_progress, stalled_retry_selection, input_admission_callbacks, + ack_absolute_deadline, ack_piggyback_deadlines, ack_partial_piggyback, ack_timer_interleaving, + ack_urgent_and_oom, ack_partial_frame_and_lifecycle, ack_sender_failure, ack_link_lifecycle, ack_due_other_link, receive_admission_and_path_acks, peer_window_queries, pacing_continuity, pacing_data_units, attempt_memory_limits, normalizer_rx_oom, normalizer_tx_oom, normalizer_callback_abort, reset_without_allocation, restart_allocation_failures, receiver_bandwidth_bounds, receiver_point_lifecycle, receiver_point_wire, receiver_point_truncated, diff --git a/tests/test_media_primitives.c b/tests/test_media_primitives.c index 9a82355a..a02be870 100644 --- a/tests/test_media_primitives.c +++ b/tests/test_media_primitives.c @@ -19,7 +19,8 @@ static char root[256], source[512], destination[512]; static unsigned visits; #ifdef __linux__ -enum fault { NONE, READ, WRITE, FLUSH, SOURCE_CLOSE, OUTPUT_CLOSE, FDOPEN, PUBLISH, OPEN_DIR, READ_DIR, CLOSE_DIR, SEEK, THREAD }; +enum fault { NONE, READ, WRITE, FLUSH, SOURCE_CLOSE, OUTPUT_CLOSE, FDOPEN, PUBLISH, OPEN_DIR, READ_DIR, CLOSE_DIR, SEEK, THREAD, + SYNC_FILE, SYNC_DIRECTORY }; static enum fault fault; static int injected, read_failed, rng_after = -1, allocation_after = -1; static FILE *input_file, *output_file; @@ -35,6 +36,7 @@ extern size_t __real_fwrite(const void*, size_t, size_t, FILE*); extern int __real_ferror(FILE*), __real_fflush(FILE*), __real_fclose(FILE*); extern int __real_rename(const char*, const char*), __real_RAND_bytes(unsigned char*, int); extern int __real_fseeko(FILE*, off_t, int); +extern int __real_fsync(int); extern DIR *__real_opendir(const char*); extern struct dirent *__real_readdir(DIR*); extern int __real_closedir(DIR*); @@ -84,6 +86,11 @@ int __wrap_fclose(FILE *file) { return result; } int __wrap_rename(const char *from, const char *to) { return inject(PUBLISH) ? -1 : __real_rename(from, to); } +int __wrap_fsync(int fd) { + struct stat data; assert(!fstat(fd, &data)); + if (inject(S_ISDIR(data.st_mode) ? SYNC_DIRECTORY : SYNC_FILE)) return -1; + return __real_fsync(fd); +} int __wrap_RAND_bytes(unsigned char *out, int size) { if (rng_after == 0) { injected = 1; return 0; } if (rng_after > 0) rng_after--; @@ -169,7 +176,7 @@ static void temporary_tests(void) { struct file_io_temp outputs[3] = {0}; char paths[3][FILE_IO_PATH_MAX], stale[FILE_IO_PATH_MAX]; for (unsigned i = 0; i < 3; i++) { - assert(!file_io_temp_open(&outputs[i], destination)); + assert(!file_io_temp_open(&outputs[i], destination, 0)); snprintf(paths[i], sizeof(paths[i]), "%s", outputs[i].temporary); assert(file_io_remove_orphan(paths[i]) == 0); assert(fputs("owned", outputs[i].file) >= 0 && !file_io_temp_finish(&outputs[i])); @@ -187,10 +194,10 @@ static void temporary_tests(void) { #ifdef __linux__ struct file_io_temp failed = {0}; fault = FDOPEN; injected = 0; - assert(file_io_temp_open(&failed, destination) == -1 && injected); fault = NONE; + assert(file_io_temp_open(&failed, destination, 0) == -1 && injected); fault = NONE; assert(failed.temporary[0] && file_io_remove_orphan(failed.temporary) == 0); file_io_temp_discard(&failed); - assert(!file_io_temp_open(&failed, destination) && !file_io_temp_finish(&failed)); + assert(!file_io_temp_open(&failed, destination, 0) && !file_io_temp_finish(&failed)); fault = PUBLISH; injected = 0; assert(file_io_temp_publish(&failed, source, destination) == -1 && injected); fault = NONE; assert(file_io_remove_orphan(failed.temporary) == 0); file_io_temp_discard(&failed); @@ -198,6 +205,40 @@ static void temporary_tests(void) { DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS active temp ownership: open/finished, multiple generations, publish/discard and failures"); } +/* Ошибка до rename сохраняет прежний файл; отказ directory sync уже не отменяет замену. */ +static void durable_tests(void) { + struct file_io_temp output = {0}; + write_text(destination, "previous"); + assert(!file_io_temp_open(&output, destination, FILE_IO_DURABLE)); + assert(fputs("durable", output.file) >= 0 && !file_io_temp_finish(&output)); + check_text(destination, "previous"); assert(file_io_remove_orphan(output.temporary) == 0); + assert(!file_io_temp_publish(&output, source, destination) && !output.temporary[0]); + file_io_temp_discard(&output); check_text(destination, "durable"); + assert(!file_io_sync_file(destination) && !file_io_sync_directory("relative-name")); +#ifndef _WIN32 + assert(!file_io_sync_directory("/file")); +#endif +#ifdef __linux__ + assert(!file_io_temp_open(&output, destination, FILE_IO_DURABLE)); + assert(fputs("failed", output.file) >= 0); + fault = SYNC_FILE; injected = 0; + assert(file_io_temp_finish(&output) == -1 && injected && !output.file && output.temporary[0]); fault = NONE; + file_io_temp_discard(&output); check_text(destination, "durable"); + assert(!file_io_temp_open(&output, destination, FILE_IO_DURABLE)); + assert(fputs("installed", output.file) >= 0 && !file_io_temp_finish(&output)); + fault = SYNC_DIRECTORY; injected = 0; + assert(file_io_temp_publish(&output, source, destination) == -1 && injected && !output.temporary[0]); fault = NONE; + file_io_temp_discard(&output); check_text(destination, "installed"); + assert(!file_io_temp_open(&output, destination, 0)); + assert(fputs("ordinary", output.file) >= 0); + fault = SYNC_FILE; injected = 0; + assert(!file_io_temp_finish(&output) && !injected); fault = SYNC_DIRECTORY; + assert(!file_io_temp_publish(&output, source, destination) && !injected); fault = NONE; + file_io_temp_discard(&output); check_text(destination, "ordinary"); +#endif + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS durable temp: sync-before-close, rename commit point, directory failure and ordinary mode"); +} + static void crypto_tests(void) { uint8_t output[64], zero[64] = {0}; memset(output, 0xa5, sizeof(output)); @@ -291,7 +332,7 @@ static void registration_tests(struct UASYNC *ua) { struct temp_job { struct file_io_temp output; pthread_barrier_t barrier; int publish; }; static void* temp_worker(void* arg) { struct temp_job* job = arg; - assert(!file_io_temp_open(&job->output, destination)); + assert(!file_io_temp_open(&job->output, destination, 0)); assert(fputs("worker", job->output.file) >= 0 && !file_io_temp_finish(&job->output)); pthread_barrier_wait(&job->barrier); pthread_barrier_wait(&job->barrier); if (job->publish) assert(!file_io_temp_publish(&job->output, source, destination)); @@ -384,7 +425,7 @@ int main(void) { #endif assert(!utun_mkdir(root, 0700)); snprintf(source, sizeof(source), "%s/source", root); snprintf(destination, sizeof(destination), "%s/destination", root); - copy_tests(); temporary_tests(); crypto_tests(); size_and_walk_tests(); + copy_tests(); temporary_tests(); durable_tests(); crypto_tests(); size_and_walk_tests(); struct UASYNC *ua = uasync_create(); assert(ua); registration_tests(ua); #ifdef __linux__ diff --git a/tools/etcp_diag_report.py b/tools/etcp_diag_report.py new file mode 100644 index 00000000..641bb54d --- /dev/null +++ b/tools/etcp_diag_report.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Проверить CSV UTUN_MULTIPATH_DIAG и построить автономный HTML с графиками и исходными срезами.""" +import argparse +import csv +import json +from pathlib import Path + + +def read_samples(path): + """Проверить схему, учёт очередей и интервалы; неправильный CSV не превращать в правдоподобный график.""" + with path.open(newline="", encoding="utf-8") as source: + reader = csv.DictReader(source) + if not reader.fieldnames or len(set(reader.fieldnames)) != len(reader.fieldnames): + raise ValueError("отсутствуют или повторяются заголовки CSV") + rows = [] + previous = {} + for line, raw in enumerate(reader, 2): + if None in raw or any(value is None for value in raw.values()): + raise ValueError(f"строка {line}: число значений не соответствует заголовку") + row = {key: value if key in ("case", "mask", "phase") else int(value) for key, value in raw.items()} + key = (row["case"], row["mask"], row["side"], row["path"]) + valid = row["dt_us"] > 0 and row["unacked_bytes"] == row["send_bytes"] + row["wait_ack_bytes"] + valid &= row["send_count"] == row["send_fresh"] + row["send_retries"] + valid &= row["flight_min"] <= row["flight_bytes"] <= row["flight_max"] + valid &= row["cwnd_min"] <= row["cwnd_bytes"] <= row["cwnd_max"] + valid &= row["early_ordinary_ack_entries"] <= row["ack_only_entries"] <= row["ack_entries"] + valid &= row["early_ordinary_ack_packets"] <= row["ordinary_ack_only_packets"] <= row["ack_only_packets"] + valid &= row["retries"] <= row["data_packets"] and row["rtt_samples"] <= row["bbr_samples"] + if key in previous: + valid &= row["time_us"] - previous[key] == row["dt_us"] + if not valid: + raise ValueError(f"строка {line}: нарушен учёт очередей, счётчиков или времени") + previous[key] = row["time_us"] + rows.append(row) + if not rows: + raise ValueError("CSV не содержит срезов") + return rows + + +PAGE = r''' +ETCP: очереди и pacing + +

ETCP: динамика очередей, DATA и ACK

+

Источник: . Данные проверены. Наведите курсор на график для просмотра всех полей среза.

+ + + + +

Очереди приложения/normalizer/send/wait_ack общие для соединения. Flight, pacer, ACK и канал — для выбранного пути. +Блокировки — число проверок выбора, а не время ожидания. Оранжевый фон: BBR PROBE_RTT. +Пики очереди и задержки эмулятора накоплены с последнего сброса его статистики.

+

Исходный срез

+''' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("csv", type=Path) + parser.add_argument("html", type=Path) + args = parser.parse_args() + try: + rows = read_samples(args.csv) + page = PAGE.replace("__ROWS__", json.dumps(rows, ensure_ascii=False).replace("<", "\\u003c")) + page = page.replace("__SOURCE__", json.dumps(args.csv.name, ensure_ascii=False).replace("<", "\\u003c")) + args.html.write_text(page, encoding="utf-8") + except (OSError, ValueError, KeyError) as error: + parser.exit(1, f"Ошибка отчёта ETCP: {error}\n") + print(f"Проверено {len(rows)} срезов; отчёт: {args.html}") + + +if __name__ == "__main__": + main()