Browse Source

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.
master
evgeny 2 days ago
parent
commit
5a9e61164b
  1. 10
      doc/stream.txt
  2. 573
      doc/tasks.md
  3. 149
      lib/u_async.c
  4. 24
      lib/u_async.h
  5. 27
      lib/u_async_doc.md
  6. 3
      src/chat/chat_core.h
  7. 1
      src/chat/chat_event.c
  8. 1
      src/chat/chat_event.h
  9. 6
      src/chat/chat_headless_control.c
  10. 20
      src/chat/chat_status.c
  11. 4
      tests/Makefile.am
  12. 238
      tests/test_uasync_load.c
  13. 3
      tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt
  14. 2
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
  15. 39
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/CpuLoadIndicator.kt
  16. 9
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt
  17. 32
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
  18. 13
      tools/chatgui-android/jni_bridge/android_jni_bridge.c
  19. 41
      tools/chatgui/src/channellist.cpp
  20. 3
      tools/chatgui/src/channellist.h
  21. 22
      tools/chatgui/src/mainwindow.cpp
  22. 2
      tools/chatgui/src/mainwindow.h
  23. 31
      tools/chatgui/tests/test_message_events.cpp
  24. 5
      tools/chatgui/transport/gui_bridge.h
  25. 21
      tools/chatgui/transport/gui_bridge_impl.cpp

10
doc/stream.txt

@ -0,0 +1,10 @@
Модуль стриминга. Архитектурные нюансы.
- реализуем в utun отдельным каталогом: src/stream
- создаем отдельные списки подключений (ncd) для стриминга.
главная цель: узлы ищут более оптимальные пути доставки контента от источника до себя.
Надо учитывать что узлы могут быть перегружены трафиком. 2 сигнала перегрузки: очередь отправки и поток uasync Не справляется с нагрузкой.
надо сделать замер нагрузки на uasync.
- узлы учавствующие в стриминге анонсируют себя аналогично как сделано в рации.
У модуля свой фоновый опросчик связности и поиска хороших линков - он пробует связь с узлами стриминна кроме мобильных, и обновляет их rtt и флаг прямой доступности.

573
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_<channel>, вернуть его через 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) его не трогает.

149
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);
}
}

24
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);

27
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`.

3
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);

1
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);

1
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);

6
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);

20
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);

4
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)

238
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 <errno.h>
#include <math.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <time.h>
#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;
}

3
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") },

2
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

39
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)
}
}

9
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<DmConversation> = 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<String?>(null) }
var optionsChannel by remember { mutableStateOf<Channel?>(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),

32
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<Boolean> = _isStuck
private val _cpuLoad = MutableStateFlow(0)
val cpuLoad: StateFlow<Int> = _cpuLoad
private val _cpuLoadVisible = MutableStateFlow(false)
private var cpuLoadPending = false
fun setCpuLoadVisible(visible: Boolean) { _cpuLoadVisible.value = visible }
private val _nodeStatuses = MutableStateFlow<Map<Long, NodeStatus>>(emptyMap())
val nodeStatuses: StateFlow<Map<Long, NodeStatus>> = _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

13
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;

41
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;

3
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<QString, bool> m_dmOnline;
QTimer* m_dmPresenceTimer;
CpuLoadIndicator* m_cpuLoad;
};

22
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();

2
tools/chatgui/src/mainwindow.h

@ -129,6 +129,8 @@ private:
QQueue<AutoPlayItem> m_autoPlayQueue;
QTimer* m_autoPlayTimer = nullptr;
QTimer* m_cpuLoadTimer = nullptr;
bool m_cpuLoadPending = false;
QTimer* m_shutdownTimer = nullptr;
QSet<int64_t> m_autoPlayDownloading; /* msgIds with download in progress */

31
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 <QtTest>
#include <cstring>
#include <thread>
struct ReceivedMessage {
QString channel;
@ -11,6 +13,13 @@ struct ReceivedMessage {
bool guiThread;
};
static QList<ReceivedMessage> received;
static QList<int> 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<int>({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)

5
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);

21
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;
}

Loading…
Cancel
Save