Files
utun2/tools/chatgui/tests/test_call_commands.cpp
T

342 lines
18 KiB
C++

/* Настоящие CallWindow/bridge/uasync: владение call-командами при отказе, отмене и исполнении.
* Linux hooks наблюдают границу native trampoline и затем вызывают его без подмены освобождения payload. */
#include "../src/callwindow.h"
#include "../src/call_camera_capture.h"
#include "../transport/gui_bridge.h"
#include <QtTest>
#include <QLabel>
#include <QPushButton>
#include <QPointer>
#include <QScopeGuard>
#include <atomic>
#include <thread>
#include <cstring>
extern "C" {
#include "utun_instance.h"
#include "transport_layer/etcp.h"
#include "chat/chat_core.h"
#include "call/call.h"
#include "call/call_priv.h"
#include "call/call_proto.h"
#include "ntp_time.h"
#include "mem.h"
#include "debug_config.h"
}
struct Observation {
UTUN_INSTANCE* instance;
uint64_t id, peer;
int action;
QByteArray channel;
};
static QList<Observation> observed;
static UTUN_INSTANCE* executingCore = nullptr;
static bool bindingValid = true;
#ifdef UTUN_TEST_CALL_HOOKS
static std::atomic<int> failAllocation{0};
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 size, const char* where) {
if (failAllocation && strstr(where, "/gui_bridge_impl.cpp:") && --failAllocation == 0) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "[INJECT] call command allocation failure at %s", where); return nullptr;
}
return __real_u_calloc_impl(count, size, where);
}
extern "C" void __real_call_start_trampoline(void*);
extern "C" void __wrap_call_start_trampoline(void* arg) {
auto* request = static_cast<call_start_arg*>(arg);
bindingValid &= request->inst == executingCore;
observed.append({request->inst, request->call_id, request->peer_node_id, -1, request->ch_id});
__real_call_start_trampoline(arg);
}
static void observeAction(void* arg, int action, void (*native)(void*)) {
auto* request = static_cast<call_id_arg*>(arg);
bindingValid &= request->inst == executingCore;
observed.append({request->inst, request->call_id, 0, action, {}}); native(arg);
}
extern "C" void __real_call_accept_trampoline(void*);
extern "C" void __wrap_call_accept_trampoline(void* arg) { observeAction(arg, GUI_CALL_ACCEPT, __real_call_accept_trampoline); }
extern "C" void __real_call_decline_trampoline(void*);
extern "C" void __wrap_call_decline_trampoline(void* arg) { observeAction(arg, GUI_CALL_DECLINE, __real_call_decline_trampoline); }
extern "C" void __real_call_hangup_trampoline(void*);
extern "C" void __wrap_call_hangup_trampoline(void* arg) { observeAction(arg, GUI_CALL_HANGUP, __real_call_hangup_trampoline); }
extern "C" void __real_call_continue_trampoline(void*);
extern "C" void __wrap_call_continue_trampoline(void* arg) { observeAction(arg, GUI_CALL_CONTINUE, __real_call_continue_trampoline); }
#endif
struct CommandCore {
UASYNC* ua = uasync_create();
UTUN_INSTANCE instance{};
~CommandCore() {
gui_bridge_set_inst(nullptr); gui_bridge_set_uasync(nullptr);
if (ua) { uasync_mark_stopped(ua); uasync_destroy(ua, 0); }
}
void attach() {
instance.ua = ua; uasync_mark_running(ua); gui_bridge_set_uasync(ua); gui_bridge_set_inst(&instance);
}
void poll() { executingCore = &instance; uasync_poll(ua, 0); executingCore = nullptr; }
};
static int pending(UASYNC* ua) {
int count = 0;
for (auto* task = ua->posted_tasks_head; task; task = task->next) ++count;
return count;
}
struct IncomingEvents {
QPointer<CallWindow> window;
unsigned incoming = 0, ended = 0, declined = 0;
};
static void incomingEvent(UTUN_INSTANCE* instance, int type, const uint8_t*, int) {
auto* events = static_cast<IncomingEvents*>(instance->test_user_ptr);
if (type == CHAT_EVT_CALL_INCOMING) ++events->incoming;
if (type == CHAT_EVT_CALL_DECLINED) ++events->declined;
if (type == CHAT_EVT_CALL_ENDED) {
++events->ended;
if (events->window) events->window->deleteLater(); // Как MainWindow::closeCallWindow.
}
}
/* Граница сервиса после router: настоящий call_handle_invite создаёт сессию и её таймеры, внешнего пира нет. */
static bool deliverInvite(UTUN_INSTANCE* instance, uint64_t group, uint64_t id) {
auto callback = instance->router_bindings.callbacks[ETCP_RT_ID_CALL];
if (!callback) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "fixture: call service is not bound"); return false; }
auto* entry = queue_entry_new(0);
if (!entry) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "fixture: invite entry allocation failed"); return false; }
entry->len = ROUTER_SVC_PAYLOAD_OFF + sizeof(call_pkt_invite);
entry->dgram = static_cast<uint8_t*>(u_calloc(1, entry->len));
if (!entry->dgram) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "fixture: invite payload allocation failed"); queue_entry_free(entry); return false;
}
entry->dgram[0] = ETCP_RT_ID_CALL;
entry->dgram[ROUTER_SVC_FLAGS_OFF] = ROUTER_FLAG_SIGNED | ROUTER_FLAG_ENCRYPTED;
const uint64_t peer = 42;
memcpy(entry->dgram + ROUTER_SVC_SRC_OFF, &peer, sizeof(peer));
memcpy(entry->dgram + ROUTER_SVC_DST_OFF, &instance->node_id, sizeof(instance->node_id));
memcpy(entry->dgram + ROUTER_SVC_GROUP_OFF, &group, sizeof(group));
call_pkt_invite packet{}; packet.subcmd = CALL_SUBCMD_INVITE; packet.call_id = id; packet.group_id = group;
packet.num_tracks = 1; packet.expires_us = ntp_time_get_us(instance) + 2000000;
packet.tracks[0] = {CALL_TRACK_AUDIO, CALL_CODEC_OPUS, 48000, 1, 32000};
memcpy(entry->dgram + ROUTER_SVC_PAYLOAD_OFF, &packet, sizeof(packet));
ETCP_CONN connection{}; connection.instance = instance; callback(&connection, entry);
auto* session = call_session_find(instance->call, id);
return session && session->state == CALL_ST_RINGING && call_busy(instance);
}
class CallCommandsTest : public QObject {
Q_OBJECT
static void scenarios() {
QTest::addColumn<QString>("scenario");
for (const char* value : {"unavailable", "stopped", "cancelled", "executed"}) QTest::newRow(value) << QString(value);
#ifdef UTUN_TEST_CALL_HOOKS
QTest::newRow("request-allocation") << QString("request-allocation");
QTest::newRow("post-allocation") << QString("post-allocation");
#endif
}
static void prepare(CommandCore& core, const QString& scenario) {
core.attach();
if (scenario == "unavailable") gui_bridge_set_inst(nullptr);
if (scenario == "stopped") uasync_mark_stopped(core.ua);
#ifdef UTUN_TEST_CALL_HOOKS
failAllocation = scenario == "request-allocation" ? 1 : scenario == "post-allocation" ? 2 : 0;
#endif
}
private slots:
void initTestCase() {
debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN);
debug_set_category_level(DEBUG_CATEGORY_CALL, DEBUG_LEVEL_DEBUG);
debug_set_category_level(DEBUG_CATEGORY_GENERAL, DEBUG_LEVEL_DEBUG); gui_bridge_init();
{ CallWindow warmup; } // Singleton audio engine создаётся до подсчёта командных аллокаций.
}
void cleanup() {
#ifdef UTUN_TEST_CALL_HOOKS
failAllocation = 0;
#endif
gui_bridge_set_inst(nullptr); gui_bridge_set_uasync(nullptr);
observed.clear(); bindingValid = true;
QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete);
}
void windowAction_data() {
QTest::addColumn<QString>("scenario"); QTest::addColumn<QString>("action");
QStringList failures{"unavailable", "stopped", "cancelled", "executed"};
#ifdef UTUN_TEST_CALL_HOOKS
failures << "request-allocation" << "post-allocation";
#endif
for (const auto& failure : failures)
for (const char* action : {"onAccept", "onDecline", "onHangup", "continue", "close", "closeIncoming"})
QTest::newRow(qPrintable(failure + '-' + action)) << failure << QString(action);
}
void windowAction() {
QFETCH(QString, scenario); QFETCH(QString, action);
const size_t before = u_get_allocated_count();
{
CommandCore core; QVERIFY(core.ua);
CallWindow window; window.setCallId(0x123456789);
const bool closing = action.startsWith("close");
const auto state = action == "onAccept" || action == "onDecline" || action == "closeIncoming" ? CallWindow::Incoming :
action == "continue" ? CallWindow::WaitDecision : CallWindow::Outgoing;
window.setState(state); prepare(core, scenario);
const size_t ready = u_get_allocated_count();
if (action == "continue") {
QPushButton* button = nullptr;
for (auto* candidate : window.findChildren<QPushButton*>())
if (candidate->text() == QCoreApplication::translate("ChatGui", "Call anyway")) button = candidate;
QVERIFY(button); button->click();
} else if (closing) { window.show(); window.close(); QCOMPARE(window.state(), CallWindow::Ended); }
else QVERIFY(QMetaObject::invokeMethod(&window, qPrintable(action), Qt::DirectConnection));
const bool accepted = scenario == "executed" || scenario == "cancelled";
QCOMPARE(pending(core.ua), accepted ? 1 : 0);
if (!accepted && !closing) {
QCOMPARE(window.state(), state);
bool message = false;
for (auto* label : window.findChildren<QLabel*>())
message |= label->text() == QCoreApplication::translate("ChatGui", "Could not send the call command");
QVERIFY(message);
}
if (scenario == "executed") core.poll();
else gui_bridge_set_inst(nullptr);
QCOMPARE(pending(core.ua), 0); QCOMPARE(u_get_allocated_count(), ready);
#ifdef UTUN_TEST_CALL_HOOKS
QCOMPARE(failAllocation.load(), 0); QCOMPARE(observed.size(), scenario == "executed" ? 1 : 0);
if (!observed.isEmpty()) {
const int expected = action == "onAccept" ? GUI_CALL_ACCEPT :
action == "onDecline" || action == "closeIncoming" ? GUI_CALL_DECLINE :
action == "continue" ? GUI_CALL_CONTINUE : GUI_CALL_HANGUP;
QCOMPARE(observed.front().action, expected); QCOMPARE(observed.front().id, uint64_t(0x123456789));
}
#endif
QVERIFY(bindingValid);
}
QCOMPARE(u_get_allocated_count(), before);
}
void closeStates_data() {
QTest::addColumn<int>("state");
QTest::newRow("outgoing") << int(CallWindow::Outgoing); QTest::newRow("ringing") << int(CallWindow::Ringing);
QTest::newRow("incoming") << int(CallWindow::Incoming); QTest::newRow("active") << int(CallWindow::Active);
QTest::newRow("ended") << int(CallWindow::Ended); QTest::newRow("wait-decision") << int(CallWindow::WaitDecision);
}
void closeStates() {
QFETCH(int, state); const size_t before = u_get_allocated_count();
{
CommandCore core; QVERIFY(core.ua); core.attach();
CallWindow window; window.setCallId(77); window.setState(static_cast<CallWindow::State>(state));
QSignalSpy closed(&window, &CallWindow::closed); window.show(); window.close();
QCOMPARE(window.state(), CallWindow::Ended); QCOMPARE(closed.count(), 1);
QCOMPARE(closed.front().front().toULongLong(), quint64(77));
const int commands = state == CallWindow::Ended ? 0 : 1;
QCOMPARE(pending(core.ua), commands);
window.close(); QCOMPARE(pending(core.ua), commands); // До исполнения первой команды повтор не должен дублировать её.
core.poll(); QCOMPARE(pending(core.ua), 0); QVERIFY(bindingValid);
#ifdef UTUN_TEST_CALL_HOOKS
QCOMPARE(observed.size(), commands);
if (commands) QCOMPARE(observed.front().action, state == CallWindow::Incoming ? GUI_CALL_DECLINE : GUI_CALL_HANGUP);
#endif
}
QCOMPARE(u_get_allocated_count(), before);
}
void incomingEndsSession_data() {
QTest::addColumn<bool>("closeWindow");
QTest::newRow("close") << true; QTest::newRow("decline-control") << false;
}
void incomingEndsSession() {
QFETCH(bool, closeWindow); const size_t before = u_get_allocated_count();
{
IncomingEvents events; UASYNC* ua = uasync_create(); UTUN_INSTANCE* instance = nullptr;
const auto release = qScopeGuard([&] {
delete events.window.data(); gui_bridge_set_inst(nullptr); gui_bridge_set_uasync(nullptr);
executingCore = nullptr;
if (ua) uasync_mark_stopped(ua);
if (instance) utun_instance_destroy(instance);
if (ua) uasync_destroy(ua, 0);
});
QVERIFY(ua);
instance = utun_instance_create_from_str(ua,
"[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");
QVERIFY(instance); QCOMPARE(utun_core_start(instance), 0); QCOMPARE(chat_service_start(instance), 0);
instance->test_user_ptr = &events; chat_event_set_handler(instance, incomingEvent);
uasync_mark_running(ua); gui_bridge_set_uasync(ua); gui_bridge_set_inst(instance);
const uint64_t group = chat_core_create_channel_auto(instance, "Incoming close test"); QVERIFY(group);
const uint64_t id = 101; QVERIFY(deliverInvite(instance, group, id)); QCOMPARE(events.incoming, 1u);
events.window = new CallWindow(); events.window->setCallId(id); events.window->setState(CallWindow::Incoming);
QObject::connect(events.window.data(), &CallWindow::closed, events.window.data(), [&events] {
events.window->deleteLater();
});
events.window->show(); const int queued = pending(ua);
if (closeWindow) events.window->close();
else QVERIFY(QMetaObject::invokeMethod(events.window.data(), "onDecline", Qt::DirectConnection));
QCOMPARE(pending(ua), queued + 1);
QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete);
if (closeWindow) QVERIFY(events.window.isNull()); // Команда должна пережить GUI-владельца.
executingCore = instance; uasync_poll(ua, 0); executingCore = nullptr;
QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete);
auto* session = call_session_find(instance->call, id); QVERIFY(session);
QCOMPARE(int(session->state), int(CALL_ST_ENDED)); QCOMPARE(int(session->reason), int(CALL_REASON_DECLINE));
QCOMPARE(call_busy(instance), 0); QCOMPARE(events.ended, 1u); QCOMPARE(events.declined, 1u);
QVERIFY(events.window.isNull()); QVERIFY(bindingValid);
DEBUG_INFO(DEBUG_CATEGORY_CALL, "incoming %s verified: native=ENDED reason=DECLINE busy=0 window=deleted",
closeWindow ? "close" : "decline");
}
QCOMPARE(u_get_allocated_count(), before);
}
void startLifecycle_data() { scenarios(); }
void startLifecycle() {
QFETCH(QString, scenario);
const size_t before = u_get_allocated_count();
{
CommandCore core; QVERIFY(core.ua); prepare(core, scenario);
const size_t ready = u_get_allocated_count();
char channel[] = "dm:123456789abcdef0";
const bool accepted = scenario == "executed" || scenario == "cancelled";
QCOMPARE(gui_bridge_call_start(channel, 42, 73), accepted ? 0 : -1);
memset(channel, 'x', sizeof(channel) - 1); // После возврата caller может менять/освобождать строку.
if (scenario == "executed") core.poll();
else gui_bridge_set_inst(nullptr);
QCOMPARE(pending(core.ua), 0); QCOMPARE(u_get_allocated_count(), ready);
#ifdef UTUN_TEST_CALL_HOOKS
QCOMPARE(failAllocation.load(), 0); QCOMPARE(observed.size(), scenario == "executed" ? 1 : 0);
if (!observed.isEmpty()) {
QCOMPARE(observed.front().channel, QByteArray("dm:123456789abcdef0"));
QCOMPARE(observed.front().peer, uint64_t(42)); QCOMPARE(observed.front().id, uint64_t(73));
}
#endif
QVERIFY(bindingValid);
}
QCOMPARE(u_get_allocated_count(), before);
}
void invalidArguments() {
CommandCore core; QVERIFY(core.ua); core.attach(); const size_t before = u_get_allocated_count();
char channel[64]; memset(channel, '1', sizeof(channel));
QCOMPARE(gui_bridge_call_start(nullptr, 1, 1), -1); QCOMPARE(gui_bridge_call_start("", 1, 1), -1);
QCOMPARE(gui_bridge_call_start(channel, 1, 1), -1); QCOMPARE(gui_bridge_call_start("42", 0, 1), -1);
QCOMPARE(gui_bridge_call_start("42", 1, 0), -1);
QCOMPARE(gui_bridge_call_action(static_cast<gui_call_action>(99), 1), -1);
QCOMPARE(gui_bridge_call_action(GUI_CALL_ACCEPT, 0), -1);
QCOMPARE(pending(core.ua), 0); QCOMPARE(u_get_allocated_count(), before);
}
void concurrentRebind() {
const size_t before = u_get_allocated_count();
{
CommandCore first, second; QVERIFY(first.ua); QVERIFY(second.ua); first.attach();
std::thread producer([] {
for (uint64_t id = 1; id <= 64; ++id) gui_bridge_call_action(GUI_CALL_HANGUP, id);
});
for (unsigned i = 0; i < 32; ++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_call_action(GUI_CALL_HANGUP, 1000), 0); first.poll();
QVERIFY(bindingValid); QCOMPARE(pending(first.ua), 0); QCOMPARE(pending(second.ua), 0);
#ifdef UTUN_TEST_CALL_HOOKS
QVERIFY(!observed.isEmpty()); QCOMPARE(observed.back().instance, &first.instance);
#endif
}
QCOMPARE(u_get_allocated_count(), before);
}
};
int main(int argc, char** argv) {
if (argc >= 2 && !strcmp(argv[1], "--call-camera-worker")) return call_camera_worker_main(argc, argv);
QApplication application(argc, argv); CallCommandsTest test; return QTest::qExec(&test, argc, argv);
}
#include "test_call_commands.moc"