From bc78e0889a04567813a323b7e3afa570fa352b4d Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 27 Oct 2026 23:18:49 +0100 Subject: [PATCH] Route MainWindow radio invitations through owned commands and test them --- tools/chatgui/CMakeLists.txt | 14 ++ tools/chatgui/src/mainwindow.cpp | 27 +-- tools/chatgui/tests/test_radio_commands.cpp | 218 ++++++++++++++++++++ 3 files changed, 242 insertions(+), 17 deletions(-) create mode 100644 tools/chatgui/tests/test_radio_commands.cpp diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 8ea7c4b9..1c54f2cc 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -580,6 +580,20 @@ endif() add_test(NAME test_settings_save COMMAND test_settings_save) set_tests_properties(test_settings_save PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;XDG_SESSION_TYPE=x11" TIMEOUT 60) +add_executable(test_radio_commands tests/test_radio_commands.cpp ${DM_DELETION_SOURCES}) +add_dependencies(test_radio_commands chatgui_translations) +target_include_directories(test_radio_commands PRIVATE $) +target_compile_definitions(test_radio_commands PRIVATE $) +target_link_libraries(test_radio_commands PRIVATE ${DM_DELETION_LIBRARIES} ${QT_TEST_LIB}) +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + target_compile_definitions(test_radio_commands PRIVATE UTUN_TEST_RADIO_HOOKS) + foreach(symbol u_malloc_impl u_calloc_impl u_free_impl radio_call_trampoline ma_context_init) + target_link_options(test_radio_commands PRIVATE "-Wl,--wrap=${symbol}") + endforeach() +endif() +add_test(NAME test_radio_commands COMMAND test_radio_commands) +set_tests_properties(test_radio_commands PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;XDG_SESSION_TYPE=x11" TIMEOUT 30) + add_executable(test_group_edits tests/test_group_edits.cpp ${DM_DELETION_SOURCES}) add_dependencies(test_group_edits chatgui_translations) target_include_directories(test_group_edits PRIVATE $) diff --git a/tools/chatgui/src/mainwindow.cpp b/tools/chatgui/src/mainwindow.cpp index dbd62227..ddc0f628 100644 --- a/tools/chatgui/src/mainwindow.cpp +++ b/tools/chatgui/src/mainwindow.cpp @@ -811,20 +811,13 @@ void MainWindow::showMemberFiles(const QString& channel, quint64 owner) { m_fileBrowser->setChannel(channel, owner); m_fileBrowser->show(); m_fileBrowser->raise(); m_fileBrowser->activateWindow(); } -/* Все команды рации идут в поток ядра; GUI не читает живые таблицы BGP. */ -static void postRadioCall(quint64 id, int action, quint64 group = 0, quint64 peer = 0) { - auto* inst = gui_bridge_get_inst(); - if (!inst) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "desktop radio call without running core"); return; } - auto* req = static_cast(u_calloc(1, sizeof(radio_call_request))); - if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "desktop radio call request allocation failed"); return; } - req->inst = inst; req->id = id; req->group = group; req->peer = peer; req->action = action; - gui_bridge_post_uasync_fn(radio_call_trampoline, req); -} - +/* GUI передаёт мосту значения; ядро и время жизни запроса выбирает мост. */ void MainWindow::onRadioCallRequested(const QString& channel, quint64 peer) { if (m_radioCallWindow) { m_radioCallWindow->show(); m_radioCallWindow->raise(); return; } quint64 id = QRandomGenerator::global()->generate64(); - postRadioCall(id ? id : 1, RADIO_CALL_START, channel.toULongLong(), peer); + if (gui_bridge_radio_call(GUI_RADIO_CALL_START, id ? id : 1, channel.toULongLong(), peer) < 0) + DEBUG_WARN(DEBUG_CATEGORY_RADIO, "desktop radio call start was not queued channel=%s peer=%llu", + qPrintable(channel), (unsigned long long)peer); } /* Один диалог на незавершённое приглашение; принятая рация живёт своим обычным циклом. */ @@ -878,7 +871,7 @@ void MainWindow::onRadioCallEvent(const uint8_t* data, int len) { m_radioCallWindow = nullptr; } if (m_radioCallId == id) return; - if (m_radioCallId || !m_activeCalls.isEmpty()) { postRadioCall(id, RADIO_CALL_REJECT_BUSY); return; } + if (m_radioCallId || !m_activeCalls.isEmpty()) { gui_bridge_radio_call(GUI_RADIO_CALL_REJECT_BUSY, id, 0, 0); return; } QString name = m_db->getDisplayName(peer); if (name.isEmpty()) name = QStringLiteral("0x%1").arg(peer, 16, 16, QChar('0')); QString channelName = channel; @@ -894,11 +887,11 @@ void MainWindow::onRadioCallEvent(const uint8_t* data, int len) { connect(box, &QMessageBox::buttonClicked, this, [this, id, channel, accept](QAbstractButton* button) { if (m_radioCallId != id) return; m_radioCallAnswered = true; - if (button != accept) { postRadioCall(id, RADIO_CALL_DECLINE); return; } + if (button != accept) { gui_bridge_radio_call(GUI_RADIO_CALL_DECLINE, id, 0, 0); return; } SoundManager::instance()->stopLoop(); m_channelList->selectChannel(channel); bool ready = m_currentChannelId == channel && m_messageList->enableRadio(); - postRadioCall(id, ready ? RADIO_CALL_ACCEPT : RADIO_CALL_FAIL); + gui_bridge_radio_call(ready ? GUI_RADIO_CALL_ACCEPT : GUI_RADIO_CALL_FAIL, id, 0, 0); }); } else if (state == RADIO_CALL_WAIT_DECISION) { box->setInformativeText(QCoreApplication::translate("ChatGui", "Subscriber is on a phone call")); @@ -906,10 +899,10 @@ void MainWindow::onRadioCallEvent(const uint8_t* data, int len) { box->addButton(QCoreApplication::translate("ChatGui", "End call"), QMessageBox::RejectRole); connect(box, &QMessageBox::buttonClicked, this, [this, id, proceed](QAbstractButton* button) { if (m_radioCallId != id || button != proceed) return; + if (gui_bridge_radio_call(GUI_RADIO_CALL_CONTINUE, id, 0, 0) < 0) return; m_radioCallAnswered = true; m_radioCallId = 0; DEBUG_INFO(DEBUG_CATEGORY_RADIO, "desktop caller overrides phone busy id=%016llx", (unsigned long long)id); - postRadioCall(id, RADIO_CALL_CONTINUE); }); } else { box->addButton(QMessageBox::Cancel); @@ -919,7 +912,7 @@ void MainWindow::onRadioCallEvent(const uint8_t* data, int len) { if (m_radioCallId == id && !m_radioCallAnswered) { m_radioCallAnswered = true; SoundManager::instance()->stopLoop(); - postRadioCall(id, incoming ? RADIO_CALL_DECLINE : RADIO_CALL_CANCEL); + gui_bridge_radio_call(incoming ? GUI_RADIO_CALL_DECLINE : GUI_RADIO_CALL_CANCEL, id, 0, 0); } }); box->show(); box->raise(); box->activateWindow(); @@ -927,7 +920,7 @@ void MainWindow::onRadioCallEvent(const uint8_t* data, int len) { if (incoming) { if (m_trayIcon && (!isVisible() || isMinimized())) m_trayIcon->showMessage(QCoreApplication::translate("ChatGui", "Radio call"), box->text()); - postRadioCall(id, RADIO_CALL_READY); + gui_bridge_radio_call(GUI_RADIO_CALL_READY, id, 0, 0); } } diff --git a/tools/chatgui/tests/test_radio_commands.cpp b/tools/chatgui/tests/test_radio_commands.cpp new file mode 100644 index 00000000..89e8a219 --- /dev/null +++ b/tools/chatgui/tests/test_radio_commands.cpp @@ -0,0 +1,218 @@ +/* Настоящие MainWindow/bridge/radio/uasync: отказ, отмена, OOM и привязка команды к владельцу очереди. + * Native trampoline исполняется без подмены; пустой каталог CHAT даёт штатный FINISHED/UNAVAILABLE для START. */ +#include "../src/mainwindow.h" +#include "../transport/gui_bridge.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +extern "C" { +#include "utun_instance.h" +#include "chat/chat_event.h" +#include "radio/radio_call.h" +#include "mem.h" +#include "debug_config.h" +} + +static UTUN_INSTANCE* executingCore = nullptr; +static bool bindingValid = true; +static QList observed; + +#ifdef UTUN_TEST_RADIO_HOOKS +#include "miniaudio.h" +static std::atomic watching{false}; +static std::atomic failAllocation{0}; +static std::mutex allocationMutex; +static std::map outstanding; +static void track(void* pointer, const char* source) { + if (!pointer || !watching || (!strstr(source, "/gui_bridge_impl.cpp:") && !strstr(source, "/mainwindow.cpp:"))) return; + std::lock_guard lock(allocationMutex); outstanding.emplace(pointer, source); +} +extern "C" void* __real_u_malloc_impl(uint32_t, const char*); +extern "C" void* __wrap_u_malloc_impl(uint32_t bytes, const char* source) { + void* pointer = __real_u_malloc_impl(bytes, source); track(pointer, source); return pointer; +} +extern "C" void* __real_u_calloc_impl(uint32_t, uint32_t, const char*); +extern "C" void* __wrap_u_calloc_impl(uint32_t count, uint32_t bytes, const char* source) { + if (failAllocation && strstr(source, "/gui_bridge_impl.cpp:") && --failAllocation == 0) { + DEBUG_WARN(DEBUG_CATEGORY_RADIO, "[INJECT] radio command allocation failure at %s", source); return nullptr; + } + void* pointer = __real_u_calloc_impl(count, bytes, source); track(pointer, source); return pointer; +} +extern "C" void __real_u_free_impl(void*, const char*); +extern "C" void __wrap_u_free_impl(void* pointer, const char* source) { + { std::lock_guard lock(allocationMutex); outstanding.erase(pointer); } + __real_u_free_impl(pointer, source); +} +extern "C" void __real_radio_call_trampoline(void*); +extern "C" void __wrap_radio_call_trampoline(void* arg) { + auto* request = static_cast(arg); + bindingValid &= request->inst == executingCore; observed.append(*request); + __real_radio_call_trampoline(arg); +} +// Настоящий mixer остаётся включённым; null backend устраняет зависимость от аппаратных устройств. +extern "C" ma_result __real_ma_context_init(const ma_backend*, ma_uint32, const ma_context_config*, ma_context*); +extern "C" ma_result __wrap_ma_context_init(const ma_backend*, ma_uint32, const ma_context_config* config, ma_context* context) { + const ma_backend backend = ma_backend_null; + return __real_ma_context_init(&backend, 1, config, context); +} +#endif + +struct RadioCore { + QTemporaryDir directory; + UASYNC* ua = uasync_create(); + UTUN_INSTANCE* inst = nullptr; + unsigned results = 0; + bool resultValid = true; + ~RadioCore() { + gui_bridge_set_inst(nullptr); gui_bridge_set_uasync(nullptr); + if (ua) uasync_mark_stopped(ua); + if (inst) utun_instance_destroy(inst); + if (ua) uasync_destroy(ua, 0); + } + bool start() { + if (!ua || !directory.isValid()) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "fixture: no uasync/config directory"); return false; + } + const QByteArray config = "[global]\nauto_sockets=no\n[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\n" + "member_files_enabled=0\nlocal_invites_enabled=0\nlocal_broadcasts_enabled=0\n[ntp]\nenabled=no\n"; + const QString path = directory.filePath("utun.cfg"); QFile file(path); + if (!file.open(QIODevice::WriteOnly) || file.write(config) != config.size()) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "fixture: config write failed: %s", qPrintable(file.errorString())); return false; + } + file.close(); inst = utun_instance_create(ua, path.toUtf8().constData()); + if (!inst || utun_core_start(inst) || chat_service_start(inst)) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "fixture: core/chat startup failed"); return false; + } + inst->test_user_ptr = this; + chat_event_set_handler(inst, [](UTUN_INSTANCE* instance, int type, const uint8_t* data, int len) { + if (type != CHAT_EVT_RADIO_CALL) return; + auto* core = static_cast(instance->test_user_ptr); ++core->results; + core->resultValid &= len == 27 && data[24] == RADIO_CALL_FINISHED && data[25] == RADIO_CALL_UNAVAILABLE && !data[26]; + }); + attach(); poll(); return true; + } + void attach() { uasync_mark_running(ua); gui_bridge_set_uasync(ua); gui_bridge_set_inst(inst); } + void poll() { executingCore = inst; uasync_poll(ua, 0); executingCore = nullptr; } +}; +static unsigned pending(UASYNC* ua) { + unsigned count = 0; + for (auto* task = ua->posted_tasks_head; task; task = task->next) ++count; + return count; +} + +class RadioCommandsTest : public QObject { + Q_OBJECT +private slots: + void initTestCase() { + debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); gui_bridge_init(); + debug_set_category_level(DEBUG_CATEGORY_RADIO, DEBUG_LEVEL_DEBUG); + debug_set_category_level(DEBUG_CATEGORY_GENERAL, DEBUG_LEVEL_DEBUG); + } + void init() { + observed.clear(); bindingValid = true; +#ifdef UTUN_TEST_RADIO_HOOKS + watching = false; failAllocation = 0; +#endif + } + void cleanup() { +#ifdef UTUN_TEST_RADIO_HOOKS + failAllocation = 0; +#endif + gui_bridge_set_inst(nullptr); gui_bridge_set_uasync(nullptr); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); +#ifdef UTUN_TEST_RADIO_HOOKS + watching = false; + std::lock_guard lock(allocationMutex); + for (const auto& item : outstanding) + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "Unfreed radio command ptr=%p source=%s", item.first, item.second); + QCOMPARE(outstanding.size(), size_t(0)); +#endif + } + void lifecycle_data() { + QTest::addColumn("action"); QTest::addColumn("scenario"); QTest::addColumn("window"); + QStringList scenarios{"unavailable", "stopped", "cancelled", "executed"}; +#ifdef UTUN_TEST_RADIO_HOOKS + scenarios << "request-oom" << "queue-oom"; +#endif + for (int action = GUI_RADIO_CALL_START; action <= GUI_RADIO_CALL_CONTINUE; ++action) + for (const auto& scenario : scenarios) + QTest::newRow(qPrintable(QString("bridge-%1-%2").arg(action).arg(scenario))) << action << scenario << false; + for (const auto& scenario : scenarios) + QTest::newRow(qPrintable("window-" + scenario)) << int(GUI_RADIO_CALL_START) << scenario << true; + QTest::newRow("window-destroyed-before-execution") << int(GUI_RADIO_CALL_START) << QString("destroyed") << true; + } + void lifecycle() { + QFETCH(int, action); QFETCH(QString, scenario); QFETCH(bool, window); + RadioCore core; QVERIFY(core.start()); + std::unique_ptr form; + if (window) form = std::make_unique(nullptr, nullptr, QString()); // UtunNode остаётся владельцу fixture. + if (scenario == "unavailable") gui_bridge_set_inst(nullptr); + if (scenario == "stopped") uasync_mark_stopped(core.ua); +#ifdef UTUN_TEST_RADIO_HOOKS + watching = true; failAllocation = scenario == "request-oom" ? 1 : scenario == "queue-oom" ? 2 : 0; +#endif + const bool accepted = scenario == "executed" || scenario == "cancelled" || scenario == "destroyed"; + const uint64_t group = action == GUI_RADIO_CALL_START ? 7654321 : 0, peer = action == GUI_RADIO_CALL_START ? 42 : 0; + if (form) form->onRadioCallRequested(QString::number(group), peer); + else QCOMPARE(gui_bridge_radio_call(static_cast(action), 73, group, peer), accepted ? 0 : -1); + QCOMPARE(pending(core.ua), accepted ? 1u : 0u); + if (scenario == "destroyed") form.reset(); + const bool executes = scenario == "executed" || scenario == "destroyed"; + if (executes) core.poll(); + else gui_bridge_set_inst(nullptr); + QCOMPARE(pending(core.ua), 0u); QVERIFY(bindingValid); + QCOMPARE(core.results, executes && action == GUI_RADIO_CALL_START ? 1u : 0u); QVERIFY(core.resultValid); +#ifdef UTUN_TEST_RADIO_HOOKS + QCOMPARE(failAllocation.load(), 0); QCOMPARE(observed.size(), executes ? 1 : 0); + if (executes) { + static const int nativeActions[]{RADIO_CALL_START, RADIO_CALL_READY, RADIO_CALL_ACCEPT, RADIO_CALL_DECLINE, + RADIO_CALL_CANCEL, RADIO_CALL_FAIL, RADIO_CALL_REJECT_BUSY, RADIO_CALL_CONTINUE}; + const auto& request = observed.front(); QCOMPARE(request.inst, core.inst); + QCOMPARE(request.action, nativeActions[action]); QCOMPARE(request.group, group); QCOMPARE(request.peer, peer); + if (window) QVERIFY(request.id); + else QCOMPARE(request.id, uint64_t(73)); + } +#endif + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "Radio command verified: window=%d action=%d scenario=%s results=%u", + window, action, qPrintable(scenario), core.results); + } + void invalidArguments() { + RadioCore core; QVERIFY(core.start()); const auto baseline = u_get_allocated_count(); + QCOMPARE(gui_bridge_radio_call(static_cast(99), 1, 0, 0), -1); + QCOMPARE(gui_bridge_radio_call(GUI_RADIO_CALL_START, 0, 1, 1), -1); + QCOMPARE(gui_bridge_radio_call(GUI_RADIO_CALL_START, 1, 0, 1), -1); + QCOMPARE(gui_bridge_radio_call(GUI_RADIO_CALL_START, 1, 1, 0), -1); + QCOMPARE(gui_bridge_radio_call(GUI_RADIO_CALL_CANCEL, 0, 0, 0), -1); + QCOMPARE(gui_bridge_radio_call(GUI_RADIO_CALL_CANCEL, 1, 1, 0), -1); + QCOMPARE(gui_bridge_radio_call(GUI_RADIO_CALL_CANCEL, 1, 0, 1), -1); + QCOMPARE(pending(core.ua), 0u); QCOMPARE(u_get_allocated_count(), baseline); + } + void concurrentRebind() { + RadioCore first, second; QVERIFY(first.start()); gui_bridge_set_inst(nullptr); QVERIFY(second.start()); +#ifdef UTUN_TEST_RADIO_HOOKS + watching = true; +#endif + std::thread producer([] { + for (uint64_t id = 1; id <= 96; ++id) gui_bridge_radio_call(GUI_RADIO_CALL_CANCEL, id, 0, 0); + }); + for (unsigned i = 0; i < 64; ++i) { + gui_bridge_set_inst(nullptr); auto& core = i % 2 ? first : second; core.attach(); core.poll(); + } + producer.join(); gui_bridge_set_inst(nullptr); first.attach(); + QCOMPARE(gui_bridge_radio_call(GUI_RADIO_CALL_START, 1000, 7654321, 42), 0); first.poll(); + QVERIFY(bindingValid); QCOMPARE(pending(first.ua), 0u); QCOMPARE(pending(second.ua), 0u); + QCOMPARE(first.results, 1u); QCOMPARE(second.results, 0u); QVERIFY(first.resultValid); +#ifdef UTUN_TEST_RADIO_HOOKS + QVERIFY(!observed.isEmpty()); QCOMPARE(observed.back().inst, first.inst); QCOMPARE(observed.back().id, uint64_t(1000)); +#endif + } +}; + +QTEST_MAIN(RadioCommandsTest) +#include "test_radio_commands.moc"