# Задачи по проекту Сюда пишется список задач с короткой аннотацией. Если задача большая и имеет ТЗ - то ТЗ оформляется отдельным файлом, а сюда помещается аннотация и ссылка на ТЗ. ## Текущая задача [+] **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_vad_enabled` (ручной PTT не меняется, при зажатой кнопке — override). Сервисный слой `radio_audio.c`: ресемплер 48k→16k моно (3-tap box + 3:1, стерео микшируется), накопитель окон 512, фильтр-гистерезис + машина состояний (`src/radio/radio_vad.h`, чистый, юнит-тестируемый): старт при подтверждении 2 окон (~64мс) выше порога `radio_vad_threshold`, стоп при тишине `radio_vad_hangover_ms` (200мс) или чужом разговоре (busy = RX-кадр <200мс). История захвата (pre-roll) удалена; передаётся текущий PCM. Компрессор в 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()`. [+] **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` (интеграция). Остались задачи ниже. [ ] **Звонок (P2P аудио)** — голос 1:1, из CHAT-группы или p2p-чата (DM). Отдельный каталог `src/call/`. Сигналинг + conn_mgr + аудиопоток (Opus) с backpressure (локальный буфер отправки при переполнении роутера) + адаптивный джиттер-буфер (SoundTouch, ускорение до 1.6x) + тоны (глитч/завершение) + watchdog 20с. 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`. Дедуп-ветка «conn already in senders_list» (строки 579–587) делает `return` БЕЗ повторной отправки `topo_group_send_table_request()`. **Зачем дедуп:** тот же `ETCP_CONN` стреляет `ETCP_CONN_STATUS_UP` дважды (UDP-линк, затем TCP-линк). Повторная обработка задваивает `active_conn_count`, из-за чего переподключение не стартует. Поэтому `return` в дедупе — правильный. **В чём гонка:** REQUEST_TABLE — это то, что заставляет пира отдать свою таблицу (NODEINFO/узлы группы). Сценарий «разнесённого старта»: 1. A↔C соединение поднято. A шлёт C `REQUEST_TABLE` для группы G (`topo_group_new_conn` → `topo_group_send_table_request`, `topo_group.c:602`). 2. У C группа G ещё **не создана** (создаётся позже, по мере загрузки каналов/синка). C получает `REQUEST_TABLE`, но `topo_group_handle_request_table` не находит G → дропает запрос. 3. C наконец создаёт G (`topo_groups_create_group` → обход connections → `topo_group_new_conn(G, conn_A)` на стороне C) → C шлёт A свой `REQUEST_TABLE`. A отвечает своей таблицей → **C узнаёт A**. 4. Но A больше не шлёт C `REQUEST_TABLE` → **A так и не узнаёт C** в группе G. Итог: при разнесённом старте A не видит C в CHAT-группе (односторонняя видимость). **Фикс (предполагаемый):** в дедуп-ветке не просто `return`, а предварительно идемпотентно `topo_group_send_table_request(group, conn)`: ```c if (((struct TOPO_GROUP_CONN_ITEM*)se->data)->conn == conn) { topo_group_send_table_request(group, conn); /* повторно, идемпотентно */ DEBUG_INFO(..., "conn already in senders_list, re-request table (%s)", conn->log_name); return; } ``` `topo_group_send_table_request` не ведёт состояния — просто шлёт REQUEST_TABLE, пир отвечает снимком таблицы (идемпотентно). Лишний дубль таблицы безвреден. **Замечание:** в однопоточном `test_dm_e2e` (и `test_chat_join_e2e`) из-за детерминированного порядка группа успевает создаться до прихода REQUEST_TABLE — поэтому тест проходит. Реальная гонка остаётся для асинхронных сетей. Отдельный воспроизводимый тест для этой гонки пока не написан. [ ] **«packet undecryptable» на линках C** (вторичное). A, узнав B через BGP, через `topo_group_connect` пытается поднять прямой A↔B линк (`seskey 4777737b`), но B не настроен принимать A → crypto-шум каждые ~1с. Разобраться с авто-подключением (не пытаться соединяться с узлами, для которых нет `[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) его не трогает.