From 5a9e61164bcebb778ae13d6abd0f63c421771682 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 10 Oct 2026 12:31:13 +0200 Subject: [PATCH] Measure uasync CPU load and display it in desktop and Android GUIs Sample thread CPU usage without waking idle loops, expose a one-second moving average, and refresh compact GUI indicators at 3 Hz while visible on Android. Add load and bridge regression coverage, streaming notes, and task list cleanup. --- doc/stream.txt | 10 + doc/tasks.md | 573 +----------------- lib/u_async.c | 149 ++++- lib/u_async.h | 24 + lib/u_async_doc.md | 27 + src/chat/chat_core.h | 3 + src/chat/chat_event.c | 1 + src/chat/chat_event.h | 1 + src/chat/chat_headless_control.c | 6 + src/chat/chat_status.c | 20 + tests/Makefile.am | 4 + tests/test_uasync_load.c | 238 ++++++++ .../main/java/com/utun/chat/MainActivity.kt | 3 + .../main/java/com/utun/chat/data/NativeLib.kt | 2 + .../chat/ui/components/CpuLoadIndicator.kt | 39 ++ .../utun/chat/ui/screens/ChannelListScreen.kt | 9 + .../com/utun/chat/viewmodel/ChatViewModel.kt | 32 + .../jni_bridge/android_jni_bridge.c | 13 + tools/chatgui/src/channellist.cpp | 41 +- tools/chatgui/src/channellist.h | 3 + tools/chatgui/src/mainwindow.cpp | 22 + tools/chatgui/src/mainwindow.h | 2 + tools/chatgui/tests/test_message_events.cpp | 31 +- tools/chatgui/transport/gui_bridge.h | 5 + tools/chatgui/transport/gui_bridge_impl.cpp | 21 + 25 files changed, 692 insertions(+), 587 deletions(-) create mode 100644 doc/stream.txt create mode 100644 tests/test_uasync_load.c create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/CpuLoadIndicator.kt diff --git a/doc/stream.txt b/doc/stream.txt new file mode 100644 index 00000000..6f8db257 --- /dev/null +++ b/doc/stream.txt @@ -0,0 +1,10 @@ +Модуль стриминга. Архитектурные нюансы. + +- реализуем в utun отдельным каталогом: src/stream + +- создаем отдельные списки подключений (ncd) для стриминга. +главная цель: узлы ищут более оптимальные пути доставки контента от источника до себя. +Надо учитывать что узлы могут быть перегружены трафиком. 2 сигнала перегрузки: очередь отправки и поток uasync Не справляется с нагрузкой. +надо сделать замер нагрузки на uasync. +- узлы учавствующие в стриминге анонсируют себя аналогично как сделано в рации. +У модуля свой фоновый опросчик связности и поиска хороших линков - он пробует связь с узлами стриминна кроме мобильных, и обновляет их rtt и флаг прямой доступности. diff --git a/doc/tasks.md b/doc/tasks.md index 16056d69..4e391287 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -2,12 +2,7 @@ Сюда пишется список задач с короткой аннотацией. Если задача большая и имеет ТЗ - то ТЗ оформляется отдельным файлом, а сюда помещается аннотация и ссылка на ТЗ. -## Текущая задача -[+] **Group files: удаление непустых каталогов** — 10.10.2026: - Один диалог подтверждает удаление всего выделения с содержимым папок. Сначала полный постраничный обход, - затем удаление детей раньше родителей; перед удалением папки запрашивается свежая версия после собственных изменений. - Проверено на настоящем mf_storage: дерево из 46 объектов, несколько корней, отмена, изменение файла/страницы, - появление нового файла перед удалением родителя. Новые файлы не подхватываются повторным обходом. +## Текущие задачи [ ] **Member files: исчерпание кеша результатов массовых изменений** — 10.10.2026: Воспроизведено test_member_files_browser::recursiveDeletion(server-busy): из 76 объектов удаляются 64, @@ -17,10 +12,6 @@ простое вытеснение старых записей нарушает гарантию повторного ответа. После исправления изменить регрессию на успешное удаление более 64 объектов и проверить повторные запросы/потерю ответа. -[+] **Desktop: текущая вероятность речи на кнопке VAD** — 09.10.2026: - Зелёная полоса внизу кнопки отображает 0–100% из того же снимка ядра, что настройки; обновляется до 10Гц. - При недоступной оценке остаётся пустая дорожка, в режиме уровня полоса скрыта. Нажатия и удержание сохранены. - [ ] **Desktop: провалы вероятности VAD при компрессии** — диагностика 09.10.2026: Воспроизведение пользователя: длительная гласная, затем временная нечувствительность; ближе к концу лога несколько повторов. Новый лог 14:32:14–14:34:38: на 14:34:07–18 и 14:34:20–38 все 556 окон с RMS > -30dBFS имеют p < 0.5; @@ -41,107 +32,16 @@ Полного провала всей фразы почти до нуля стенд не воспроизвёл; нужен повтор живой речи с новыми логами для связи с AEC/AGC. Алгоритмы обработки сигнала пока не менялись; исправление причины выбирается по результатам повтора. -[+] **Регрессия Silero с настоящей моделью доступна в desktop CMake** — 09.10.2026: - test_silero_vad переведён на актуальный radio_auto_fsm (в autotools ONNX здесь выключен, поэтому старый тест не собирался). - Опциональный --probe сравнивает повторения одной речи при разном фоне/усилении и печатает вероятность, gain и clips. - -[+] **Desktop: общий интерфейс настроек активации рации** — 09.10.2026: - Удержание 500мс или правая кнопка на VAD/значке уровня открывает мини-блок; короткое нажатие переключает автопередачу. - В мини-блоке и вкладке используется один редактор: две кнопки режима, оба порога dBFS, порог/усиление VAD и задержка. - Одновременно отображаются RMS, вероятность речи и фактическое усиление VAD; снимок общего ядра обновляется с частотой до 10Гц. - Применение явное, закрытие отменяет несохранённый ввод; новая вкладка прокручивается на небольшом экране. - Проверки: radio_panel (удержание без toggle, повторное открытие, применение, индикаторы), gui_language, audio_recovery, voice_tx — PASS. - -[+] **Android: текущая оценка VAD и постоянные настройки обоих детекторов** — 09.10.2026: - В мини-блоке и вкладке рации одновременно доступны пороги уровня, порог VAD, усиление перед VAD и задержка отключения. - Вероятность речи из работающего Silero выводится в процентах и на шкале с отметкой порога; обновление UI — 100мс. - Рядом с пределом усиления показан фактический gain отдельного компрессора VAD в dB; выключенный отмечен как 0 dB. - Снимок native/JNI помечает оценку недоступной при RX, ошибке inference, разрыве/остановке захвата или возрасте больше 200мс. - При выключенной автоматике или режиме уровня UI предлагает включить VAD для измерения вероятности речи. - Проверки: voice_tx (вероятность, свежесть, ошибки, RX, reset), radio_member_mute, radio_panel, gui_language — PASS. - voice_tx также сравнивает отображаемый gain с эталонным VAD-компрессором для mono/stereo и проверяет выключенный компрессор. - APK arm64-v8a/x86_64 собран; новый индикатор на физическом устройстве пока не проверен. - -[+] **Android: автопередача рации по уровню сигнала** — 08.10.2026: - режим VAD/уровень, два порога dBFS, живой измеритель из общего radio_audio и prefetch 600мс. - Вкладка рации временно держит микрофон для измерения при выключенной автоматике; уход со вкладки/пауза освобождает этот захват. - Оба детектора используют прежнее окно foreground/screen/background; PTT имеет приоритет, RX-защита остаётся общей. - Настройки сохраняются одним DataStore edit и передаются одним заданием uasync с подтверждением до старта capture. - Кнопка панели показывает выбранный режим и сохранённое включение; JNI принимает явный OFF/VAD/LEVEL. - Для уровня на кнопке — значок эквалайзера; удержание VAD/уровня открывает мини-блок режима, измерителя и порогов прямо у кнопки. - Выбор режима — две кнопки VAD/уровень с областью нажатия от 48 dp, без вложенного выпадающего меню; выбор записывается в лог radio. - Добавлены русские/английские строки и диагностика. assembleDebug (arm64-v8a/x86_64), 21 JVM-тест — PASS. - На физическом устройстве новый режим пока не проверен. - -[+] **ChatGUI: автопередача рации по уровню сигнала** — 08.10.2026: - выбор VAD/уровень, RMS 20мс после AEC до компрессоров, два порога dBFS (-35/-45 по умолчанию), - подтверждение 40мс и общая задержка отключения. Измеритель работает при включённой рации даже без автоматики. - Общие FSM, prefetch 600мс, приоритет PTT и RX-защита; уровень работает без Silero. - INI: radio_auto_enabled, radio_auto_mode (1=VAD, 2=уровень), radio_auto_hangover_ms, - radio_level_on_dbfs/radio_level_off_dbfs. Пара проверяется перед сохранением; ядро отключает автоматику - при некорректной паре из INI. Общие настройки/API переименованы в Android без изменения его VAD-интерфейса. - Проверки: FSM, voice_tx (PCM/Opus/prefetch/RX/AGC), radio_panel (настройки/индикатор/INI), - radio_member_mute, voice_audio_io, audio_recovery, gui_language — PASS; ChatGUI собран. - -[+] **Индикация говорящих в списке участников Android** — 07.10.2026: - динамик без волн — молчит, с волнами — говорит; красное перечёркивание сохраняется при mute, включая речь. - Отдельный набор говорящих активной рации переживает обновления участников и просмотр другого канала; - STOP снимает только свой источник, выключение/смена активной рации и stop ядра очищают состояние. - Собственный значок использует фактический native TX (PTT/VAD) с привязкой к каналу, сброс после join capture. - Добавлены переводы и логи изменений radio. Чистые основная C-сборка и APK — PASS; 16 JVM-тестов — PASS. - APK установлен с сохранением данных на Q8 и SM-A525F; приложения запускаются без AndroidRuntime ошибок. - [ ] **Linux headless CMake: актуализировать зависимости общего ядра** — 07.10.2026: чистая сборка tools/chatgui-android/build_headless останавливается на lib/speex_aec.c: speex/speex_echo.h не найден. utun_setup_sources включает этот исходник, но UTUN_INCLUDE_DIRS не содержит lib/speexdsp/include. Проверить полный состав Speex/voice зависимостей отдельного каркаса; основная Autotools C-сборка и Android NDK-сборка проходят. -[+] **Динамик участника показывает речь в рации ChatGUI** — 07.10.2026: - без волн — молчит, с волнами — говорит, красное перечёркивание — локально заглушён; волны видны и при mute. - START/STOP обновляют строки через Qt bridge; собственная передача учитывает PTT, хоткеи и VAD. - Обновления списка/RTT сохраняют индикацию; смена канала, выключение рации и stop очищают её. - Исправлен сдвиг индексов строк при удалении участника. Добавлены переводы подсказок и логи radio. - Чистая сборка desktop, 4 Qt-теста и сетевой test_radio с FIN/таймаутом/отпиской — PASS; - полный check.sh — 138 PASS, 0 FAIL, 1 SKIP; proxy/burst/load — PASS. Формы значков проверены визуально. - -[+] **Случайный зашифрованный padding ETCP** — 07.10.2026: - keepalive UDP/TCP, UDP PING/PONG и STCP-handshake используют нулевой padding переменной длины. - Каждый ETCP_CONN хранит свой диапазон и таймер; отдельный диапазон instance обслуживает пинги без соединения. - min=rand(0,30), max=min+rand(32,128), обновление каждые rand(50,300) секунд. - Добавлены проверки границ 0/158, эхо-данных, sleep/ACK, независимости и отмены таймеров, ошибок MTU/формата. - TCP-пинг не удерживает instance. Полный check.sh: 134 PASS, 0 FAIL, 1 SKIP; proxy/burst/load — PASS. - -[+] **STCP: исключить повтор CTR-потока между подключениями** — 07.10.2026: - CTR IV теперь зависит от session_key, направления и соли отправителя из существующего блока handshake. - Потоки инициализируются по мере получения солей; шифротекст Ed25519 и нулевого padding меняется. - Проверяются повторные и параллельные подключения одной пары, короткие send/recv и подмена соли при том же ECDH. - STCP — 14/14; полный check.sh — 134 PASS, 0 FAIL, 1 SKIP; proxy/burst/load — PASS. - [ ] **REALITY: исправить формат ClientHello и продолжение TLS-потока** — 07.10.2026: supported_groups и signature_algorithms не содержат внутренние двухбайтовые длины списков. После ClientHello/ServerHello идёт STCP без TLS record framing; это распознаётся пассивным наблюдателем. -[+] **Одна реакция автора и горизонтальный выбор смайликов** — 07.10.2026: - выбор в Android и desktop отображается одной горизонтальной прокручиваемой строкой с выделением своего смайлика. - Ядро допускает одну реакцию на сообщение от автора; другой смайлик атомарно заменяет прежний, повторный клик отменяет. - Многосмайловые состояния отклоняются кодеком, в том числе корректно подписанные; ver и подпись защищают изменения/отмены. - По выбору пользователя прежнее состояние 👍+😮 заменено на 👍 новой подписанной версией 3; резервная копия и разовый - скрипт сохранены в `/home/user/utun2-backups/single-reaction-20261007`. - Local и Q8 обновлены. Тесты кодеков/API/Merkle/сетевого восстановления и 7 Qt-тестов — PASS; APK и desktop собраны. - -[+] **Реакции в нижней строке пузыря в Android и desktop** — 07.10.2026: - слева внутри пузыря, включая голосовые/файлы/изображения/видео/опросы, добавлен прозрачный контур белого сердечка - с тенью в один физический пиксель во все стороны. Большие сообщения только из эмодзи показываются без кнопки. - Сердечко открывает выбор реакции; существующие реакции добавляют/убирают собственный выбор через общий API. - Сердечко уменьшено до 20; реакции находятся в общей строке с временем справа. При переносе реакций пузырь - увеличивается по высоте, время сохраняется справа в последней строке. Media-метаданные остаются выше неё. - Проверены входящие/исходящие сообщения шести типов в узком и широком desktop-окне: нажатия не запускают медиа, - drag/right-click не отправляют реакцию, внутри сердечка нет заливки. Проверено изменение высоты строки при - добавлении/удалении 16 реакций. Чистые Android/desktop-сборки и 7 Qt-тестов — PASS. - Local перезапущен; APK установлен и запущен на Q8, профиль сохранён в `/home/user/utun2-backups/inline-reactions-20261007`. - Визуальная проверка Q8 ждёт разблокировки экрана; установка на SM — подключения устройства к ADB. - [ ] **Завершить проверку реакций и голосов на SM** — 07.10.2026: причина отказа установлена: у записей корней `signed_by=0/NULL` были нулевые/отсутствующие подписи дерева. Проверка корня ключом канала остаётся обязательной; добавлены причины отказа и проверка доверенного Ed25519. @@ -153,49 +53,6 @@ Linux/Android/desktop собраны; `./check.sh`: 133 passed, 0 failed, 1 skipped; proxy/full, burst, load и 7 Qt-тестов — PASS. Осталось установить APK на SM (`RZ8R72MG64B`) и проверить реакцию/голос на устройстве: SM отключился от ADB во время работы. -[+] **Восстановить отправку сообщений SM (Android) → local после обновления** — 07.10.2026: - существующие таблицы `msg_*` не имели нового поля `message_id`, INSERT и чтение истории завершались ошибкой. - При регистрации канала таблица атомарно перестраивается с вычислением ID из канала, timestamp и подписи; - прежние поля сообщений сохраняются, при ошибке вся операция откатывается. Список каналов читается до DDL. - `test_db_sync_schema` проверяет сохранение всех полей, одинаковые timestamp, ограничения ID, пустую историю, - повторный запуск, отправку после обновления и откат частичного копирования. Проверены копии БД SM и local. - Обновлены и запущены SM и local: все 89 сообщений каждого клиента и прежние поля сохранены, SQLite quick_check OK. - Резервные копии: `/home/user/utun2-backups/message-id-20261007`. Чистые сборки Linux и Android прошли; - `./check.sh`: 133 passed, 0 failed, 1 skipped; proxy/full, burst и load — PASS. - -[+] **Голосования, опросы и реакции в чате** — 06.10.2026, копия `/home/user/utun3`, ветка `feature/chat-feedback`: - реализован базовый слой `src/chat/chat_feedback.c/h`: таблица каждого канала, - подписи участников, независимые ver для ответа и реакции, сохраняемая отмена, - детерминированный конфликт ver, атомарное применение страниц и восстановление индекса. - Merkle v3 разделяет MEMBERS/FEEDBACK, курсор записи — 32 байта. - Итоговый `./check.sh`: 132 passed, 0 failed, 1 skipped; proxy/full, burst и load также PASS. - Чистые сборки desktop и Android assembleDebug прошли; 7 Qt-тестов — PASS. - Подключены message_id к БД/API, общий каталог 16 эмодзи, канонические форматы - poll/answer/reactions, single/multiple, allow_custom_answer, подсчёт результатов, - отложенное получение опроса, CLI, desktop и Android. Собраны desktop и Android APK. - Сетевой тест A ↔ B ↔ C прошёл, включая restart C и сохранение отмены. - Исправлен запуск начального member_sync после restart чата поверх уже UP-транспорта. - Реализация и финальная проверка завершены. APK: app/build/outputs/apk/debug/app-debug.apk - внутри tools/chatgui-android; desktop: tools/chatgui/build/vibechat. - План продолжения: - 1. Сохранить индексируемый message_id в msg_, вернуть его через API сообщений. - 2. Добавить общий каталог эмодзи и канонические кодеки poll/answer/reactions; - single/multiple и allow_custom_answer проверяются ядром. - 3. Добавить снимок результатов, список голосовавших и собственные состояния; - неизвестное сообщение откладывает учёт ответа, получение сообщения обновляет представление. - 4. Подключить единый командный API к CLI, desktop и Android; команды выполняются в uasync. - 5. Прогнать проверки кодеков и сетевые сценарии, собрать интерфейсы и выполнить check.sh. - -[+] **tcp_proxy_full: проверить аномальные значения elapsed в отчёте** — 06.10.2026: - `/tmp/utun3-check.log` при успешной передаче показывает длительности вроде - `basic_1mb: 161949390ms` и `concurrent_20: -1612179507375916304ms`. - В `tests/tcp_proxy_full/run_test.sh` длительность считается через `date +%s%3N`; - проверить фактический формат времени и арифметику в окружении запуска. - Причина установлена: date +%s%3N в окружении отдаёт 19 цифр наносекунд вместо миллисекунд. - run_test.sh переведён на Python time.monotonic_ns() // 1000000. - Подтверждено финальным check.sh: basic_1mb 179ms, concurrent_20 326ms, idle 1080ms; - все результаты передачи и проверки байтов — PASS. Лог: /tmp/utun3-polls-check.log. - [ ] **rlottie: разбор предупреждений GCC 15 в импортированных исходниках** — 05.10.2026: при сборке `tools/chatgui/rlottie_src` GCC сообщает `-Wuninitialized` в `VArenaAlloc::reset()`: fFirstBlock/fFirstSize/fFirstHeapAllocationSize читаются @@ -205,17 +62,6 @@ Лог: `/tmp/utun-rlottie-vendor-build.log`. Сборка библиотеки и подключение её экспортированного CMake-пакета к ChatGUI на Linux прошли; Windows ещё не проверен. -[+] **ChatGUI Windows: проверка линковки ZXing без -Os** — 05.10.2026: - после удаления старых DLL и чистой сборки линкер не находит move-конструктор - `std::string` в `libZXing.a(Barcode.cpp.obj)`. ZXing задаёт этому файлу -Os; - такая ошибка описана в https://github.com/msys2/MINGW-packages/issues/31015. - Для MinGW GCC отключён пофайловый -Os; используются флаги выбранного типа сборки. - Проверена генерация команд: при MinGW+GNU Barcode.cpp получает -O3 без -Os, - Linux сохраняет прежний -Os. Компиляция Barcode.cpp на Linux прошла. - Windows-прогон `tools/chatgui/build.bat` прошёл линковку и дошёл до раскладки DLL; - это подтверждено следующим `1.log`. В списке системных DLL раскладки также добавлены - `imagehlp.dll` и `bcryptprimitives.dll`, ошибочно считавшиеся отсутствующими зависимостями. - [ ] **ChatGUI Windows: подтверждение обновления runtime компилятора** — 05.10.2026: `cc-probe.log`: cc1 запускается из корня, но в `tools/chatgui/build` завершается с `0xC0000139`; локальные DLL GCC/math/runtime отличаются от UCRT64. @@ -231,124 +77,11 @@ Runtime GCC обновляется отдельно; для остальных DLL нужно продумать обновление с сохранением приоритета собственной сборки FFmpeg и проверить запуск приложения. -[+] **Открытие HTTP/HTTPS в сообщениях Android и Qt** — 04.10.2026: - общая обработка ссылок по диапазонам текста; веб-адреса открываются системным - обработчиком, `utun://` сохраняет JOIN и короткое отображение PM-приглашений. - Учтены Unicode, IPv6, скобки, пунктуация, параметры, fragment и emoji в адресах. - Qt использует одинаковый layout для отрисовки, размеров, нажатия и выделения. - Ошибки открытия переведены и логируются. Общие 15 случаев прошли в Android и Qt; - проверки нажатия/drag/copy и приглашений прошли. Чистые сборки Qt и APK — PASS. - `check.sh`: 124 passed, 0 failed, 1 skipped; proxy/burst/load — PASS. - APK установлен на Q8, проверено системное открытие HTTP и HTTPS через ACTION_VIEW, - приложение запущено. Логи: `/tmp/message-links-check.log`, - `/tmp/message-links-qt-final-tests.log`, `/tmp/message-links-android-tests.log`. - Контракт: `doc/message_links.md`. - -[+] **Заголовок PM-приглашения и короткое отображение ссылки** — 04.10.2026: - `ct=invite` содержит зашифрованные название группы и полную ссылку; GUI получает - название через `tags.group_name` и добавляет заголовок из перевода интерфейса. - Android и Qt отображают ссылку одной строкой с многоточием. Нажатие и копирование - используют полный адрес: долгое нажатие в Android, выделение строки и Ctrl+C в Qt. - Срок действия ключа — 10 минут. Проверена доставка названия с Unicode, кавычками - и переносом строки, JOIN и история; Qt проверяет разные ширины, нажатие на конец - сокращённой ссылки и копирование. Чистые сборки ядра, Qt и APK прошли. - `check.sh`: 124 passed, 0 failed, 1 skipped; proxy/burst/load — PASS. - Qt `test_invite_links` и `test_emoji_playback` — PASS. APK установлен на Q8, - приложение запущено. Логи: `/tmp/pm-invite-title-check.log`, - `/tmp/pm-invite-title-e2e-final.log`. Контракт: `doc/pm_invites.md`. - -[+] **История группы после PM-приглашения** — 04.10.2026: регистрация `db_sync` - создаёт локальное состояние с блокировкой обмена; история запускается после - успешного member_sync через READY-пиров конкретной группы. Подписка на переходы - READY сбрасывает обмен при DOWN/REINIT/LEAVE и возобновляет его при восстановлении. - Обе роли имеют 15-секундный таймаут; ошибки отправки и отказ пира разрешают повтор. - JOIN_INFO_RESP применяет участника через транзакционный member_sync, обновляя - Merkle-индекс вместе с моделью. ASAN также выявил и помог исправить использование - временного content_type после выхода из области видимости при выдаче JSON истории. - Проверены пять сообщений до приглашения, оба порядка ID, потеря первого обмена, - восстановление READY на общем транспорте и последующая доставка новых сообщений. - Эти сценарии, ownership и services прошли под ASAN. Финальный check.sh: - 124 passed, 0 failed, 1 skipped; интеграционные proxy/burst/load — PASS. - Чистые сборки ядра, Qt и APK прошли. На Q8 после обновления - существующий `g2` получил все 5 старых сообщений без повторного JOIN; - SYNC_DONE подтвердил совпадение хеша, SQLite quick_check — ok. - Диагностика: `/tmp/pm-history-q8-final.log`, `/tmp/pm-history-asan-invite.log`, - `/tmp/pm-history-asan-timeout.log`, `/tmp/pm-history-final-check.log`. - Контракт: `src/chat/db_sync_arch.md`. - -[+] **Android: диагностика несрабатывания VAD** — 03.10.2026: в `debug` добавлены - решения по каждому изменению состояния и сводки PCM/AGC/модели раз в секунду; - `debug=trace` показывает каждое окно 32мс. Логи различают сброс подтверждения, - чужой RX, закрытое окно Android, задержки inference и отложенный BEGIN. - Регрессии radio_vad, Silero, radio_audio и voice_tx прошли. На Samsung в 19:36:23 - подтверждён отказ после истечения 15-минутного окна: `reason=window_expired`, - `requested=true screen=true capture=false nativeVad=false`; ручной PTT работает. - Кнопка на панели показывает сохранённое включение, а не фактическую активность VAD. - Лог: `/tmp/vad-sm-current.log`. Исправление таймера и индикации — следующая запись. - -[+] **Android: индикация и фоновый таймер VAD** — 03.10.2026: кнопка показывает - фактическую активность захвата, гаснет при его остановке; повторное включение - открывает новое окно. 15-минутный таймер расходуется только в фоне, при видимом - приложении сбрасывается: после каждого сворачивания доступны полные 15 минут. - После истечения возврат в приложение возобновляет VAD; погасший экран по-прежнему - останавливает микрофон, включение экрана открывает новое окно. - `tests/test_radio_vad_window.kt` проверяет 2 часа в foreground, сброс таймера при - возврате, полное фоновое окно, границу истечения, экран и сохранение окна при перенастройке. - На Samsung проверены фактические lifecycle-переходы и выключенная кнопка после - остановки захвата: `/tmp/vad-background-sm-transitions.log`. Чистая сборка APK прошла, - Samsung SM-A525F и Q8 обновлены; на Samsung восстановлены прежний канал и активный VAD. - Уточнение: при возвращении в приложение таймер теперь полностью сбрасывается, - соответствующая регрессия прошла. Новая чистая сборка — `/tmp/vad-reset-android-build.log`, - установлена на оба телефона. Повторная проверка lifecycle на Samsung после этого - обновления не выполнена: телефон заблокирован; правило сброса проверено тестом с искусственным временем. - [ ] **Android: время жизни config provider** — 03.10.2026: `utun_bridge_init()` передаёт адрес локального `utun_config_provider_t` в `utun_config_set_provider()`, который хранит указатель. После возврата из init он недействителен; перед использованием `cfg_get_*` обеспечить постоянное хранение provider. -[+] **DM: надёжная доставка сообщений, первый этап pm.txt** — 01.10.2026: - атомарные история/seq/outbox, подписанные точные квитанции после commit, - live BGP-маршрут и durable очередь на суперузле с проверкой подписанной роли. - Повторы, out-of-order, rollback БД, неверные ACK и поздний PUT проверены; - 40 offline-сообщений доставлены после restart источника, первый offline DM — - после restart суперузла при выключенном источнике. Копии удалены по квитанции. - Подготовка ciphertext медиа использует ключ target и независимые nonce порций; - проверены границы 32 КиБ, пустые файлы, неверные ключи/UUID/hash и повреждения. - Clean-сборка ядра/Qt, test_dm, test_dm_e2e и полный check прошли: - 113 PASS, 0 FAIL, 1 SKIP (auto_socket_dynamic требует root); proxy/burst/load PASS. - Текущие гарантии и оставшаяся интеграция: `doc/dm_arch.md`. - -[+] **DM: сетевая доставка и pending-медиа по pm.txt** — 01.10.2026: - отдельные owners dm_media/dm_mailbox_media и файловый transfer с CM handle, - подписанные метаданные и независимая квитанция файла после durable приёма. - Прямая доставка или S→M custody, restart S/M при выключенном A, доставка B, - повтор удаления до DELETE_ACK; поздние STORE/STORED не возрождают копии. - TTL M по умолчанию 7 дней, резервирование квоты, отмена transfer и stop workers. - Копирование, шифрование, хеширование, проверка и durable файловые операции — - в media_async; отправка по 8 КиБ через call_soon. Тест 8 МиБ/0,5 с сохраняет - heartbeat uasync (максимальный интервал 10,3 мс в проверенном прогоне). - Qt/Android: оригинальные файловые вложения, сохранение и статусы доставки; - PM-снимки из ядра асинхронные. Чистые сборки Qt и Android APK прошли. - test_dm_media/test_dm_worker, rollback/quota/cancel и полный check: - 115 PASS, 0 FAIL, 1 SKIP; proxy/burst/load PASS. GUI на устройствах не проверялся. - Протокол, владение и проверки: `doc/dm_arch.md`. - -[+] **DM: голосовые/видео и общий GUI вложений** — 01.10.2026: - тип FILE/VOICE/VIDEO и метаданные зашифрованы ключом target; в transport остаётся - подписанный descriptor, без новых ключей и канального протокола в DM. - Общие attachment/voice_file/attachment_send: Opus, waveform, копирование и видео - готовятся в workers; completion через reserved post вместо polling. PCM передаётся - без копии; адресат закреплён до записи/выбора. Отмена освобождает собственные файлы. - Android: ConversationComposer/ConversationMessages для каналов и PM, CameraX MP4, - IO для URI/метаданных; Qt: общий InputBar/delegate, FFmpeg, фоновые превью/Opus. - Подготовка адресована беседе и UUID задачи; статусы доставки показаны для всех типов. - test_attachment/test_attachment_send проверяют кодек, формат, владение и отмену; - test_dm_media — voice напрямую и encrypted video metadata после offline restart. - Чистые сборки ядра/Qt/APK, 117 PASS / 0 FAIL / 1 SKIP в check.sh; proxy/burst/load PASS. - Qt CTest: 7 PASS, включая повреждения Opus и отмену decoder. APK не устанавливался, - интерактивная проверка устройств не выполнялась. Подробности: `doc/dm_arch.md`. - [ ] **uasync: ложные ERROR об утечке перед cleanup** — 01.10.2026: test_dm_e2e и существующий test_conn_mgr проходят, но uasync_destroy() пишет Timer leak до освобождения отменённых heap/immediate таймеров. @@ -356,31 +89,6 @@ предварительные счётчики в DEBUG; ERROR оставить для реального расхождения после cleanup, отдельно показывать активные и отменённые таймеры. -[+] **Статус подключения звонка в Qt и Android** — фактический путь router: - прямое / reverse / indirect / нет подключения. Учитывается BGP fallback; - события только при изменении, общий таймер 1с, сброс при завершении. - Проверены смена путей настоящего звонка, медиа, ASAN, Qt, Android APK и полный check. - -[+] **Единицы RTT в окне звонка** — `call_post_stats()` переводит - `ETCP_CONN.rtt_last` из 0.1мс в миллисекунды до отправки CALL_STATS. - Исправление общее для Qt и Android. DEBUG-категория `debug` раз в секунду - показывает исходный RTT, RTT в мс, транспортного соседа и метрики аудио. - -[+] **Восстановление путей conn_mgr на протяжении жизни handle** — начальный - TIMEOUT отделён от срока цикла проб. Поздний обрыв запускает новый цикл; - рабочий резерв и NCD сохраняются, при отсутствии резерва INDIRECT стартует сразу. - Циклы ограничены 15с, паузы 1→2→4→8→15с; новая версия узла или локальные сокеты - обходят паузу. Прямой READY останавливает поиск и получает приоритет. - Исходящие request_id и входящие сроки независимы; устаревшие ответы игнорируются. - UP/DOWN отражают общую доступность, TIMEOUT приходит один раз до первого UP. - Последний close отменяет восстановление, включая закрытие из callback. - Проверены десять сетевых сценариев с реальными данными: поздний обрыв, - повторные циклы, смена адресов/сокетов, потеря маршрута посредника, старый ACK, - начальный TIMEOUT с поздним UP и close. ASAN без ошибок; собраны Qt и Android APK. - Полный check: 113 PASS, 0 FAIL, 1 SKIP; пропущенный auto_socket_dynamic отдельно - прошёл с root в network namespace. Интеграционные proxy, burst и load прошли. - Алгоритм: `src/routing_layer/conn_mgr_doc.md`. - [ ] **Общий лимит check.sh после clean** — 01.10.2026: расчёт по времени предыдущего прогона дал 205с вместе с полной сборкой и оборвал проверку после успешных тестов до `test_uasync_socket_race`. Повторный запуск на готовой сборке с большим лимитом @@ -406,26 +114,6 @@ capture/playback, фактической частоты callback и PCM кадров за интервал. Счётчики сбрасываются до старта устройства; нужна повторная запись с новой сборкой. -[+] **Ложный сигнал потери звука при запуске звонка** — 01.10.2026, звонок - `bc3efadcac629947`: Qt запускает аудиоколлбэки в 01:54:56.933, включает gap tone в - 01:54:57.415 и выключает в 01:54:58.072 при первом воспроизведении PCM. - На SM между ACCEPT и первым захваченным кадром проходит около 1.15с; - часы SM отстают примерно на 1.23с. Qt стабильно обслуживает 100 callback/с, - RTT около 1мс. Исправлено: до первого PCM начальное ожидание проходит в тишине, - последующий перебой включает тон через 500мс. Стартовый сбой по-прежнему - ограничен существующим watchdog отсутствия медиа. Регрессия проверяет ожидание - 1.5с, начальное накопление, настоящий перебой, неполный блок, новый звонок и завершение. - Ускорение запуска Android: router.start занимает около 775мс; система сообщает - длительное удержание AudioManager.updateAudioPortCache. Ещё около 370мс проходит - до запуска capture worker. Гудки теперь останавливаются до startAudio; - router использует снимок устройств с обновлением по событиям и явной переоценке, - capture запускается до подготовки/старта playback. Ошибка playback останавливает capture. - Добавлены длительности отдельных стадий, первого capture и PCM. Ядро, Qt и APK - собраны после clean; три аудиотеста прошли. Выигрыш на SM требует замера в новом звонке. - В звонке Qt `180f046ef22defc2` первый PCM пришёл через 1245мс без стартового gap tone. - Полный check вне песочницы: 113 PASS, 0 FAIL, 1 SKIP; auto_socket_dynamic отдельно - прошёл с root в private netns. Интеграционные proxy, burst и load прошли. - [ ] **Оставшаяся задержка запуска аудио SM** — после обновления 01.10.2026, звонок `2b9ad4e21a54d8ba`: стартового gap tone нет на обеих сторонах; Qt стабильно работает около 100 callback/с, max_idle=15.3мс, буфер 55мс, RTT=2мс. @@ -456,96 +144,6 @@ `stress_sessions` иногда печатают огромные положительные/отрицательные миллисекунды, хотя итоговая скорость и длительность передачи выглядят нормально. Проверить расчёт вывода. -[+] **SpeexDSP AEC (эхоподавление) в lib/** — акустическое эхоподавление звонка. - Вендорен SpeexDSP (mdf/fftwrap/kiss_fft, 3-clause BSD) в `lib/speexdsp/`, - обёртка `lib/speex_aec.c/h` (C-API, FLOATING_POINT + USE_KISS_FFT, без внешних - зависимостей). Собственная линия задержки рендера на `delay_frames` кадров + - синхронный `speex_echo_cancellation` (встроенный буфер SpeexDSP фиксирован в - 2 кадра и для Android не годится); дрейф потоков — через переполнение/недозаполнение - линии (drop/dup + passthrough). Категория лога `aec`. - Ядро: `call_audio_set_aec_enabled/delay_frames`, AEC в feed_pcm (захват→encode) - и pull_pcm (рендер-референс), lifecycle в start/release. Desktop — настройка GUI - `aec_enabled` (`[chatserver]`, читается в `call_audio_start`). Android — включается - только на спикерфоне (`route==SPEAKER`, live через JNI `callAudioSetAec`), - задержка меряется `AudioTrack/AudioRecord.getTimestamp`; `CallAudioEngine.playLoop` - переведён на absolute-deadline (устранён систематический дрейф). Тест - `test_speex_aec` (подавление эха 25.5 дБ, сохранность речи 0.956, delay-line, NULL-гарды). - Сборки: autotools + chatgui CMake + Android CMake/APK. - -[+] **Silero VAD в lib/** — добавлен детектор голосовой активности (Silero VAD v5, - стриминговая ONNX-модель + обёртка `lib/silero_vad.c/h` над ONNX Runtime C API). - API: `silero_vad_create/create_default/destroy/reset/process/process_pcm16` - (окно 512 сэмплов @16 кГц, вероятность речи [0..1], рекуррентное состояние). - Модель вшита в бинарник (`silero_vad_model.inc`, `create_default`), файл не нужен. - Категория лога `vad`. Модуль под `#ifdef HAVE_SILERO_VAD` — без onnxruntime - компилируется в стабы, поэтому GLOB-сборки (chatgui/Android) не ломаются. - Сборка: `--with-silero-vad` (autotools), `-DHAVE_SILERO_VAD` + libonnxruntime - (chatgui CMake, Android NDK). `make check`: 111/0/1. - -[+] **Авто-PTT рации по VAD** — сделано. Отдельный режим `radio_auto_enabled` (ручной - PTT не меняется, при зажатой кнопке — override). Сервисный слой `radio_audio.c`: - ресемплер 48k→16k моно (3-tap box + 3:1, стерео микшируется), накопитель окон - 512, фильтр-гистерезис + машина состояний (`src/radio/radio_auto.h`, чистый, - юнит-тестируемый): старт при подтверждении 2 окон (~64мс) выше порога - `radio_vad_threshold`, стоп при тишине `radio_auto_hangover_ms` (200мс) или чужом - разговоре (busy = RX-кадр <200мс). Перед BEGIN сохраняется до 600мс непереданного PCM; - prefetch отправляется одной задачей перед live без повторов и задержки на стороне отправителя. - VAD использует отдельный компрессор с собственной историей усиления. Desktop `radio_audio_engine.cpp` и Android - `RadioAudioEngine.kt` фидят PCM постоянно в VAD-режиме; JNI `radioVadMode()`. - Тест `test_radio_vad` (ресемплер + FSM). Android: onnxruntime AAR 1.23.2 в - `jniLibs/` + `cpp/onnxruntime/include` + `-DHAVE_SILERO_VAD` в CMake. - Android: настройки VAD (включение, порог, задержка после тишины); микрофон в PTT - создаётся только для передачи. PTT запускается при касании, после отпускания - передаёт ещё 250мс; повторное касание отменяет завершение (двойной тап без FIN). - VAD работает 15 минут при горящем экране; - SCREEN_OFF освобождает захват, SCREEN_ON автоматически открывает новое окно. - RX остаётся активен; TX освобождается отдельно через `radio_audio_capture_stop()`. - -[+] **Отдельный компрессор перед VAD рации** — 03.10.2026: - копия PCM 48кГц/20мс проходит через общий audio_compressor до ресемплера и Silero. - Настройки общие с TX, кроме `radio_vad_compressor_max_gain_db` (15дБ по умолчанию, - 1..60дБ); включение также общее. Состояния TX/VAD независимы, prefetch хранит исходный PCM. - Выключенный компрессор не добавляет ожидания блока; разрыв/новый захват сбрасывает AGC и Silero. - Android: отдельный ползунок, DataStore/INI/runtime; диагностика `radio` показывает - параметры, input/output RMS, фактический gain и клиппинг. Диапазоны max_gain согласованы с библиотекой. - test_voice_tx: точный вход Silero, байтовое совпадение Opus/prefetch, mono/stereo, - 6/30дБ, переменные PCM-чанки и reset. Настоящий Silero распознаёт речь при амплитуде - 0.03 на тихом фоне, останавливается после речи; повторный reset воспроизводит результат. - Чистые сборки ядра/Qt/APK, целевые тесты и полный check.sh прошли: - 124 PASS / 0 FAIL / 1 SKIP, proxy/burst/load PASS; Qt CTest 2/2, AGC 479/479. - APK с последующим переносом переключателя VAD установлен на Samsung SM_A525F. - -[+] **Включение VAD на панели рации Android** — 03.10.2026: - кнопка VAD расположена слева от правой кнопки плавающего PTT; состояние читается из DataStore Flow. - Действие в ViewModel сохраняет настройку, обновляет ядро, запрашивает разрешения и перенастраивает захват. - Переключатель удалён из настроек; порог речи, задержка после тишины и max gain оставлены. - Чистые сборки ядра/Qt/APK прошли, APK установлен на Samsung; UI настроек проверен через uiautomator. - -[+] **Верхняя панель рации и VAD в desktop ChatGUI** — 03.10.2026: - строка под шапкой канала: меню микрофона/выхода, состояние, переключатель VAD слева от PTT. - PTT перенесена с сообщений в панель; подсветка показывает фактический TX, включая автоматический VAD. - Настройки рации: вероятность речи, задержка тишины, включение AGC и независимые max gain TX/VAD. - Общий стек сохраняет prefetch 600мс; настройки применяются в uasync с подтверждением в GUI. - Перезапускается только capture, RX/output сохраняются; удерживаемые кнопка/хоткей восстанавливаются. - INI сохраняется атомарно; ошибка микрофона/детектора видна на панели, ручной PTT доступен без VAD. - Запрос настроек принадлежит reserved uasync task и освобождается также при shutdown до исполнения. - Исправлено сохранение Audio без посещения страницы: устройства и аудиопараметры предварительно загружаются. - Чистые сборки ядра/Qt и Qt CTest 16/16; test_radio_panel проверяет Qt-кнопки и настоящие audio workers - с null backend: auto-TX, 30 кадров prefetch, manual/hotkey, capture recovery, INI restart и отмену запроса. - Полный check.sh: 124 PASS / 0 FAIL / 1 SKIP, proxy/burst/load PASS; настоящий Silero распознаёт тихую речь. - Логи: `/tmp/desktop-vad-qt-verified-tests.log`, `/tmp/desktop-vad-core-check-unrestricted.log`. - -[+] **PTT кнопками громкости на панели рации Android** — 03.10.2026: - переключатель слева от VAD, включённое состояние подсвечено; панель и настройки используют общий DataStore Flow. - Значок: динамик по центру, плюс сверху, минус снизу; проверен на панели Samsung после чистых сборок ядра/Qt/APK. - Сохранение обновляет быстрый кэш после записи; ошибки записи логируются без изменения состояния переключателя. - Экранная кнопка, оверлей и кнопки громкости имеют независимые владельцы ручной передачи. - Учёт отдельных volume-клавиш защищает от повторов и посторонних клавиш; потеря фокуса/выключение опции отменяет volume-PTT. - Чистые сборки ядра/Qt/APK прошли, 12 сценариев владельцев PTT проверены отдельным Kotlin-прогоном. - APK установлен на Samsung SM_A525F: обе volume-кнопки запускают запись, отпускание завершает её с хвостом 250мс. - В отдельном канале без других участников проверены переключатель и сохранение выключенного состояния после restart. - Логи: `/tmp/android-volume-ptt-final-device.log`, `/tmp/android-volume-ptt-apk-final-build.log`. - [ ] **Linux ONNX Runtime: повторная регистрация схем при запуске теста** — 03.10.2026: test_silero_vad при загрузке модели печатает серии `Schema error: ... already registered`. Инференс, речь, тишина и reset проходят; отдельно проверить состав и загрузку Linux-библиотек ONNX. @@ -556,35 +154,10 @@ огромные или отрицательные миллисекунды (например, -1611883348002232012ms). Отдельно проверить формат/аргументы вывода длительности; `/tmp/vad-compressor-check.log`. -[+] **chatgui (Qt) не собирался: C++ void* в utun_node.cpp** — сделано. Причина: - `proxy_protocol.h` (C-заголовок, включается в C++ `utun_node.cpp`) делал - `e->dgram = u_malloc(...)` и `struct proxy_flow* f = arg;` без явных приведений — - в C++ нет неявного `void*`→T*. Добавлены `(uint8_t*)`/`(struct proxy_flow*)` - (валидны и в C). `vibechat` собирается (47МБ, с вшитой моделью + onnxruntime). - -[+] **test_auto_socket_dynamic: root-регрессия и нагрузка** — изолированный namespace, - корректные маршруты, двусторонний поток через backpressure, строгие seq/payload, - пределы TX/RX, остановка при обрыве и drain после восстановления, баланс ресурсов. - Исправлены привязка TCP к интерфейсу, удаление TCP моделей/линков/портов из БД, - sender receive window при selective ACK, timestamp overflow и нулевой jitter. - ASan/UBSan/LSan: PASS; дополнительно исправлены невыровненные метки аллокаторов - и PID-порты в test_etcp_connect. [Разбор и логи](etcp_full_suite_findings.md). - [ ] **debug_parse_config: формат и индексы категорий** — parser ожидает `:`, документация описывает `=`; преобразование битовой маски осталось после перехода enum на индексы. [Разбор](etcp_full_suite_findings.md). -[+] **Q8/Android не подключается к локальному чатгуи (LAN)** — сделано. Причина: тип сокета - в Android-конфиге был захардкожен `type=public`, из-за чего `sock_match` ставил - `has_priv=0` и не создавал линки к приватным (LAN) адресам пиров (`create_links … → 0 links`). - Исправлено: (1) `ip_is_public()` вынесен в `lib/platform_compat` (единый классификатор - IPv4 с корректным ntohl); (2) в `auto_socket_reconcile_config` (Android-режим) тип сокета - выставляется по адресу (public→PUBLIC, приватный/link-local→NAT), конфиг `type` игнорируется; - (3) `sock_collect_views` больше не смотрит на `type` из конфига: `has_priv` — по классификации - локального адреса, `has_inet=1` (исходящие считаем доступными), `is_eim` — по NAT-детекции - (hairpin). `is_local_subnet` упрощён до `!ip_is_public()`. Тест `test_sock_match` обновлён - (+ `ip_is_public` кейсы). `check.sh`: 86 passed / 1 failed / 1 skipped. - [ ] **DM: прямой p2p чат с любым пользователем группы** — отдельная DM-подсистема. ТЗ и архитектура: `/doc/dm_arch.md`. Статус: реализовано (dm_core/dm_mailbox/dm_crypto), тесты `test_dm` (крипто) и `test_dm_e2e` (интеграция). Остались задачи ниже. @@ -596,123 +169,11 @@ Android: переключение источника (трубка/громкая/гарнитура). Архитектура: `/doc/chat_call_arch.md`. - [+] **Сигналинг + релей медиа** — сделано. `call_proto.h` (svc `ETCP_RT_ID_CALL - 0x36`, subcmd INVITE/RINGING/ACCEPT/DECLINE/BUSY/CANCEL/HANGUP/MEDIA), - `call.c` (машина состояний, реестр сессий, conn_mgr, ring-timeout 45с, - watchdog no-traffic 20с, потеря соединения → end, события - CHAT_EVT_CALL_* 34–40), `DEBUG_CATEGORY_CALL 32`. `test_call` (2 инстанса, - happy path + decline + media-релей). Приём медиа — через `call_set_media_recv_cb`. - [+] **Headless API** — сделано. Команды control-сокета `call_start/accept/decline/ - hangup` + события `call_incoming/ringing/accepted/ended/declined` (JSON); - аудио-сокет `call/call_headless.c` (TCP, бинарный фрейм - `[type:1][call_id:8][len:2][data]`, HELLO/FRAME), конфиг `call_audio_bind`. - Разрыв conn_mgr — отложенно 500мс (`CALL_TEARDOWN_DELAY_TB`) после ENDED, - чтобы HANGUP успел долететь; защита от stale-handle через conn-status. - Тесты: `test_call` (сигналинг+медиа), `test_call_headless` (полный e2e через - control+audio сокеты). - [+] **Аудио-контур (time-stretch в сервисном слое)** — сделано. `src/call/audio_jitter.cpp` - (SoundTouch, C API `ajb_*`: адаптивный джиттер-буфер ≤1000мс, target 60мс, - tempo 1.0→1.6x — догон live без потери данных при сетевой паузе), - `src/call/call_tones.c` (глитч при сталле >500мс + тон завершения), - `src/call/call_audio.c` (Opus 48kHz/20мс + jitter + тоны + pull API финального PCM). - `call.c`: `call_audio_ops` (on_media c seq/ts, get_stats) + событие - `CHAT_EVT_CALL_STATS` раз в 1с [rtt_ms, buffer_ms, tempo_x100]. - Desktop `call_audio_engine.cpp` — только miniaudio I/O (получает финальный PCM). - Тест `test_audio_jitter` (ёмкость/догон/стационар). Демон остаётся C-only. - [+] **Android** — сделано. JNI (`utun_bridge_call_*`/`nativeCall*`), AudioRecord/Track - (CallAudioEngine), роутинг источника (CallAudioRouter: EARPIECE/SPEAKER/HEADSET, - BT SCO), CallScreen + IncomingCallDialog + RingtonePlayer + IncomingCallManager, - `libutun_lite/call_audio.c` (собственный аудио-контур Opus 48kHz/20мс). - [+] **Единый голосовой стек (voice stack) + SoundTouch на Android** — сделано. - Унификация аудио-контура звонка: `libutun_voice` (статическая C++-либа с - C-интерфейсом `call_audio.h` = SoundTouch + `voice_jitter.cpp` + `call_audio.c` - + `call_tones.c`), собирается через `src/call/voice_sources.cmake` и линкуется - desktop/Android/headless-CLI. Джиттер-буфер держит КОДИРОВАННЫЕ (Opus) кадры, - декодирует только на pull (непосредственно перед воспроизведением) — память ~48x - меньше и uasync-поток разгружен. Android переведён с push-FIFO (Kotlin ArrayDeque) - на pull-модель (`nativeCallAudioPull`), своего джиттера больше нет → убрана - асимметрия задержки (desktop↔Android). Headless-аудио-сокет отдаёт готовый PCM - (декод Opus→PCM, без тактирования; клиент сам задаёт темп). Диагностика: - `CALL_STATS` расширен [dropped, underruns] (раз в 1с), счётчики headless. - Удалены `audio_jitter.cpp` и `libutun_lite/call_audio.c`. Тест `test_voice_jitter` - (ёмкость/догон/стационар). Android — `ANDROID_STL=c++_static`. [ ] **DM-звонки** (fallback `group_id==0`) — не сделано: `call_is_online`/`call_start` требуют реальную группу (`topo_groups_find`), для DM (group_id==0) маршрута нет. -## Звонок: две фазы исходящего вызова (индикация + гудки) - -[+] **Две фазы вызова** — сделано. Исходящий звонок разбит на 2 фазы с индикацией - в окне вызова и разными гудками: - - фаза 1 «Пытаемся связаться…» (`OUTGOING` — INVITE/маршрут) — гудок «соединение»; - - фаза 2 «Звоним…» (`RINGING` — у пира идёт вызов) — длинные гудки (ringback). - Desktop: `CallWindow::Ringing` + `SoundManager` loop `call_connecting`/`call_ringing` - (MP3 в qrc), переключение по `onCallRinging`, остановка по accepted/ended/error; - добавлена обработка `GUI_EVT_CALL_ERROR` (40) — раньше при «пир офлайн» окно/гудок - зависали навсегда. Android: `CallRingback` (MediaPlayer loop из res/raw), - подписи `OUTGOING`/`RINGING` в CallScreen + ActiveCallBanner. - Звуковые события добавлены в SoundSettingsPage (call_connecting/call_ringing). - -[+] **Надёжность отмены вызова (кодограмма + watchdog)** — сделано. Отмена вызова - (HANGUP/CANCEL) шлётся один раз, без ретрансмита, и при флапе маршрута (RST/epoch, - прямой TCP к мобиле не поднимается — только релей) терялась: вызываемый висел в - RINGING вечно и отвечал BUSY на все новые INVITE. Исправлено моделью «сердцебиения»: - - вызывающий шлёт кодограмму INVITE каждые 300мс весь период вызова (OUTGOING+RINGING); - - у вызываемого watchdog 2с — нет кодограммы → вызов сорван (`CALL_REASON_CANCEL`); - - завершающие кодограммы без ретрансмиссий (потерю покрывает watchdog); - - запоздалые кодограммы вызова при разговоре (CONNECTING/ACTIVE) игнорируются. - `CALL_INVITE_RETRY_TB=3000`, `CALL_RING_DEAD_TB=20000`, `CALL_NO_ROUTE_MAX_STRIKES=20`. - ## Рефакторинг: per-instance chat/dm (однопоточный test_dm_e2e) -Сделано (глобальные синглтоны переведены на `UTUN_INSTANCE`): -- `chat_setting` → `inst->chat_settings` (`struct chat_setting_state`, state-level API для парсера конфига + instance-level для runtime). -- `chat_event` → `inst->chat_event_handler` (обработчик теперь получает `inst`). -- `chat_core` (`g_cc`) → `inst->chat_core` (`struct chat_core_ctx*`). -- `chat_sync` (`g_cs`) → `inst->chat_sync`. -- `member_sync` callback-списки (`g_props_cbks`/`g_apply_cbks`) → в `chat_core_ctx`. -- `chat_join` (`g_keys`) → в `chat_core_ctx`. -- `chat_msg` media-счётчики/backfill → в `chat_core_ctx`. -- `dm_core` (`g_dm`) → `inst->dm`; `dm_mailbox` (`g_mb`) → `inst->dm_mailbox`. -- `chat_headless_control` (`g_hc`) → `inst->headless`. -- `chat_whisper` — сигнатуры `init/available/trigger` принимают `inst`. -- API протащено через `inst` (chat_core_*/dm_*), обновлены call-сайты в src/ (headless, topo, media_delivery, auto_socket, config_parser). -- `test_dm_e2e.c` переписан на однопоточный: один `uasync`, три `utun_instance` (A,B,C), master state-machine, без fork. Фаза 2 (B offline) — `utun_instance_destroy(B1)` + пересоздание B2. -- Попутно исправлен 1-байтовый overflow в `dm_mailbox.c: mb_route_send` (`u_malloc(1+body_len)` → `1+1+body_len`). - -Статус сборки: `make -C src` (libutun.a + бинарник utun) и `make -C tests` — OK. - -### Осталось (по порядку) - -[+] **test_dm_e2e: зависание в utun_instance_destroy(B1)** (фаза P_B_OFFLINE) — сделано. - Причин было несколько (не одна): - 1. `lib/u_async.c`: если ближайший таймер уже истёк на входе в `uasync_poll`, - `get_next_timeout` возвращал {0,0}, `timeout_ms=-1` → `epoll_wait(-1)` блокировался - навсегда, а `process_timeouts` (вызывается только после epoll_wait) не запускался. - Фикс: `if (timeout_ms == -1 && heap не пуст) timeout_ms = 0`. - 2. `routing_layer/etcp_router.c`: `router_send_conn` с group_id=0 (глобальная - маршрутизация DM/mailbox) не находил маршрут (`topo_groups_find(0)==NULL`). - Фикс: fallback на `instance_find_conn(inst, remote)` при group==NULL. - 3. `dm/dm_core.c`: `dm_on_conn_status` PULL-ил только беседы с подключившимся пиром; - B2 подключается к storage (C), а беседа — с A. Фикс: на подъём любого соединения - PULL для всех бесед, чей пир не подключён напрямую. - 4. `dm/dm_mailbox.c`: не было ACK после PULL_RESP → storage не чистил dm_mail. - Фикс: PULL_RESP теперь несёт sender, получатель шлёт MB_SUBCMD_ACK. - 5. `utun_instance.c`: `dm_core_init` ставил deliver-cb до `dm_mailbox_init` → - mailbox не находился, deliver-cb не регистрировался. Фикс: mailbox init раньше core. - `test_dm_e2e` проходит (несколько прогонов), `test_dm` тоже. - -[+] **chatgui-android: переезд на per-instance API** — сделано. - `instance_lite.c/h` (+`instance_lite_get_instance()`), `jni_bridge.c`, `standby.c`, - `headless_control.c`, `voice_recorder.c`, `attachment_sender.c`, `video_sender.c`, - `photo_sender.c` — все call-сайты переведены на `inst`. `libutun_lite` компилируется, - `jni_bridge.c` — синтакс-чисто. (headless-линковка сломана ПРЕДЫДУЩЕ — instance_lite.c - ссылается на jni_bridge-функции, не включённые в headless-сборку; отдельная задача.) - -[+] **test_chat_join: segfault** (регресс рефакторинга) — сделано. - `chat_join` хранил `join_keys` в `chat_core_ctx` (CC(inst)), а тест использует chat_join - без chat_core → NULL-deref. Фикс: `join_keys` вынесен в `UTUN_INSTANCE.join_keys` - (у chat_join собственный init/destroy). - [ ] **BGP-гонка: дропнутый REQUEST_TABLE** (отдельная задача, как договорились). **Место:** `topo_group_new_conn()` в `src/routing_layer/topo_group.c:571`. Дедуп-ветка @@ -759,44 +220,12 @@ (не пытаться соединяться с узлами, для которых нет `[client]`/ncd-конфигурации, либо обрабатывать неудачу без спама). -[+] **Обновить GUI call-сайты `tools/chatgui` (Qt, desktop)** — сделано. - - В `chat_core.h` вынесены trampoline-структуры из `.c`: `update_my_name_arg`, - `save_ui_state_arg`, `chat_setting_arg` (были в chat_profile.c/chat_core.c). - - `gui_bridge.h/impl`: `gui_bridge_set_inst()`/`gui_bridge_get_inst()` (+`g_inst`). - - `utun_node.cpp`: `gui_bridge_set_inst(m_instance)`, `chat_event_set_handler(m_instance,…)`, - `chat_core_sync_my_addresses(m_instance)`. - - Обновлены ~45 call-сайтов в 16 файлах (`mainwindow`, `messagelist`, `messagedelegate`, - `memberlistmodel`, `memberpropsdialog`, `accountlist`, `invitedialog`, `inviteby`, - `joindialog`, `settingsdialog`, `channelsettingsdialog`, `soundsettingspage`, - `audiodevicesettingspage`, `storagesettingspage`, `statuspage`, `connmonitorwindow`). - - Сборка `cmake --build . --target vibechat` — OK. - Попутно: `chat_sync_connect_from_invite` писал `cs->pending_*` на вызывающем (GUI) потоке — - перенёс в `cm_invite_trampoline` (uasync-поток), убрав гонку; заодно исправил утечку - `inv->addrs_data` и добавил null-check `cs` в трамплине. - -[+] **Прогон всех тестов** — сделано. `make clean && make -j4` OK; все тесты проходят - (кроме `test_auto_socket_dynamic` — пропуск «requires root»). `test_dm_e2e` стабилен - в нескольких прогонах. В `test_dm_e2e` при teardown остаётся timer-leak (~68 узлов, - `router_no_route`), не влияет на результат — стоит разобрать отдельно. - [ ] **lwip/proxy: утечка pcb в FIN_WAIT_2** — `tcp_shutdown(pcb,0,1)` (half-close по FIN от exit) не ставит `TF_RXCLOSED`, поэтому таймаут FIN_WAIT_2 в `tcp_slowtmr` не срабатывает: если браузер никогда не пришлёт FIN, pcb навсегда остаётся в FIN_WAIT_2 (утечка, не crash). Двойной free по TIME_WAIT уже закрыт (detach по локальному закрытию + guard abort). ## Открытые флаки -[+] **test_chat_join_e2e** — сделано. Причина: ложный критерий успеха джойнера J. - `wait_group_started()` проверял `senders_list` непуст в предположении «senders_list - непуст ⟺ J получил JOIN_READY». После коммита b483335c `tgi_to_channel_cb` - (topo_group_invite.c) при `NCD_EVENT_UP` стал вызывать `topo_group_new_conn` → - conn попадает в `senders_list` сразу после установки TCP/UDP-соединения, ДО - завершения хендшейка (JOIN_INFO_REQ→RESP→REQUEST→READY). J выходил «OK» раньше, - чем A получал JOIN_REQUEST → A не подписывал J → `wait_signed` на A/C падал - (`get_sign_rc=-1`). Фикс: критерий J — событие `CHAT_EVT_CONNECT_RESULT(result=0)` - (JOIN_READY), ловится через `chat_event_set_handler`; `wait_group_started` удалён. - Это также убрало гонку «C вышел раньше, чем J синкает свой рекорд» (C выходит сразу - после `wait_signed`, не дожидаясь merkle-синка J). 5 прогонов стабильно 3/3. - [ ] **test_etcp_reconnect** — флаки под параллельной нагрузкой `make check -j4`: phase 4 (reconnect после server restart) таймаутит (`sent=480 recv=0`), при одиночном запуске стабильно `TEST PASSED`. Наш код (chat_join/chat_sync) его не трогает. diff --git a/lib/u_async.c b/lib/u_async.c index f713e4fe..fa483fba 100644 --- a/lib/u_async.c +++ b/lib/u_async.c @@ -1012,11 +1012,123 @@ static void process_epoll_events(struct UASYNC* ua, struct epoll_event* events, } #endif -// Instance version -void uasync_poll(struct UASYNC* ua, int timeout_tb) { - if (!ua) return; - if (!ua->sockets || !ua->timeout_heap) return; +// CPU-часы читаются только в потоке владельца, включая системное время этого потока. +static int uasync_thread_cpu_ns(uint64_t* value) { +#ifdef _WIN32 + FILETIME created, exited, kernel, user; + if (!GetThreadTimes(GetCurrentThread(), &created, &exited, &kernel, &user)) { + DEBUG_ERROR(DEBUG_CATEGORY_SYS, "uasync CPU clock failed: error=%lu", (unsigned long)GetLastError()); + return -1; + } + uint64_t kernel_ticks = ((uint64_t)kernel.dwHighDateTime << 32) | kernel.dwLowDateTime; + uint64_t user_ticks = ((uint64_t)user.dwHighDateTime << 32) | user.dwLowDateTime; + *value = (kernel_ticks + user_ticks) * 100; +#else + struct timespec ts; + if (clock_gettime(CLOCK_THREAD_CPUTIME_ID, &ts) != 0) { + DEBUG_ERROR(DEBUG_CATEGORY_SYS, "uasync CPU clock failed: %s", strerror(errno)); + return -1; + } + *value = (uint64_t)ts.tv_sec * 1000000000ULL + ts.tv_nsec; +#endif + return 0; +} + +#define UASYNC_LOAD_PERIOD_US 100000ULL +#define UASYNC_LOAD_WINDOW_US 1000000ULL + +static void uasync_update_load(struct UASYNC* ua) { + if (ua->load_state < 0) return; + uint64_t now = get_time_us(); + if (ua->load_state && now < ua->load_next_us) return; + uint64_t cpu_ns; + if (uasync_thread_cpu_ns(&cpu_ns) < 0) { + ua->load_state = -1; ua->load.valid = 0; + DEBUG_WARN(DEBUG_CATEGORY_SYS, "uasync CPU load disabled: ua=%p", (void*)ua); + return; + } + if (!ua->load_state) { + ua->load_state = 1; ua->load_previous_us = now; ua->load_previous_cpu_ns = cpu_ns; + ua->load_next_us = now + UASYNC_LOAD_PERIOD_US; ua->load_log_us = now + UASYNC_LOAD_WINDOW_US; + DEBUG_DEBUG(DEBUG_CATEGORY_SYS, "uasync CPU load started: ua=%p period=100ms window=1000ms", (void*)ua); + return; + } + if (cpu_ns < ua->load_previous_cpu_ns || now <= ua->load_previous_us) { + DEBUG_ERROR(DEBUG_CATEGORY_SYS, "uasync CPU load clock regression: ua=%p wall=%llu->%llu cpu=%llu->%llu", + (void*)ua, (unsigned long long)ua->load_previous_us, (unsigned long long)now, + (unsigned long long)ua->load_previous_cpu_ns, (unsigned long long)cpu_ns); + ua->load_state = -1; ua->load.valid = 0; + return; + } + uint64_t elapsed = now - ua->load_previous_us, delta_cpu = cpu_ns - ua->load_previous_cpu_ns; + uint64_t late = now - ua->load_next_us; + if (late >= UASYNC_LOAD_PERIOD_US && now - ua->last_poll_exit_us >= UASYNC_LOAD_PERIOD_US) { + DEBUG_WARN(DEBUG_CATEGORY_SYS, "uasync CPU sample delayed: ua=%p interval=%lluus late=%lluus missed=%llu", + (void*)ua, (unsigned long long)elapsed, (unsigned long long)late, + (unsigned long long)(late / UASYNC_LOAD_PERIOD_US)); + } + unsigned int capacity = sizeof(ua->load_history) / sizeof(ua->load_history[0]); + ua->load_history[ua->load_head] = (struct uasync_load_interval){ua->load_previous_us, now, delta_cpu}; + ua->load_head = (ua->load_head + 1) % capacity; + if (ua->load_count < capacity) ua->load_count++; + uint64_t cutoff = now > UASYNC_LOAD_WINDOW_US ? now - UASYNC_LOAD_WINDOW_US : 0; + uint64_t window_us = 0; + double window_cpu_ns = 0; + for (unsigned int i = 0; i < ua->load_count; i++) { + const struct uasync_load_interval* sample = &ua->load_history[i]; + if (sample->end_us <= cutoff) continue; + uint64_t start = sample->start_us > cutoff ? sample->start_us : cutoff; + uint64_t overlap = sample->end_us - start; + window_us += overlap; + window_cpu_ns += (double)sample->cpu_ns * overlap / (sample->end_us - sample->start_us); + } + ua->load = (struct uasync_load){ + .current_percent = (double)delta_cpu / elapsed / 10.0, + .average_percent = window_cpu_ns / window_us / 10.0, + .sample_time_us = now, .interval_us = elapsed, .window_us = window_us, .valid = 1 + }; + // Разница точности CPU/реальных часов может дать небольшое превышение 100%. + if (ua->load.current_percent > 100.0) ua->load.current_percent = 100.0; + if (ua->load.average_percent > 100.0) ua->load.average_percent = 100.0; + ua->load_previous_us = now; ua->load_previous_cpu_ns = cpu_ns; + ua->load_next_us += (late / UASYNC_LOAD_PERIOD_US + 1) * UASYNC_LOAD_PERIOD_US; + if (now >= ua->load_log_us) { + DEBUG_DEBUG(DEBUG_CATEGORY_SYS, "uasync CPU load: ua=%p current=%.2f%% average=%.2f%% interval=%lluus window=%lluus late=%lluus", + (void*)ua, ua->load.current_percent, ua->load.average_percent, (unsigned long long)elapsed, + (unsigned long long)window_us, (unsigned long long)late); + ua->load_log_us = now + UASYNC_LOAD_WINDOW_US; + } +} + +// Секунда непрерывного ожидания полностью вытесняет прежнюю нагрузку из окна. +static void uasync_load_after_wait(struct UASYNC* ua, uint64_t wait_start_us) { + uint64_t now = get_time_us(); + ua->last_poll_exit_us = now; + if (ua->load_state <= 0 || now - wait_start_us < UASYNC_LOAD_WINDOW_US) return; + uint64_t cpu_ns; + if (uasync_thread_cpu_ns(&cpu_ns) < 0) { ua->load_state = -1; ua->load.valid = 0; return; } + ua->load_history[0] = (struct uasync_load_interval){now - UASYNC_LOAD_WINDOW_US, now, 0}; + ua->load_head = 1; ua->load_count = 1; + ua->load_previous_us = now; ua->load_previous_cpu_ns = cpu_ns; + ua->load_next_us = now + UASYNC_LOAD_PERIOD_US; + ua->load = (struct uasync_load){.sample_time_us = now, .interval_us = now - wait_start_us, + .window_us = UASYNC_LOAD_WINDOW_US, .valid = 1}; + DEBUG_DEBUG(DEBUG_CATEGORY_SYS, "uasync CPU load: ua=%p current=0%% average=0%% idle=%lluus", + (void*)ua, (unsigned long long)(now - wait_start_us)); + ua->load_log_us = now + UASYNC_LOAD_WINDOW_US; +} +err_t uasync_get_load(struct UASYNC* ua, struct uasync_load* load) { + if (!ua || !load) { + DEBUG_ERROR(DEBUG_CATEGORY_SYS, "uasync_get_load: invalid argument ua=%p load=%p", (void*)ua, (void*)load); + return ERR_FAIL; + } + uasync_update_load(ua); + *load = ua->load; + return ERR_OK; +} + +static void uasync_poll_once(struct UASYNC* ua, int timeout_tb) { process_immediate_queue(ua); uint64_t now_us_poll_entry = get_time_us(); @@ -1050,9 +1162,10 @@ void uasync_poll(struct UASYNC* ua, int timeout_tb) { struct epoll_event events[64]; // Stack-allocated array for events int max_events = 64; + uint64_t wait_start_us = get_time_us(); int ret = epoll_wait(ua->epoll_fd, events, max_events, timeout_ms); int saved_errno = errno; - ua->last_poll_exit_us = get_time_us(); + uasync_load_after_wait(ua, wait_start_us); if (ret < 0) { if (saved_errno == EINTR) { DEBUG_DEBUG(DEBUG_CATEGORY_SYS, "epoll_wait→EINTR"); @@ -1087,6 +1200,7 @@ void uasync_poll(struct UASYNC* ua, int timeout_tb) { // If no sockets to poll, just wait and process timeouts if (total_fds == 0) { + uint64_t wait_start_us = get_time_us(); if (timeout_ms > 0) { #ifdef _WIN32 Sleep(timeout_ms); @@ -1095,7 +1209,7 @@ void uasync_poll(struct UASYNC* ua, int timeout_tb) { nanosleep(&ts, NULL); #endif } - ua->last_poll_exit_us = get_time_us(); + uasync_load_after_wait(ua, wait_start_us); process_timeouts(ua); return; } @@ -1138,11 +1252,13 @@ void uasync_poll(struct UASYNC* ua, int timeout_tb) { tv.tv_sec = timeout_ms / 1000; tv.tv_usec = (timeout_ms % 1000) * 1000; + uint64_t wait_start_us = get_time_us(); int ret = select((int)max_fd + 1, &read_fds, &write_fds, &except_fds, timeout_ms < 0 ? NULL : &tv); - ua->last_poll_exit_us = get_time_us(); + int saved_error = ret < 0 ? WSAGetLastError() : 0; + uasync_load_after_wait(ua, wait_start_us); if (ret < 0) { - int err = WSAGetLastError(); + int err = saved_error; if (err != WSAEINTR) { DEBUG_ERROR(DEBUG_CATEGORY_SYS, "select failed: %d", err); } @@ -1229,9 +1345,10 @@ void uasync_poll(struct UASYNC* ua, int timeout_tb) { } } + uint64_t wait_start_us = get_time_us(); int ret = poll(ua->poll_fds, ua->poll_fds_count, timeout_ms); int saved_errno = errno; - ua->last_poll_exit_us = get_time_us(); + uasync_load_after_wait(ua, wait_start_us); if (ret < 0) { if (saved_errno == EINTR) { return; @@ -1344,7 +1461,12 @@ void uasync_poll(struct UASYNC* ua, int timeout_tb) { } -// Put this near the top of u_async.c, after includes and before uasync_create +void uasync_poll(struct UASYNC* ua, int timeout_tb) { + if (!ua || !ua->sockets || !ua->timeout_heap) return; + uasync_update_load(ua); + uasync_poll_once(ua, timeout_tb); + uasync_update_load(ua); +} #ifdef _WIN32 @@ -1793,7 +1915,8 @@ void uasync_stop(struct UASYNC* ua) { } void uasync_mainloop(struct UASYNC* ua) { - while (!__atomic_load_n(&ua->stop, __ATOMIC_ACQUIRE)) { - uasync_poll(ua, -1); - } + if (!ua) { DEBUG_ERROR(DEBUG_CATEGORY_SYS, "uasync_mainloop: null instance"); return; } + while (!__atomic_load_n(&ua->stop, __ATOMIC_ACQUIRE)) { + uasync_poll(ua, -1); + } } diff --git a/lib/u_async.h b/lib/u_async.h index 1089a3e4..42412e2a 100644 --- a/lib/u_async.h +++ b/lib/u_async.h @@ -39,6 +39,22 @@ typedef void (*uasync_post_callback_t)(void* user_arg); struct timeout_node; // Forward declaration +/* CPU одного потока: 100% = одно ядро. При работе цикла/запросе снимок обновляется раз в 100 мс, без пробуждений. + * До первого замера valid=0; window_us растёт до секунды. При задержке замера + * вклад граничного интервала в среднее оценивается пропорционально времени. */ +struct uasync_load { + double current_percent; + double average_percent; + uint64_t sample_time_us; + uint64_t interval_us; + uint64_t window_us; + int valid; +}; + +struct uasync_load_interval { + uint64_t start_us, end_us, cpu_ns; +}; + struct posted_task { uasync_post_callback_t callback; void* arg; @@ -79,6 +95,11 @@ struct UASYNC { volatile sig_atomic_t stop; volatile sig_atomic_t running; // 0=shutdown mode (API rejected), 1=full init done uint64_t last_poll_exit_us; + struct uasync_load load; + struct uasync_load_interval load_history[12]; // 10 периодов + граничные интервалы при задержках + uint64_t load_previous_us, load_previous_cpu_ns, load_next_us, load_log_us; + unsigned int load_head, load_count; + int load_state; // 0=не начат, 1=работает, -1=ошибка часов }; // Type definitions @@ -137,6 +158,9 @@ void uasync_stop(struct UASYNC* ua); // Debug statistics void uasync_get_stats(struct UASYNC* ua, size_t* timer_alloc, size_t* timer_free, size_t* socket_alloc, size_t* socket_free); +// Актуализирует снимок по сроку 100 мс. Только в потоке uasync; наружу передавать копию через события. +err_t uasync_get_load(struct UASYNC* ua, struct uasync_load* load); + // Handle — кодированный индекс; сохраняется при расширении массива сокетов. int uasync_lookup_socket(struct UASYNC* ua, int fd, void** socket_id); diff --git a/lib/u_async_doc.md b/lib/u_async_doc.md index 6bdb41e3..d9af49e7 100644 --- a/lib/u_async_doc.md +++ b/lib/u_async_doc.md @@ -164,8 +164,35 @@ uint64_t now_us = get_time_us(); // микросекунды (для burst-и | Функция | Описание | |---------|----------| | `uasync_get_stats(ua, ...)` | Получить счётчики аллокаций/освобождений таймеров и сокетов. | +| `uasync_get_load(ua, &load)` | Актуализировать по сроку 100 мс и скопировать снимок CPU потока: текущий процент, среднее за секунду, время замера, длительность интервала и окна, valid. Только в потоке uasync. | | `uasync_print_resources(ua, prefix)` | Вывести в лог все активные таймеры (имя, оставшееся время) и сокеты. Вызывается также в `uasync_destroy` перед очисткой. | +CPU измеряется с первого `uasync_poll` или `uasync_get_load`: по сроку 100 мс читается CPU-время текущего потока +(`CLOCK_THREAD_CPUTIME_ID` на POSIX, user+kernel `GetThreadTimes` на Windows). 100% означает одно полностью занятое ядро. +Учитывается вся работа этого потока между замерами, включая код между вызовами poll; CPU других потоков не учитывается. +Проценты вычисляются по фактической длительности интервала: `100 * cpu_ns / (elapsed_us * 1000)`. +Реальное время берётся из `get_time_us` (Linux: `CLOCK_BOOTTIME`, включая suspend; Windows: QPC). + +Сроки измерения идут по сетке 100 мс без накопления дрейфа. Проверка выполняется при работе цикла и при запросе нагрузки. +Измеритель не создаёт таймеров, не ограничивает ожидание событий и не будит спящий поток. +После непрерывного ожидания не менее секунды весь старый интервал нагрузки вытеснен: текущий процент и среднее равны 0%. +Это фиксируется сразу после выхода из ожидания, до callbacks; CPU последующих обработчиков учитывается с новой точки отсчёта. +История хранится внутри `UASYNC`, без аллокаций. Частые запросы не создают лишних отсчётов. +Среднее ограничено последней секундой; вклад пересекающего границу интервала оценивается пропорционально перекрытию. +До первого замера `valid=0`, затем окно растёт до секунды. Долгий callback задерживает публикацию; +пропущенные отсчёты не восстанавливаются, текущий процент относится ко всему фактическому интервалу. +При большом перерыве вне ожидания среднее внутри этого интервала является оценкой по его общей загрузке. +Снимок содержит `sample_time_us`, чтобы потребитель мог показать возраст данных. +Ошибка CPU-часов логируется и отключает измеритель (`valid=0`), сохраняя работу цикла событий. +В `sys=debug` раз в секунду выводятся проценты, длительность интервала/окна и опоздание; +пропуск полного периода из-за долгой обработки выводится как WARN. Статус чата и headless-команда `status` показывают CPU и среднее. + +GUI показывает округлённое секундное среднее 0–100% поверх полосы с градиентом зелёный → жёлтый → красный: +desktop — рядом с заголовком каналов, Android — рядом с версией в списке групп. +Запрос `chat_core_collect_cpu_load_trampoline` возвращает короткое событие `CHAT_EVT_CPU_LOAD` (`valid:1, average_percent:1`). +GUI опрашивает ядро каждые 333 мс, не более одного незавершённого запроса. Android опрашивает только пока экран списка групп RESUMED; +при паузе или уходе с экрана coroutine отменяется, фоновых запросов и таймеров ядра для индикатора нет. + ### Внутренние структуры - **`struct timeout_node`** — узел таймера: name, arg, callback, expiration_ms, heap_index. Используется и для таймеров в heap, и для FIFO-очереди immediate (через поле `next`). Выделяется из `timeout_pool`. diff --git a/src/chat/chat_core.h b/src/chat/chat_core.h index 4da44ea8..3850311f 100644 --- a/src/chat/chat_core.h +++ b/src/chat/chat_core.h @@ -212,6 +212,9 @@ struct voice_played_req { /* Сбор статуса (NTP + connections) и отправка в GUI. arg = struct UTUN_INSTANCE* (inst) */ void chat_core_collect_status_trampoline(void* arg); +/* Лёгкий запрос нагрузки uasync для GUI. arg = inst, ответ CHAT_EVT_CPU_LOAD. */ +void chat_core_collect_cpu_load_trampoline(void* arg); + /* Сбор списка ETCP-соединений и отправка в GUI (binary packet). arg = inst */ void chat_core_collect_conn_list_trampoline(void* arg); diff --git a/src/chat/chat_event.c b/src/chat/chat_event.c index fd2509ce..48efed2e 100644 --- a/src/chat/chat_event.c +++ b/src/chat/chat_event.c @@ -52,6 +52,7 @@ void chat_event_post(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, [45]="DM_CONVERSATIONS", [46]="ATTACHMENT_PREPARATION", [47]="DM_CALL_ROUTE", [55]="RADIO_MUTE_RESULT", [56]="MSG_SUBMITTED", + [57]="CPU_LOAD", }; const char* n = (type >= 1 && type < (int)(sizeof(names) / sizeof(names[0]))) ? names[type] : "?"; DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "chat_event: %s(%d) data=%d bytes", n, type, len); diff --git a/src/chat/chat_event.h b/src/chat/chat_event.h index 9c9ee28a..468500ca 100644 --- a/src/chat/chat_event.h +++ b/src/chat/chat_event.h @@ -78,6 +78,7 @@ extern "C" { #define CHAT_EVT_FEEDBACK_RESULT 54 /* [request_id:8LE][success:1][JSON] — результат команды интерфейса */ #define CHAT_EVT_RADIO_MUTE_RESULT 55 /* [group_id:8LE][node_id:8LE][success:1] — локальный переключатель рации */ #define CHAT_EVT_MSG_SUBMITTED 56 /* [request_id:8LE][success:1] — результат локального сохранения сообщения */ +#define CHAT_EVT_CPU_LOAD 57 /* [valid:1][average_percent:1] — CPU потока uasync, среднее за секунду, 0..100 */ typedef void (*chat_event_handler_fn)(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int len); diff --git a/src/chat/chat_headless_control.c b/src/chat/chat_headless_control.c index bd6e86f9..1729db46 100644 --- a/src/chat/chat_headless_control.c +++ b/src/chat/chat_headless_control.c @@ -330,6 +330,12 @@ static void hc_handle_status(struct headless_client* cli, int id, const char* js off += snprintf(buf + off, sizeof(buf) - off, "ERROR: no instance"); } else { int conn_count = hc->inst->connections ? queue_entry_count(hc->inst->connections) : 0; + struct uasync_load load; + if (uasync_get_load(hc->ua, &load) == ERR_OK && load.valid) { + off += snprintf(buf + off, sizeof(buf) - off, "uasync CPU: %.1f%%, average %.1f%% (window %.0f ms, age %.0f ms)\\n", + load.current_percent, load.average_percent, load.window_us / 1000.0, + (get_time_us() - load.sample_time_us) / 1000.0); + } else off += snprintf(buf + off, sizeof(buf) - off, "uasync CPU: unavailable\\n"); struct NTP_TIME* ntp = &hc->inst->ntp; off += snprintf(buf + off, sizeof(buf) - off, "NTP: enabled=%s synced=%s offset=%.1fs\\n", ntp->enabled ? "yes" : "no", ntp->synced ? "yes" : "no", ntp->offset_us / 1000000.0); diff --git a/src/chat/chat_status.c b/src/chat/chat_status.c index eb51633a..0bf3c523 100644 --- a/src/chat/chat_status.c +++ b/src/chat/chat_status.c @@ -47,6 +47,13 @@ static void chat_core_collect_status(struct UTUN_INSTANCE* inst) { return; } + struct uasync_load load; + if (uasync_get_load(inst->ua, &load) == ERR_OK && load.valid) { + off += snprintf(buf + off, sizeof(buf) - off, "uasync CPU: %.1f%%, average %.1f%% (window %.0f ms, age %.0f ms)\n", + load.current_percent, load.average_percent, load.window_us / 1000.0, + (get_time_us() - load.sample_time_us) / 1000.0); + } else off += snprintf(buf + off, sizeof(buf) - off, "uasync CPU: unavailable\n"); + struct NTP_TIME* ntp = &inst->ntp; off += snprintf(buf + off, sizeof(buf) - off, "=== NTP ===\n"); off += snprintf(buf + off, sizeof(buf) - off, "Enabled: %s\n", ntp->enabled ? "yes" : "no"); @@ -293,6 +300,19 @@ void chat_core_collect_status_trampoline(void* arg) { chat_core_collect_status(inst); } +/* Снимок снимается в потоке ядра; GUI получает только готовый процент. */ +void chat_core_collect_cpu_load_trampoline(void* arg) { + struct UTUN_INSTANCE* inst = arg; + if (!inst || !inst->ua) { DEBUG_WARN(DEBUG_CATEGORY_SYS, "CPU load request: core unavailable"); return; } + struct uasync_load load; + uint8_t data[2] = {0, 0}; + if (uasync_get_load(inst->ua, &load) == ERR_OK && load.valid) { + data[0] = 1; data[1] = (uint8_t)(load.average_percent + 0.5); + } + DEBUG_TRACE(DEBUG_CATEGORY_SYS, "CPU load GUI response: valid=%u average=%u%%", data[0], data[1]); + chat_event_post(inst, CHAT_EVT_CPU_LOAD, data, sizeof(data)); +} + void chat_core_collect_conn_list_trampoline(void* arg) { struct UTUN_INSTANCE* inst = (struct UTUN_INSTANCE*)arg; collect_conn_list(inst); diff --git a/tests/Makefile.am b/tests/Makefile.am index 111b080c..3f87c670 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -134,6 +134,7 @@ if OS_LINUX check_PROGRAMS += test_auto_socket_dynamic test_standby test_standby_transport test_group_connect_standby test_android_udp_log test_pool_corruption_log check_PROGRAMS += test_reality_fallback test_stcp_reality_rotation test_uasync_regressions test_ll_queue_regressions test_etcp_padding check_PROGRAMS += test_android_replies +check_PROGRAMS += test_uasync_load endif # Silero VAD: только при включённом ONNX Runtime (--with-silero-vad) @@ -216,6 +217,9 @@ test_etcp_padding_LDFLAGS = -Wl,--wrap=uasync_set_timeout -Wl,--wrap=socket_send test_uasync_regressions_SOURCES = test_uasync_regressions.c $(async_regression_sources) test_uasync_regressions_LDADD = $(COMMON_LIBS) test_uasync_regressions_LDFLAGS = $(async_regression_ldflags) +test_uasync_load_SOURCES = test_uasync_load.c +test_uasync_load_LDADD = $(COMMON_LIBS) +test_uasync_load_LDFLAGS = -Wl,--wrap=clock_gettime -Wl,--wrap=epoll_wait -Wl,--wrap=poll -Wl,--wrap=epoll_create1 test_ll_queue_regressions_SOURCES = test_ll_queue_regressions.c $(async_regression_sources) test_ll_queue_regressions_LDADD = $(COMMON_LIBS) test_ll_queue_regressions_LDFLAGS = $(async_regression_ldflags) diff --git a/tests/test_uasync_load.c b/tests/test_uasync_load.c new file mode 100644 index 00000000..592e4f12 --- /dev/null +++ b/tests/test_uasync_load.c @@ -0,0 +1,238 @@ +/* CPU-метрика: управляемые часы/ожидание и реальная нагрузка одного потока. */ +#define _GNU_SOURCE +#include "u_async.h" +#include "debug_config.h" +#include "mem.h" +#include +#include +#include +#include +#include +#include +#include + +#define CHECK(c) do { if (!(c)) { fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #c); exit(1); } } while (0) +#define NEAR(a, b) CHECK(fabs((a) - (b)) < 0.0001) + +static int simulated, clock_failure, wait_error, last_timeout, worker_stop, force_poll; +static uint64_t wall_us, cpu_ns, wake_after_us; + +int __real_epoll_create1(int flags); +int __wrap_epoll_create1(int flags) { + if (force_poll) { errno = ENOSYS; return -1; } + return __real_epoll_create1(flags); +} + +int __real_clock_gettime(clockid_t id, struct timespec* ts); +int __wrap_clock_gettime(clockid_t id, struct timespec* ts) { + if (!simulated) return __real_clock_gettime(id, ts); + if (id == CLOCK_THREAD_CPUTIME_ID && clock_failure) { errno = EINVAL; return -1; } + uint64_t ns = id == CLOCK_THREAD_CPUTIME_ID ? cpu_ns : wall_us * 1000; + ts->tv_sec = ns / 1000000000; ts->tv_nsec = ns % 1000000000; + return 0; +} + +static int simulate_wait(int timeout) { + last_timeout = timeout; + CHECK(timeout >= 0 || wake_after_us); + wall_us += timeout >= 0 ? (uint64_t)timeout * 1000 : wake_after_us; + wake_after_us = 0; + if (wait_error) { errno = wait_error; wait_error = 0; return -1; } + return 0; +} + +int __real_epoll_wait(int fd, struct epoll_event* events, int count, int timeout); +int __wrap_epoll_wait(int fd, struct epoll_event* events, int count, int timeout) { + return simulated ? simulate_wait(timeout) : __real_epoll_wait(fd, events, count, timeout); +} + +int __real_poll(struct pollfd* fds, nfds_t count, int timeout); +int __wrap_poll(struct pollfd* fds, nfds_t count, int timeout) { + return simulated ? simulate_wait(timeout) : __real_poll(fds, count, timeout); +} + +static struct uasync_load snapshot(struct UASYNC* ua) { + struct uasync_load load; + CHECK(uasync_get_load(ua, &load) == ERR_OK); + return load; +} + +static struct uasync_load advance(struct UASYNC* ua, uint64_t elapsed_us, uint64_t used_cpu_us) { + wall_us += elapsed_us; cpu_ns += used_cpu_us * 1000; + uasync_poll(ua, 0); + return snapshot(ua); +} + +static struct UASYNC* start_fake(int fallback) { + simulated = 1; wall_us = 10000000; cpu_ns = 0; clock_failure = 0; wait_error = 0; + force_poll = fallback; + struct UASYNC* ua = uasync_create(); CHECK(ua); + CHECK(!snapshot(ua).valid); + uasync_poll(ua, 0); + CHECK(!snapshot(ua).valid); + return ua; +} + +static void delayed_callback(void* arg) { + (void)arg; + wall_us += 350000; cpu_ns += 70000000; +} + +static void deterministic(int fallback) { + struct UASYNC* ua = start_fake(fallback); + for (int i = 1; i <= 10; i++) { + uasync_poll(ua, 1000); + struct uasync_load load = snapshot(ua); + CHECK(last_timeout == 100); CHECK(load.valid); CHECK(load.window_us == (uint64_t)i * 100000); + NEAR(load.current_percent, 0); NEAR(load.average_percent, 0); + } + // Пять нагруженных отсчётов вытесняют половину секунды простоя. + struct uasync_load load; + for (int i = 1; i <= 5; i++) { + load = advance(ua, 100000, 100000); + NEAR(load.current_percent, 100); NEAR(load.average_percent, i * 10.0); + CHECK(load.window_us == 1000000); + } + for (int i = 0; i < 5; i++) load = advance(ua, 100000, 0); + NEAR(load.average_percent, 50); + for (int i = 1; i <= 5; i++) { load = advance(ua, 100000, 0); NEAR(load.average_percent, 50 - i * 10.0); } + // Нулевые проходы не создают отсчёты. + uint64_t sampled = load.sample_time_us; + for (int i = 0; i < 100; i++) uasync_poll(ua, 0); + CHECK(snapshot(ua).sample_time_us == sampled); + // EINTR на сроке измерения также публикует отсчёт. + wait_error = EINTR; uasync_poll(ua, 1000); + CHECK(snapshot(ua).sample_time_us == sampled + 100000); + uasync_destroy(ua, 0); + + ua = start_fake(fallback); + load = advance(ua, 190000, 95000); NEAR(load.average_percent, 50); + load = advance(ua, 10000, 0); NEAR(load.current_percent, 0); NEAR(load.average_percent, 47.5); + for (int i = 0; i < 9; i++) load = advance(ua, 100000, 0); + // В окне осталось 90мс первого интервала с CPU 50% = 45мс / 1с. + CHECK(load.window_us == 1000000); NEAR(load.average_percent, 4.5); + load = advance(ua, 100000, 0); NEAR(load.average_percent, 0); + // Перерыв > окна: оценка по измеренному длинному интервалу, без выдуманных отсчётов. + load = advance(ua, 2500000, 625000); + CHECK(load.interval_us == 2500000); CHECK(load.window_us == 1000000); + NEAR(load.current_percent, 25); NEAR(load.average_percent, 25); + uasync_destroy(ua, 0); + + ua = start_fake(fallback); + CHECK(uasync_call_soon(ua, NULL, delayed_callback)); + uasync_poll(ua, 0); load = snapshot(ua); + CHECK(last_timeout == 0); CHECK(load.interval_us == 350000); NEAR(load.current_percent, 20); + uasync_poll(ua, 500); CHECK(last_timeout == 50); CHECK(snapshot(ua).sample_time_us == 10400000); + clock_failure = 1; advance(ua, 100000, 0); CHECK(!snapshot(ua).valid); + clock_failure = 0; uasync_poll(ua, 0); CHECK(!snapshot(ua).valid); + uasync_destroy(ua, 0); + + ua = start_fake(fallback); + // Запросы без poll актуализируют нагрузку, частые запросы не вытесняют историю. + for (int i = 0; i < 20000; i++) { + wall_us += 50; cpu_ns += 25000; + load = snapshot(ua); + } + NEAR(load.current_percent, 50); NEAR(load.average_percent, 50); CHECK(load.window_us == 1000000); + // Бесконечный poll не получает таймаут от измерителя; пробуждение моделирует внешнее событие. + wake_after_us = 2000000; uasync_poll(ua, -1); CHECK(last_timeout == -1); + load = snapshot(ua); CHECK(load.valid); NEAR(load.current_percent, 0); NEAR(load.average_percent, 0); + CHECK(load.window_us == 1000000); + uasync_destroy(ua, 0); + printf("PASS deterministic %s: cadence, window, weighting, delay, EINTR, clock failure, request, sleep\n", fallback ? "poll" : "epoll"); +} + +static uint64_t real_time_ns(clockid_t id) { + struct timespec ts; CHECK(__real_clock_gettime(id, &ts) == 0); + return (uint64_t)ts.tv_sec * 1000000000 + ts.tv_nsec; +} + +struct workload { + struct UASYNC* ua; + int mixed; + uint64_t next_us; + void* timer; +}; + +static void burn(void* arg) { + struct workload* work = arg; + work->timer = NULL; + uint64_t end = real_time_ns(CLOCK_MONOTONIC) + (work->mixed ? 25000000 : 3000000); + while (real_time_ns(CLOCK_MONOTONIC) < end) {} + if (work->mixed) { + work->next_us += 100000; + uint64_t now = get_time_us(); + int delay_tb = now < work->next_us ? (int)((work->next_us - now + 99) / 100) : 0; + work->timer = uasync_set_timeout(work->ua, delay_tb, work, burn, "load 25%"); + } else work->timer = uasync_call_soon(work->ua, work, burn); + CHECK(work->timer); +} + +static void* other_thread(void* arg) { + (void)arg; + while (!__atomic_load_n(&worker_stop, __ATOMIC_ACQUIRE)) {} + return NULL; +} + +static void real_phase(struct UASYNC* ua, const char* name, double minimum, double maximum) { + uint64_t start = get_time_us(), deadline = start + 1400000, last = snapshot(ua).sample_time_us; + uint64_t before_cpu = real_time_ns(CLOCK_THREAD_CPUTIME_ID); + unsigned int updates = 0; + while (get_time_us() < deadline) { + uasync_poll(ua, 1000); + uint64_t sample = snapshot(ua).sample_time_us; + if (sample != last) { updates++; last = sample; } + } + struct uasync_load load = snapshot(ua); + double reference = (double)(real_time_ns(CLOCK_THREAD_CPUTIME_ID) - before_cpu) / (get_time_us() - start) / 10; + printf("LIVE %-18s current=%6.2f%% average=%6.2f%% reference=%6.2f%% updates=%u window=%lluus\n", + name, load.current_percent, load.average_percent, reference, updates, (unsigned long long)load.window_us); + CHECK(load.valid); CHECK(load.window_us == 1000000); + CHECK(load.average_percent >= minimum && load.average_percent <= maximum); + CHECK(fabs(load.average_percent - reference) < 15); + CHECK(updates >= 10 && updates <= 16); +} + +static void timer_fired(void* arg) { *(int*)arg = 1; } + +static void real_load(int fallback) { + simulated = 0; + force_poll = fallback; + struct UASYNC* ua = uasync_create(); CHECK(ua); + printf("REAL %s\n", fallback ? "poll" : "epoll"); + real_phase(ua, "idle", 0, 10); + struct workload work = {.ua = ua, .mixed = 1, .next_us = get_time_us()}; + work.timer = uasync_call_soon(ua, &work, burn); CHECK(work.timer); + real_phase(ua, "25% wall workload", 10, 40); + CHECK(uasync_cancel_timeout(ua, work.timer) == ERR_OK); + work.mixed = 0; work.timer = uasync_call_soon(ua, &work, burn); CHECK(work.timer); + real_phase(ua, "busy", 40, 100); + CHECK(uasync_call_soon_cancel(ua, work.timer) == ERR_OK); + real_phase(ua, "recovered idle", 0, 10); + pthread_t worker; __atomic_store_n(&worker_stop, 0, __ATOMIC_RELEASE); + CHECK(pthread_create(&worker, NULL, other_thread, NULL) == 0); + real_phase(ua, "other thread busy", 0, 10); + __atomic_store_n(&worker_stop, 1, __ATOMIC_RELEASE); CHECK(pthread_join(worker, NULL) == 0); + // Один poll спит до прикладного таймера, без пробуждений для CPU-метрики. + int fired = 0; CHECK(uasync_set_timeout(ua, 12000, &fired, timer_fired, "long idle")); + uint64_t sleep_start = get_time_us(); + uasync_poll(ua, -1); + uint64_t slept = get_time_us() - sleep_start; + struct uasync_load load = snapshot(ua); + CHECK(fired); CHECK(slept >= 1190000); NEAR(load.current_percent, 0); NEAR(load.average_percent, 0); + printf("LIVE long idle current=%.2f%% average=%.2f%% one_poll=%lluus\n", + load.current_percent, load.average_percent, (unsigned long long)slept); + uasync_destroy(ua, 0); +} + +int main(void) { + debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); + for (int i = 1; i < DEBUG_CATEGORY_COUNT; i++) debug_set_category_level(i, DEBUG_LEVEL_WARN); + if (getenv("UTUN_TEST_DEBUG")) debug_set_category_level(DEBUG_CATEGORY_SYS, DEBUG_LEVEL_DEBUG); + size_t allocated = u_get_allocated_count(); + deterministic(0); deterministic(1); + real_load(0); real_load(1); + CHECK(u_get_allocated_count() == allocated); + puts("PASS uasync CPU load"); + return 0; +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt index c2c8b247..446d5bf5 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt @@ -358,11 +358,14 @@ class MainActivity : AppCompatActivity() { val channels by vm.channels.collectAsState() val dmConversations by vm.dmConversations.collectAsState() val stuck by vm.isStuck.collectAsState() + val cpuLoad by vm.cpuLoad.collectAsState() ChannelListScreen( channels = channels, dmConversations = dmConversations, connected = connected, isStuck = stuck, + cpuLoad = cpuLoad, + onCpuLoadVisible = vm::setCpuLoadVisible, onSelectChannel = { ch -> vm.selectChannel(ch); screen = Screen.Chat }, onSelectDm = { dm -> vm.openDm(dm.convId, dm.peerNodeId, dm.peerName); screen = Screen.Dm }, onCreateChannel = { name -> vm.createChannel(name, "channel_1") }, diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index f4517d8f..356698a8 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt @@ -84,6 +84,7 @@ object NativeLib { fun requestInviteLink(channelId: String, targetNodeId: Long, requestId: Long) { nativeRequestInviteLink(channelId, targetNodeId, requestId) } fun ping() { nativePing() } + fun requestCpuLoad(): Boolean = nativeRequestCpuLoad() fun isResponsive(): Boolean = nativeIsResponsive() /* ── App foreground/background (Android lifecycle) ── */ @@ -253,6 +254,7 @@ object NativeLib { private external fun nativeInit(dbPath: String, controlPort: Int, config: Any, logCb: Any): Boolean private external fun nativeStart(configText: String): Boolean private external fun nativeStop() + private external fun nativeRequestCpuLoad(): Boolean private external fun nativeSetEventCallback(cb: Any) private external fun nativeDestroy() private external fun nativeSendMessage(channelId: String, text: ByteArray, replyTo: ByteArray, requestId: Long): Boolean diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/CpuLoadIndicator.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/CpuLoadIndicator.kt new file mode 100644 index 00000000..8888d986 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/CpuLoadIndicator.kt @@ -0,0 +1,39 @@ +package com.utun.chat.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/* Градиент привязан ко всей ширине: низкая нагрузка зелёная, полная доходит до красного. */ +@Composable +fun CpuLoadIndicator(percent: Int, modifier: Modifier = Modifier) { + val value = percent.coerceIn(0, 100) + Box(modifier.size(60.dp, 24.dp).semantics { contentDescription = "CPU $value%" }, contentAlignment = Alignment.Center) { + Canvas(Modifier.matchParentSize()) { + val shape = Path().apply { addRoundRect(RoundRect(0f, 0f, size.width, size.height, CornerRadius(5.dp.toPx()))) } + clipPath(shape) { + drawRect(Color(0xFFE4E9E6)) + clipRect(right = size.width * value / 100f) { + drawRect(Brush.horizontalGradient(listOf(Color(0xFF66BB6A), Color(0xFFFFCA28), Color(0xFFEF5350)), endX = size.width)) + } + } + } + Text("$value%", color = Color(0xFF172019), fontSize = 12.sp, fontWeight = FontWeight.Bold, maxLines = 1) + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt index 57bb982b..c151ec1d 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt @@ -18,6 +18,8 @@ import androidx.compose.material.icons.filled.Build import androidx.compose.material3.* import androidx.compose.runtime.* import com.utun.chat.ui.components.PeerStatusDot +import com.utun.chat.ui.components.CpuLoadIndicator +import androidx.lifecycle.compose.LifecycleResumeEffect import com.utun.chat.ui.components.GroupAvatar import com.utun.chat.ui.components.UserAvatar import com.utun.chat.ui.components.GroupPropertiesDialog @@ -43,6 +45,8 @@ fun ChannelListScreen( dmConversations: List = emptyList(), connected: Boolean, isStuck: Boolean = false, + cpuLoad: Int = 0, + onCpuLoadVisible: (Boolean) -> Unit = {}, onSelectChannel: (Channel) -> Unit, onSelectDm: (DmConversation) -> Unit = {}, onCreateChannel: (String) -> Unit, @@ -53,6 +57,10 @@ fun ChannelListScreen( onLogs: () -> Unit, onRefresh: () -> Unit = {} ) { + LifecycleResumeEffect(Unit) { + onCpuLoadVisible(true) + onPauseOrDispose { onCpuLoadVisible(false) } + } var showCreateDialog by remember { mutableStateOf(false) } var menuChannelId by remember { mutableStateOf(null) } var optionsChannel by remember { mutableStateOf(null) } @@ -81,6 +89,7 @@ fun ChannelListScreen( }, floatingActionButton = { Row(verticalAlignment = Alignment.CenterVertically) { + CpuLoadIndicator(cpuLoad, Modifier.padding(end = 12.dp)) Text( text = "v${BuildConfig.VERSION_NAME}", modifier = Modifier.padding(end = 16.dp), diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index 350414f1..376f5595 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -250,6 +250,13 @@ class ChatViewModel : ViewModel() { private val _isStuck = MutableStateFlow(false) val isStuck: StateFlow = _isStuck + private val _cpuLoad = MutableStateFlow(0) + val cpuLoad: StateFlow = _cpuLoad + private val _cpuLoadVisible = MutableStateFlow(false) + private var cpuLoadPending = false + + fun setCpuLoadVisible(visible: Boolean) { _cpuLoadVisible.value = visible } + private val _nodeStatuses = MutableStateFlow>(emptyMap()) val nodeStatuses: StateFlow> = _nodeStatuses @@ -324,6 +331,21 @@ class ChatViewModel : ViewModel() { } init { + viewModelScope.launch { + _cpuLoadVisible.collectLatest { visible -> + if (!visible) return@collectLatest + LogManager.addLog("DEBUG", "CPU", "GUI polling started: period=333ms") + try { + while (isActive) { + if (!NativeLib.isReady()) { cpuLoadPending = false; _cpuLoad.value = 0 } + else if (!cpuLoadPending) cpuLoadPending = NativeLib.requestCpuLoad() + delay(333) + } + } finally { + LogManager.addLog("DEBUG", "CPU", "GUI polling stopped") + } + } + } viewModelScope.launch { AppEventHandler.events.collect { (type, data) -> handleEvent(type, data) } } @@ -386,11 +408,21 @@ class ChatViewModel : ViewModel() { private fun handleEvent(type: Int, data: ByteArray?) { if (type == 13) { + cpuLoadPending = false; _cpuLoad.value = 0 dmPresenceRefreshJob?.cancel() _dmCallAvailable.value = false _dmConversations.value = _dmConversations.value.map { it.copy(online = false) } } else if (type in listOf(4, 5, 12, 16, 17, 24, 25, 31, 33)) refreshDmCallRoute() when (type) { + 57 -> { /* CPU_LOAD: valid:1, average_percent:1. */ + cpuLoadPending = false + if (data == null || data.size != 2 || data[0].toInt() !in 0..1 || data[1].toInt() !in 0..100) { + LogManager.addLog("ERROR", "CPU", "Invalid CPU load response: size=${data?.size}") + _cpuLoad.value = 0 + return + } + if (_cpuLoadVisible.value) _cpuLoad.value = if (data[0].toInt() == 1) data[1].toInt() else 0 + } 56 -> { /* MSG_SUBMITTED: request_id:8LE, success. */ if (data == null || data.size != 9) { LogManager.addLog("ERROR", "Reply", "invalid submission result"); return } val id = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN).long diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index f213ccf2..93740728 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -2161,6 +2161,19 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativePing( utun_bridge_ping(); } +/* GUI запрашивает готовый снимок асинхронно, CPU-часы читает только поток ядра. */ +JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRequestCpuLoad(JNIEnv* env, jobject thiz) { + (void)env; (void)thiz; + struct UASYNC* ua = instance_lite_get_uasync(); + struct UTUN_INSTANCE* inst = instance_lite_get_instance(); + if (!ua || !inst || !instance_lite_is_running()) return JNI_FALSE; + struct posted_task* task = u_calloc(1, sizeof(*task)); + if (!task) { DEBUG_ERROR(DEBUG_CATEGORY_SYS, "CPU load request allocation failed"); return JNI_FALSE; } + task->callback = chat_core_collect_cpu_load_trampoline; task->arg = inst; + uasync_post_reserved(ua, task); + return JNI_TRUE; +} + JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeConnectChannel( JNIEnv* env, jobject thiz, jstring channelId) { (void)thiz; diff --git a/tools/chatgui/src/channellist.cpp b/tools/chatgui/src/channellist.cpp index a9d336b5..7d24c390 100644 --- a/tools/chatgui/src/channellist.cpp +++ b/tools/chatgui/src/channellist.cpp @@ -24,6 +24,38 @@ extern "C" { #include "dm/dm_core.h" } +/* Секундное среднее CPU: фиксированный градиент, заполнение по проценту. */ +class CpuLoadIndicator : public QWidget { +public: + explicit CpuLoadIndicator(QWidget* parent) : QWidget(parent) { + setObjectName(QStringLiteral("cpuLoadIndicator")); + setFixedSize(60, 24); + setAccessibleName(QStringLiteral("CPU")); + } + void setValue(int percent) { + percent = qBound(0, percent, 100); + if (m_percent == percent) return; + m_percent = percent; update(); + } +protected: + void paintEvent(QPaintEvent*) override { + QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); + QPainterPath path; path.addRoundedRect(QRectF(rect()), 5, 5); + painter.setClipPath(path); + painter.fillRect(rect(), QColor("#E4E9E6")); + QLinearGradient gradient(0, 0, width(), 0); + gradient.setColorAt(0, QColor("#66BB6A")); + gradient.setColorAt(0.5, QColor("#FFCA28")); + gradient.setColorAt(1, QColor("#EF5350")); + painter.fillRect(QRectF(0, 0, width() * m_percent / 100.0, height()), gradient); + painter.setPen(QColor("#172019")); + QFont f = font(); f.setBold(true); painter.setFont(f); + painter.drawText(rect(), Qt::AlignCenter, QString::number(m_percent) + '%'); + } +private: + int m_percent = 0; +}; + /* Пустое место и заголовки секций не сбрасывают выбор канала. */ class ChannelListView : public QListView { public: @@ -106,7 +138,12 @@ ChannelList::ChannelList(DbManager* db, QWidget *parent) auto *header = new QLabel(QCoreApplication::translate("ChatGui", "Channels"), this); header->setAlignment(Qt::AlignCenter); header->setStyleSheet("font-weight: bold; padding: 8px; background: palette(window);"); - layout->addWidget(header); + auto* headerRow = new QHBoxLayout; + headerRow->setContentsMargins(0, 0, 8, 0); + headerRow->addWidget(header, 1); + m_cpuLoad = new CpuLoadIndicator(this); + headerRow->addWidget(m_cpuLoad); + layout->addLayout(headerRow); m_listView->setModel(m_model); m_listView->setItemDelegate(new ChannelDelegate(this)); @@ -266,6 +303,8 @@ void ChannelList::onDmCallRoute(const uint8_t* data, int len) { } } +void ChannelList::setCpuLoad(int percent) { m_cpuLoad->setValue(percent); } + void ChannelList::loadChannels() { refreshDmPresence(); QString selectedCid; diff --git a/tools/chatgui/src/channellist.h b/tools/chatgui/src/channellist.h index dae6c080..272c70cf 100644 --- a/tools/chatgui/src/channellist.h +++ b/tools/chatgui/src/channellist.h @@ -12,6 +12,7 @@ class DbManager; class QLabel; +class CpuLoadIndicator; class ChannelList : public QWidget { Q_OBJECT @@ -25,6 +26,7 @@ public: void selectChannel(const QString& channelId); void setChannelPeersOnline(const QString& channelId, int count); void setAutoConnectActive(bool active); + void setCpuLoad(int percent); void setChannelUnreadCount(const QString& channelId, int count); int getChannelUnreadCount(const QString& channelId) const; @@ -49,4 +51,5 @@ private: bool m_autoConnectActive = false; QHash m_dmOnline; QTimer* m_dmPresenceTimer; + CpuLoadIndicator* m_cpuLoad; }; diff --git a/tools/chatgui/src/mainwindow.cpp b/tools/chatgui/src/mainwindow.cpp index c309b346..9b543a68 100644 --- a/tools/chatgui/src/mainwindow.cpp +++ b/tools/chatgui/src/mainwindow.cpp @@ -171,6 +171,18 @@ MainWindow::MainWindow(QWidget *parent, DbManager* db, const QString& cfgPath, setupConnects(); } connect(m_autoPlayTimer, &QTimer::timeout, this, &MainWindow::autoPlayTimerTick); + m_cpuLoadTimer = new QTimer(this); + m_cpuLoadTimer->setTimerType(Qt::PreciseTimer); + connect(m_cpuLoadTimer, &QTimer::timeout, this, [this] { + auto* inst = gui_bridge_get_inst(); + if (!m_node || !m_node->isRunning() || !inst || !gui_bridge_is_uasync_ready()) { + m_cpuLoadPending = false; m_channelList->setCpuLoad(0); return; + } + if (m_cpuLoadPending) return; + m_cpuLoadPending = gui_bridge_request_cpu_load() != 0; + }); + m_cpuLoadTimer->start(333); + DEBUG_DEBUG(DEBUG_CATEGORY_SYS, "desktop CPU indicator polling started: period=333ms"); #ifndef _WIN32 std::signal(SIGINT, unixSignalHandler); @@ -183,6 +195,9 @@ MainWindow::MainWindow(QWidget *parent, DbManager* db, const QString& cfgPath, MainWindow::~MainWindow() { s_mainWindow = nullptr; + m_cpuLoadTimer->stop(); + gui_bridge_set_cpu_load_cb(nullptr); + DEBUG_DEBUG(DEBUG_CATEGORY_SYS, "desktop CPU indicator polling stopped"); if (m_messageNotification) m_messageNotification->dismiss("shutdown"); m_messageNotification.reset(); delete AvatarManager::instance(); @@ -407,6 +422,11 @@ void MainWindow::setupSoundFromConfig() { void MainWindow::setupBridgeCallbacks() { s_mainWindow = this; + gui_bridge_set_cpu_load_cb([](int percent) { + if (!s_mainWindow) return; + s_mainWindow->m_cpuLoadPending = false; + s_mainWindow->m_channelList->setCpuLoad(percent); + }); gui_bridge_set_member_files_response_cb([](const uint8_t* data, int length) { DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "member_files: GUI response route bytes=%d browser=%d", length, (int)(s_mainWindow && s_mainWindow->m_fileBrowser)); @@ -554,6 +574,8 @@ void MainWindow::setupBridgeCallbacks() { gui_bridge_set_network_changed_cb([](int type) { if (!s_mainWindow) return; if (type == 13) { + s_mainWindow->m_cpuLoadPending = false; + s_mainWindow->m_channelList->setCpuLoad(0); s_mainWindow->m_accountList->model()->setRadioState(0, false, false); s_mainWindow->m_messageList->clearDmCallRoute(); s_mainWindow->m_channelList->clearDmPresence(); diff --git a/tools/chatgui/src/mainwindow.h b/tools/chatgui/src/mainwindow.h index 6747f711..2cc23eb5 100644 --- a/tools/chatgui/src/mainwindow.h +++ b/tools/chatgui/src/mainwindow.h @@ -129,6 +129,8 @@ private: QQueue m_autoPlayQueue; QTimer* m_autoPlayTimer = nullptr; + QTimer* m_cpuLoadTimer = nullptr; + bool m_cpuLoadPending = false; QTimer* m_shutdownTimer = nullptr; QSet m_autoPlayDownloading; /* msgIds with download in progress */ diff --git a/tools/chatgui/tests/test_message_events.cpp b/tools/chatgui/tests/test_message_events.cpp index bc62ce6b..22756478 100644 --- a/tools/chatgui/tests/test_message_events.cpp +++ b/tools/chatgui/tests/test_message_events.cpp @@ -1,8 +1,10 @@ /* Формат канального события: live/history сохраняются при передаче в GUI, битые события отбрасываются. */ #include "../transport/gui_bridge.h" #include "../../../lib/debug_config.h" +#include "../../../src/chat/chat_event.h" #include #include +#include struct ReceivedMessage { QString channel; @@ -11,6 +13,13 @@ struct ReceivedMessage { bool guiThread; }; static QList received; +static QList cpuLoads; +static bool cpuOnGuiThread = true; + +static void cpu_received(int percent) { + cpuLoads.append(percent); + cpuOnGuiThread &= QThread::currentThread() == qApp->thread(); +} static void message_received(const char* channel, int length, uint64_t author, int initial_sync) { received.append({QString::fromUtf8(channel, length), author, initial_sync != 0, QThread::currentThread() == qApp->thread()}); @@ -39,7 +48,7 @@ private slots: gui_bridge_init(); gui_bridge_set_msg_received_cb(message_received); } - void cleanupTestCase() { gui_bridge_set_msg_received_cb(nullptr); } + void cleanupTestCase() { gui_bridge_set_msg_received_cb(nullptr); gui_bridge_set_cpu_load_cb(nullptr); } void init() { received.clear(); } void liveAndHistory() { @@ -69,6 +78,26 @@ private slots: QTest::qWait(100); QVERIFY(received.isEmpty()); } + + void cpuLoadDeliveredToGuiThread() { + cpuLoads.clear(); cpuOnGuiThread = true; + gui_bridge_set_cpu_load_cb(cpu_received); + std::thread worker([] { + const uint8_t samples[][2] = {{1, 0}, {1, 50}, {1, 100}, {0, 90}, {1, 101}}; + for (const auto& sample : samples) gui_bridge_post(CHAT_EVT_CPU_LOAD, sample, sizeof(sample)); + gui_bridge_post(CHAT_EVT_CPU_LOAD, nullptr, 0); + }); + worker.join(); + QVERIFY(cpuLoads.isEmpty()); // Worker не вызывает UI напрямую. + QTRY_COMPARE(cpuLoads.size(), 6); + QCOMPARE(cpuLoads, QList({0, 50, 100, 0, 0, 0})); + QVERIFY(cpuOnGuiThread); + gui_bridge_set_cpu_load_cb(nullptr); + const uint8_t sample[] = {1, 75}; + gui_bridge_post(CHAT_EVT_CPU_LOAD, sample, sizeof(sample)); + QCoreApplication::processEvents(); + QCOMPARE(cpuLoads.size(), 6); + } }; QTEST_MAIN(MessageEventTest) diff --git a/tools/chatgui/transport/gui_bridge.h b/tools/chatgui/transport/gui_bridge.h index 86fdab6a..b5337e5f 100644 --- a/tools/chatgui/transport/gui_bridge.h +++ b/tools/chatgui/transport/gui_bridge.h @@ -121,6 +121,11 @@ void gui_bridge_set_db_ready_cb(gui_db_ready_fn cb); typedef void (*gui_status_refresh_fn)(const char* text, int len); void gui_bridge_set_status_refresh_cb(gui_status_refresh_fn cb); +/* Секундное среднее CPU uasync, 0..100; callback выполняется в GUI-потоке. */ +typedef void (*gui_cpu_load_fn)(int percent); +void gui_bridge_set_cpu_load_cb(gui_cpu_load_fn cb); +int gui_bridge_request_cpu_load(void); // 1 = запрос поставлен в очередь; 0 = ядро недоступно/ошибка + /* Callback для завершения загрузки медиа-аттача */ typedef void (*gui_attachment_downloaded_fn)(const char* ch_id, int ch_id_len, int64_t msg_id); void gui_bridge_set_attachment_downloaded_cb(gui_attachment_downloaded_fn cb); diff --git a/tools/chatgui/transport/gui_bridge_impl.cpp b/tools/chatgui/transport/gui_bridge_impl.cpp index 0b989886..e55d8352 100644 --- a/tools/chatgui/transport/gui_bridge_impl.cpp +++ b/tools/chatgui/transport/gui_bridge_impl.cpp @@ -14,6 +14,7 @@ extern "C" { #include "../../../src/routing_layer/topo_group.h" #include "../../../src/member_files/protocol.h" #include "../../../src/chat/chat_member.h" +#include "../../../src/chat/chat_core.h" } /* ── Внутренний объект-приёмник в GUI-потоке ── */ @@ -37,6 +38,7 @@ static gui_auto_connect_status_fn g_auto_connect_status_cb = nullptr; static gui_channel_peers_online_fn g_channel_peers_online_cb = nullptr; static gui_db_ready_fn g_db_ready_cb = nullptr; static gui_status_refresh_fn g_status_refresh_cb = nullptr; +static gui_cpu_load_fn g_cpu_load_cb = nullptr; static gui_attachment_downloaded_fn g_attachment_downloaded_cb = nullptr; static gui_download_progress_fn g_download_progress_cb = nullptr; static gui_nodeinfo_update_fn g_nodeinfo_update_cb = nullptr; @@ -89,6 +91,12 @@ void GuiBridgeReceiver::processPost(int eventType, QByteArray data) { eventType == 16 || eventType == 17 || eventType == 24 || eventType == 25 || eventType == 31 || eventType == 33)) g_network_changed_cb(eventType); switch (eventType) { + case CHAT_EVT_CPU_LOAD: + if (dlen != 2 || d[0] > 1 || d[1] > 100) { + DEBUG_WARN(DEBUG_CATEGORY_SYS, "invalid CPU load response bytes=%d", dlen); + if (g_cpu_load_cb) g_cpu_load_cb(0); + } else if (g_cpu_load_cb) g_cpu_load_cb(d[0] ? d[1] : 0); + break; case CHAT_EVT_MSG_SUBMITTED: if (dlen != 9 || d[8] > 1) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "invalid submission result bytes=%d", dlen); break; } if (g_msg_submitted_cb) g_msg_submitted_cb(d, dlen); @@ -497,6 +505,19 @@ void gui_bridge_set_status_refresh_cb(gui_status_refresh_fn cb) { g_status_refresh_cb = cb; } +void gui_bridge_set_cpu_load_cb(gui_cpu_load_fn cb) { + g_cpu_load_cb = cb; +} + +int gui_bridge_request_cpu_load(void) { + if (!g_ua || !g_inst) return 0; + auto* task = (struct posted_task*)u_calloc(1, sizeof(struct posted_task)); + if (!task) { DEBUG_ERROR(DEBUG_CATEGORY_SYS, "CPU load request allocation failed"); return 0; } + task->callback = chat_core_collect_cpu_load_trampoline; task->arg = g_inst; + uasync_post_reserved(g_ua, task); + return 1; +} + void gui_bridge_set_attachment_downloaded_cb(gui_attachment_downloaded_fn cb) { g_attachment_downloaded_cb = cb; }