Files
utun2/tests/test_radio.c
T

924 lines
43 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// test_radio.c — интеграционный тест рации (PTT мультикаст голоса).
//
// Однопоточный: 3 узла (A, B, C) в одном процессе, один UASYNC, без fork.
// Топология — цепочка A←B←C (A сервер, B клиент к A + сервер, C клиент к B).
// Все три в одной CHAT-группе. Master state-machine на таймере.
//
// Сценарий:
// 1. A,B,C radio_on → флаг TOPO_FLAG_RADIO распространяется по BGP (node.radio==1).
// 2. A: radio_talk_begin + 5 кадров + radio_talk_end → B и C (через relay B) получают
// кадры в порядке seq 0..4 и FIN.
// 3. C radio_off (обрезание) → A снова говорит → B получает, C не получает.
// 4. Без FIN: C снимает индикацию при radio_off, B — по таймауту источника.
// До передачи: вызовы через B, deadline 2с/60с, потери RESULT/ACK, отказ/отмена/занятость,
// отсутствие живого BGP-пути, некорректный срок INVITE и отсутствие таймеров рации в покое.
#include "chat_core.h"
#include "chat_sync.h"
#include "chat_event.h"
#include "chat_member.h"
#include "member_sync.h"
#include "../radio/radio.h"
#include "../radio/radio_call.h"
#include "../call/call.h"
#include "../routing_layer/etcp_router.h"
#include "../routing_layer/topo_node_sqlite.h"
#include "../routing_layer/topo_group.h"
#include "../routing_layer/topo_node.h"
#include "../utun_instance.h"
#include "../transport_layer/etcp.h"
#include "../transport_layer/secure_channel.h"
#include "../ntp_time.h"
#include "../lib/u_async.h"
#include "../lib/debug_config.h"
#include "../lib/mem.h"
#include "../lib/platform_compat.h"
#include "test_utils.h"
#include "test_clock.h"
#include "test_net.h"
#include <sqlite3.h>
#define OPENSSL_API_COMPAT 0x10100000L
#include <openssl/evp.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdarg.h>
#include <time.h>
#define CH_ID "4242424242424242"
#define IDX_A 0
#define IDX_B 1
#define IDX_C 2
#define TICK_TB 100 /* 10 ms на такт */
#define MAX_TICKS 24000 /* включает минутные deadline звонка и выбора продолжения */
#define N_FRAMES 5
enum rad_phase {
P_WAIT_CONN,
P_SETUP,
P_WAIT_BGP,
P_RADIO_CALLS,
P_RADIO_ON,
P_WAIT_FLAGS,
P_TALK1,
P_WAIT_RX1,
P_WAIT_MUTED_RELAY,
P_RADIO_OFF_C,
P_WAIT_OFF_C,
P_TALK2,
P_WAIT_RX2,
P_REENABLE_C,
P_WAIT_REENABLE_C,
P_TALK_STALLED,
P_WAIT_STALLED_START,
P_WAIT_STALLED_STOP,
P_WAIT_RESTORED_C,
P_WITHDRAW_C,
P_DONE,
};
struct rad_ctx {
struct UASYNC* ua;
struct UTUN_INSTANCE* inst[3];
enum rad_phase phase;
int result; /* 0=running, 1=ok, 2=fail */
int ticks;
void* timer;
int call_step;
uint64_t call_started;
};
/* приёмник кадров (per-узел): проверка порядка seq и FIN */
struct rx_evt {
int active;
uint16_t stream;
uint16_t expect_seq;
int frames; /* число data-кадров (fin=0) текущего потока */
int fin_seen;
int seq_ok;
};
struct rad_shared {
uint8_t ch_x_pub[32], ch_ed_pub[32], ch_ed_priv[32], ch_sig[64];
uint64_t nid[3];
uint8_t x_pub[3][32], x_priv[3][32], ed_pub[3][32];
};
static struct rad_shared g_sh;
static struct rx_evt g_rx[3];
static int g_member_radio[3][3];
static int g_member_lists[3];
static int g_member_event_bad;
static int g_talk_on[3], g_talk_starts[3], g_talk_stops[3];
static struct { int state, reason, finished, incoming; uint64_t finished_tb; } g_calls[20][3];
static int g_call_ready, g_drop_result, g_drop_ack, g_drop_force;
static int g_member_phone[3][3];
static etcp_recv_fn g_call_recv[3];
/* Потеря приложения после router ACK: восстановить результат обязан сервис, не транспорт. */
static void call_packet_loss(struct ETCP_CONN* conn, struct ll_entry* entry) {
int idx = -1;
for (int i = 0; i < 3; ++i) if (conn->instance->node_id == g_sh.nid[i]) idx = i;
if (idx < 0 || !entry) abort();
if (entry->len <= ROUTER_SVC_PAYLOAD_OFF) { g_call_recv[idx](conn, entry); return; }
int cmd = entry->dgram[ROUTER_SVC_PAYLOAD_OFF];
if ((idx == IDX_A && cmd == 3 && g_drop_result-- > 0) || (idx == IDX_C && cmd == 4 && g_drop_ack-- > 0) ||
(idx == IDX_C && cmd == 6 && g_drop_force-- > 0)) {
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "test drops radio result/ACK dst=%d command=%d", idx, cmd);
queue_dgram_free(entry); queue_entry_free(entry); return;
}
g_call_recv[idx](conn, entry);
}
static void radio_action(struct UTUN_INSTANCE* inst, uint64_t id, int action, uint64_t peer) {
struct radio_call_request req = {inst, id, strtoull(CH_ID, NULL, 10), peer, action};
radio_call_execute(&req);
}
static int active_timer_count(struct UASYNC* ua) {
int count = 0;
for (size_t i = 0; i < ua->timeout_heap->size; ++i) {
TimeoutEntry* entry = &ua->timeout_heap->heap[i];
if (!entry->deleted) ++count;
}
return count;
}
/* Публичный lifecycle: idle destroy/init не должен удалять или создавать ни одного таймера. */
static int check_radio_idle_timers(struct rad_ctx* t) {
int before = active_timer_count(t->ua);
for (int i = 0; i < 3; ++i) radio_call_destroy(t->inst[i]);
int stopped = active_timer_count(t->ua);
for (int i = 0; i < 3; ++i) if (radio_call_init(t->inst[i])) return 0;
int restarted = active_timer_count(t->ua);
if (before == stopped && stopped == restarted) return 1;
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test idle radio timers before=%d stopped=%d restarted=%d", before, stopped, restarted);
return 0;
}
/* Проверяем именно UI-события: self, прямой пир и пир за посредником. */
static void member_event(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int len) {
if (type == CHAT_EVT_RADIO_CALL) {
uint64_t id, group, peer;
if (!data || len != 27) { g_member_event_bad = 1; return; }
memcpy(&id, data, 8); memcpy(&group, data + 8, 8); memcpy(&peer, data + 16, 8);
int idx = -1;
for (int i = 0; i < 3; ++i) if (inst->node_id == g_sh.nid[i]) idx = i;
if (idx < 0 || id < 100 || id > 119 || group != strtoull(CH_ID, NULL, 10)) { g_member_event_bad = 1; return; }
g_calls[id - 100][idx].state = data[24]; g_calls[id - 100][idx].reason = data[25];
if (data[24] == RADIO_CALL_INCOMING) {
++g_calls[id - 100][idx].incoming;
if (g_call_ready) radio_action(inst, id, RADIO_CALL_READY, 0);
}
if (data[24] == RADIO_CALL_FINISHED) {
++g_calls[id - 100][idx].finished; g_calls[id - 100][idx].finished_tb = get_time_tb();
if (data[25] == RADIO_CALL_ACCEPTED) radio_set_active(inst, group, 1);
}
return;
}
if (type == CHAT_EVT_RADIO_TALK) {
uint64_t group, source;
if (!data || len != 19 || data[18] > 1) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test: invalid talk event bytes=%d", len); g_member_event_bad = 1; return;
}
memcpy(&group, data, 8); memcpy(&source, data + 8, 8);
int dst = -1;
for (int i = 0; i < 3; ++i) if (inst->node_id == g_sh.nid[i]) dst = i;
if (dst < 0 || group != strtoull(CH_ID, NULL, 10) || source != g_sh.nid[IDX_A]) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test: unexpected talk dst=%d group=%016llx source=%016llx",
dst, (unsigned long long)group, (unsigned long long)source);
g_member_event_bad = 1; return;
}
g_talk_on[dst] = data[18];
if (data[18]) ++g_talk_starts[dst];
else ++g_talk_stops[dst];
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "test: talk event dst=%d on=%d starts=%d stops=%d",
dst, data[18], g_talk_starts[dst], g_talk_stops[dst]);
return;
}
if (type != CHAT_EVT_MEMBER_UPDATED && type != CHAT_EVT_MEMBER_LIST) return;
if (!data || len < 3) { g_member_event_bad = 1; return; }
int cl = data[0];
if (cl != (int)strlen(CH_ID) || len < cl + 3 || memcmp(data + 1, CH_ID, cl)) return;
int count = data[cl + 1];
int off = cl + 2;
if (type == CHAT_EVT_MEMBER_LIST) { count |= data[cl + 2] << 8; off++; }
if (len != off + count * CHAT_MEMBER_DISPLAY_SIZE) { g_member_event_bad = 1; return; }
int dst = -1;
for (int i = 0; i < 3; i++) if (inst->node_id == g_sh.nid[i]) dst = i;
if (dst < 0) { g_member_event_bad = 1; return; }
if (type == CHAT_EVT_MEMBER_LIST) g_member_lists[dst]++;
for (int i = 0; i < count; i++) {
struct chat_member_display member;
if (chat_core_deserialize_member(data + off + i * CHAT_MEMBER_DISPLAY_SIZE, &member) < 0) {
g_member_event_bad = 1; return;
}
for (int j = 0; j < 3; j++) if (member.node_id == g_sh.nid[j]) {
g_member_radio[dst][j] = !!(member.flags & CHAT_MEMBER_FLAG_RADIO);
g_member_phone[dst][j] = member.flags & (CHAT_MEMBER_FLAG_PHONE_KNOWN | CHAT_MEMBER_FLAG_PHONE_BUSY);
}
}
}
/* Полный список, одиночная строка и текущие события должны совпадать. */
static int check_member_radios(struct UTUN_INSTANCE* inst, int dst, int c_radio) {
uint8_t* wire = NULL; int count = 0;
if (chat_core_get_member_list(inst, CH_ID, &wire, &count) < 0 || count != 3 || g_member_event_bad) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test: invalid member list dst=%d count=%d event_bad=%d", dst, count, g_member_event_bad);
u_free(wire); return 0;
}
int seen = 0;
for (int i = 0; i < count; i++) {
struct chat_member_display member;
chat_core_deserialize_member(wire + i * CHAT_MEMBER_DISPLAY_SIZE, &member);
for (int j = 0; j < 3; j++) {
if (member.node_id != g_sh.nid[j]) continue;
uint8_t single[CHAT_MEMBER_DISPLAY_SIZE];
int expected = j == IDX_C ? c_radio : 1;
int radio = !!(member.flags & CHAT_MEMBER_FLAG_RADIO);
if (radio != expected || g_member_radio[dst][j] != expected
|| chat_core_get_single_member(inst, CH_ID, member.node_id, single) < 0
|| !!(single[10] & CHAT_MEMBER_FLAG_RADIO) != expected) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test: member radio dst=%d src=%d list=%d event=%d expected=%d",
dst, j, radio, g_member_radio[dst][j], expected);
u_free(wire); return 0;
}
seen |= 1 << j;
}
}
u_free(wire);
return seen == 7;
}
static int G_PASSED = 0, G_FAILED = 0, G_TOTAL = 0;
#define TEST(n) do { G_TOTAL++; printf(" %-58s", n); fflush(stdout); } while(0)
#define OK() do { G_PASSED++; printf("OK\n"); } while(0)
#define FAIL(f,...) do { G_FAILED++; printf("FAIL: " f "\n", ##__VA_ARGS__); } while(0)
/* ── крипто-хелперы ── */
static void gen_x25519(uint8_t pub[32], uint8_t priv[32]) {
EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_X25519, NULL);
EVP_PKEY* pkey = NULL;
EVP_PKEY_keygen_init(ctx); EVP_PKEY_keygen(ctx, &pkey); EVP_PKEY_CTX_free(ctx);
size_t l = 32; EVP_PKEY_get_raw_public_key(pkey, pub, &l);
l = 32; EVP_PKEY_get_raw_private_key(pkey, priv, &l);
EVP_PKEY_free(pkey);
}
static void gen_ed25519(uint8_t pub[32], uint8_t priv[32]) {
EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_ED25519, NULL);
EVP_PKEY* pkey = NULL;
EVP_PKEY_keygen_init(ctx); EVP_PKEY_keygen(ctx, &pkey); EVP_PKEY_CTX_free(ctx);
size_t l = 32; EVP_PKEY_get_raw_public_key(pkey, pub, &l);
l = 32; EVP_PKEY_get_raw_private_key(pkey, priv, &l);
EVP_PKEY_free(pkey);
}
static void to_hex(const uint8_t* bin, size_t n, char* out) {
for (size_t i = 0; i < n; i++) sprintf(out + i * 2, "%02x", bin[i]);
out[n * 2] = '\0';
}
static int wf(const char* p, const char* f, ...) {
va_list ap; FILE* fp = fopen(p, "w"); if (!fp) return -1;
va_start(ap, f); vfprintf(fp, f, ap); va_end(ap); fclose(fp); return 0;
}
/* ── данные сценария + конфиги (цепочка A←B←C) ── */
static void fill_shared(void) {
memset(&g_sh, 0, sizeof(g_sh));
for (int i = 0; i < 3; i++) {
gen_x25519(g_sh.x_pub[i], g_sh.x_priv[i]);
sc_derive_ed25519_pubkey(g_sh.x_priv[i], g_sh.ed_pub[i]);
g_sh.nid[i] = sc_derive_node_id_from_pubkey(g_sh.x_pub[i]);
}
{ uint8_t ch_x_priv[32]; gen_x25519(g_sh.ch_x_pub, ch_x_priv); }
gen_ed25519(g_sh.ch_ed_pub, g_sh.ch_ed_priv);
{
uint8_t msg[256]; size_t off = 0;
off += (size_t)snprintf((char*)msg + off, sizeof(msg) - off, "%s", CH_ID) + 1;
off += (size_t)snprintf((char*)msg + off, sizeof(msg) - off, "%s", "radio-test") + 1;
memcpy(msg + off, &g_sh.nid[IDX_A], 8); off += 8;
memcpy(msg + off, g_sh.ch_x_pub, 32); off += 32;
memcpy(msg + off, g_sh.ch_ed_pub, 32); off += 32;
sc_ed25519_sign(g_sh.ch_ed_priv, msg, off, g_sh.ch_sig);
}
}
static void write_configs(const char* dir) {
for (int i = 0; i < 3; i++) {
char priv[65], pub[65], path[512], dbd[512];
to_hex(g_sh.x_priv[i], 32, priv);
to_hex(g_sh.x_pub[i], 32, pub);
snprintf(path, sizeof(path), "%s/%s.conf", dir, i == IDX_A ? "a" : i == IDX_B ? "b" : "c");
snprintf(dbd, sizeof(dbd), "%s/db%s", dir, i == IDX_A ? "a" : i == IDX_B ? "b" : "c");
utun_mkdir(dbd, 0755);
char client[512] = "";
if (i == IDX_B) { /* B → A */
char apub[65]; to_hex(g_sh.x_pub[IDX_A], 32, apub);
snprintf(client, sizeof(client), "[client: to_a]\npeer_public_key=%s\nlink=s1:127.0.0.1:0\n", apub);
} else if (i == IDX_C) { /* C → B */
char bpub[65]; to_hex(g_sh.x_pub[IDX_B], 32, bpub);
snprintf(client, sizeof(client), "[client: to_b]\npeer_public_key=%s\nlink=s1:127.0.0.1:0\n", bpub);
}
wf(path,
"[global]\ntun_ip=10.98.%d.1/24\ntun_ifname=tun%d0\ndb_path=%s/db%s\n"
"my_private_key=%s\nmy_public_key=%s\n"
"[server: s1]\naddr=127.0.0.1:0\ntype=public\n"
"%s"
"[chatserver]\nstorage_autoload=0\n[allowed_keys]\nallow_all=1\n",
i, i, dir, i == IDX_A ? "a" : i == IDX_B ? "b" : "c",
priv, pub, client);
}
}
/* ── проверки ── */
static int conn_up(struct UTUN_INSTANCE* inst, uint64_t nid) {
struct ETCP_CONN* c = instance_find_conn(inst, nid);
return c && c->initialized && c->links_up;
}
static int bgp_has(struct UTUN_INSTANCE* inst, uint64_t nid) {
uint64_t gid = strtoull(CH_ID, NULL, 10);
struct TOPO_GROUP* g = topo_groups_find(inst->topo_groups, gid);
if (!g) return 0;
struct TOPO_GROUP_NODE* nq = topo_node_find_by_id(g, nid);
return nq && nq->paths && nq->paths->head;
}
static int node_radio(struct UTUN_INSTANCE* inst, uint64_t nid) {
uint64_t gid = strtoull(CH_ID, NULL, 10);
struct TOPO_GROUP* g = topo_groups_find(inst->topo_groups, gid);
if (!g) return 0;
struct TOPO_GROUP_NODE* nq = topo_node_find_by_id(g, nid);
return nq ? nq->radio : 0;
}
/* ── frame cb (per-узел) ── */
static void rx_frame_cb(struct UTUN_INSTANCE* inst, uint64_t group_id,
uint64_t src, uint16_t stream, uint16_t seq,
uint8_t fin, const uint8_t* opus, int len, void* arg) {
(void)opus; (void)len;
if (radio_member_muted(inst, group_id, src)) return; /* локальный аудио-потребитель */
struct rx_evt* rx = (struct rx_evt*)arg;
if (fin) {
if (rx->active && stream == rx->stream) rx->fin_seen = 1;
return;
}
if (!rx->active || stream != rx->stream) {
rx->active = 1;
rx->stream = stream;
rx->expect_seq = 0;
rx->frames = 0;
rx->fin_seen = 0;
rx->seq_ok = 1;
}
if (seq != rx->expect_seq) rx->seq_ok = 0;
rx->expect_seq = (uint16_t)(seq + 1);
rx->frames++;
}
/* ── настройка канала и мемберов ── */
static void channel_put_shared(struct UTUN_INSTANCE* inst, int has_priv) {
topo_node_sqlite_channel_put(inst->topo_sqlite_db, CH_ID, "radio-test", g_sh.nid[IDX_A],
g_sh.ch_x_pub, NULL, g_sh.ch_ed_pub, has_priv ? g_sh.ch_ed_priv : NULL, g_sh.ch_sig);
chat_core_ensure_channel_ready(inst, CH_ID);
}
static void member_put_self(struct UTUN_INSTANCE* inst, const char* name) {
uint64_t join_ts = (uint64_t)ntp_time_get_seconds(inst);
uint8_t jm[128];
int jl = member_sync_build_join_msg(g_sh.ch_x_pub, g_sh.ch_ed_pub, inst->node_id,
inst->my_keys.public_key, join_ts, jm, (int)sizeof(jm));
uint8_t join_sig[64]; sc_ed25519_sign(inst->my_ed25519_privkey, jm, (size_t)jl, join_sig);
uint8_t tree_sig[64]; member_sync_sign_pubkey(g_sh.ch_ed_priv, inst->my_keys.public_key, tree_sig);
char userinfo[256]; snprintf(userinfo, sizeof(userinfo), "{\"name\":\"%s\"}", name);
member_sync_put(inst, CH_ID, inst->node_id, inst->my_keys.public_key, inst->my_ed25519_pubkey,
join_sig, join_ts, NULL, 0, userinfo, NULL, NULL, 0, 0, tree_sig);
}
static void member_put_placeholder(struct UTUN_INSTANCE* inst, int idx, const char* name) {
char userinfo[256]; snprintf(userinfo, sizeof(userinfo), "{\"name\":\"%s\"}", name);
member_sync_put(inst, CH_ID, g_sh.nid[idx], g_sh.x_pub[idx], g_sh.ed_pub[idx],
NULL, 0, NULL, 0, userinfo, NULL, NULL, 0, 0, NULL);
}
static void do_setup(struct UTUN_INSTANCE* inst, int role) {
switch (role) {
case IDX_A:
channel_put_shared(inst, 1);
member_put_self(inst, "A");
member_put_placeholder(inst, IDX_B, "B");
member_put_placeholder(inst, IDX_C, "C");
member_sync_start(inst, g_sh.nid[IDX_B], CH_ID, NULL, NULL);
break;
case IDX_B:
channel_put_shared(inst, 0);
member_put_self(inst, "B");
member_put_placeholder(inst, IDX_A, "A");
member_put_placeholder(inst, IDX_C, "C");
member_sync_start(inst, g_sh.nid[IDX_A], CH_ID, NULL, NULL);
member_sync_start(inst, g_sh.nid[IDX_C], CH_ID, NULL, NULL);
break;
case IDX_C:
channel_put_shared(inst, 0);
member_put_self(inst, "C");
member_put_placeholder(inst, IDX_A, "A");
member_put_placeholder(inst, IDX_B, "B");
member_sync_start(inst, g_sh.nid[IDX_B], CH_ID, NULL, NULL);
break;
}
}
/* ── talk: 5 кадров + FIN от A ── */
static void do_talk(struct UTUN_INSTANCE* A) {
uint64_t gid = strtoull(CH_ID, NULL, 10);
static const uint8_t opus[16] = { 0xf8, 0xff, 0xfe, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d };
radio_talk_begin(A, gid);
for (int i = 0; i < N_FRAMES; i++) radio_talk_send(A, gid, opus, sizeof(opus));
radio_talk_end(A, gid);
}
/* ── master state-machine ── */
static void rad_tick(void* arg) {
struct rad_ctx* t = arg;
t->timer = NULL;
if (t->result) return;
struct UTUN_INSTANCE* A = t->inst[IDX_A];
struct UTUN_INSTANCE* B = t->inst[IDX_B];
struct UTUN_INSTANCE* C = t->inst[IDX_C];
uint64_t na = g_sh.nid[IDX_A], nb = g_sh.nid[IDX_B], nc = g_sh.nid[IDX_C];
uint64_t gid = strtoull(CH_ID, NULL, 10);
switch (t->phase) {
case P_WAIT_CONN:
if (conn_up(A, nb) && conn_up(B, na) && conn_up(B, nc) && conn_up(C, nb))
t->phase = P_SETUP;
break;
case P_SETUP:
do_setup(A, IDX_A);
do_setup(B, IDX_B);
do_setup(C, IDX_C);
t->phase = P_WAIT_BGP;
break;
case P_WAIT_BGP:
if (bgp_has(A, nb) && bgp_has(A, nc) && bgp_has(B, na) && bgp_has(B, nc) && bgp_has(C, na) && bgp_has(C, nb))
t->phase = P_RADIO_CALLS;
break;
case P_RADIO_CALLS: {
uint64_t now = get_time_tb();
if (g_member_event_bad) { t->result = 2; uasync_stop(t->ua); return; }
switch (t->call_step) {
case 0: {
uint8_t key[32];
if (member_sync_verified_ed25519(A, CH_ID, nc, key) || member_sync_verified_ed25519(C, CH_ID, na, key)) break;
if (!check_radio_idle_timers(t)) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test idle radio invitation service has timers");
t->result = 2; uasync_stop(t->ua); return;
}
/* Уже расшифрованный пакет от проверенного пира: даже INT64_MIN обязан безопасно отбрасываться. */
struct ll_entry* expired = ll_alloc_lldgram(ROUTER_SVC_HDR_SIZE + 18);
if (!expired) abort();
memset(expired->dgram, 0, expired->len);
expired->dgram[0] = ETCP_RT_ID_RADIO_CALL;
memcpy(expired->dgram + ROUTER_SVC_SRC_OFF, &nc, 8); memcpy(expired->dgram + ROUTER_SVC_DST_OFF, &na, 8);
memcpy(expired->dgram + ROUTER_SVC_GROUP_OFF, &gid, 8);
expired->dgram[ROUTER_SVC_FLAGS_OFF] = ROUTER_FLAG_SIGNED | ROUTER_FLAG_ENCRYPTED;
expired->dgram[ROUTER_SVC_PAYLOAD_OFF] = 1;
uint64_t expired_id = 108; int64_t expired_at = INT64_MIN;
memcpy(expired->dgram + ROUTER_SVC_PAYLOAD_OFF + 1, &expired_id, 8);
memcpy(expired->dgram + ROUTER_SVC_PAYLOAD_OFF + 9, &expired_at, 8);
struct ETCP_CONN source = {.instance = A};
A->router_bindings.callbacks[ETCP_RT_ID_RADIO_CALL](&source, expired);
if (radio_call_busy(A) || g_member_event_bad) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test malformed invite created a call"); t->result = 2; uasync_stop(t->ua); return;
}
struct TOPO_GROUP* cg = topo_groups_find(C->topo_groups, gid);
struct ETCP_CONN* path = topo_group_find_conn_for_node(cg, na);
if (!path) break;
int saved_up = path->links_up;
path->links_up = 0;
radio_action(C, 106, RADIO_CALL_START, na);
path->links_up = saved_up;
radio_action(C, 107, RADIO_CALL_START, nc);
if (g_calls[6][IDX_C].reason != RADIO_CALL_UNAVAILABLE || g_calls[7][IDX_C].reason != RADIO_CALL_UNAVAILABLE ||
radio_call_busy(C)) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test BGP without live path/self-call must fail before signaling");
t->result = 2; uasync_stop(t->ua); return;
}
for (int i = 0; i < 3; ++i) {
g_call_recv[i] = t->inst[i]->router_bindings.callbacks[ETCP_RT_ID_RADIO_CALL];
t->inst[i]->router_bindings.callbacks[ETCP_RT_ID_RADIO_CALL] = call_packet_loss;
}
g_call_ready = 0; t->call_started = now;
radio_action(A, 100, RADIO_CALL_START, nc); ++t->call_step;
break;
}
case 1:
if (g_calls[0][IDX_A].finished && g_calls[0][IDX_C].finished) {
uint64_t elapsed = g_calls[0][IDX_A].finished_tb - t->call_started;
if (g_calls[0][IDX_A].reason != RADIO_CALL_UNAVAILABLE || elapsed < RADIO_CALL_RESPONSE_TB ||
elapsed > RADIO_CALL_RESPONSE_TB + 1000 || g_calls[0][IDX_C].incoming != 1) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test first response deadline failed reason=%d elapsed_tb=%llu incoming=%d",
g_calls[0][IDX_A].reason, (unsigned long long)elapsed, g_calls[0][IDX_C].incoming);
t->result = 2; uasync_stop(t->ua); return;
}
g_call_ready = 1; g_drop_result = 1; g_drop_ack = 1;
radio_action(A, 101, RADIO_CALL_START, nc); ++t->call_step;
}
break;
case 2:
if (g_calls[1][IDX_A].state == RADIO_CALL_RINGING) {
radio_set_active(C, gid, 1);
radio_action(C, 101, RADIO_CALL_ACCEPT, 0); ++t->call_step;
}
break;
case 3:
if (g_calls[1][IDX_A].finished && g_calls[1][IDX_C].finished) {
if (g_calls[1][IDX_A].reason != RADIO_CALL_ACCEPTED || !radio_is_active(A, gid) || !radio_is_active(C, gid)) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test radio acceptance failed"); t->result = 2; uasync_stop(t->ua); return;
}
radio_set_active(A, gid, 0); radio_set_active(C, gid, 0);
radio_action(A, 102, RADIO_CALL_START, nc); ++t->call_step;
}
break;
case 4:
if (g_calls[2][IDX_A].state == RADIO_CALL_RINGING) { radio_action(C, 102, RADIO_CALL_DECLINE, 0); ++t->call_step; }
break;
case 5:
if (g_calls[2][IDX_A].finished) {
if (g_calls[2][IDX_A].reason != RADIO_CALL_DECLINED || radio_is_active(A, gid) || radio_is_active(C, gid)) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test radio decline failed"); t->result = 2; uasync_stop(t->ua); return;
}
radio_action(A, 103, RADIO_CALL_START, nc); ++t->call_step;
}
break;
case 6:
if (g_calls[3][IDX_A].state == RADIO_CALL_RINGING) {
radio_action(C, 104, RADIO_CALL_START, nb);
radio_action(A, 103, RADIO_CALL_CANCEL, 0); ++t->call_step;
}
break;
case 7:
if (g_calls[3][IDX_C].finished && g_calls[4][IDX_C].finished) {
if (g_calls[3][IDX_C].reason != RADIO_CALL_CANCELLED || g_calls[4][IDX_C].reason != RADIO_CALL_BUSY) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test radio cancel/busy failed"); t->result = 2; uasync_stop(t->ua); return;
}
t->call_started = now;
radio_action(A, 105, RADIO_CALL_START, nc); ++t->call_step;
}
break;
case 8:
if (g_calls[5][IDX_A].finished && g_calls[5][IDX_C].finished) {
uint64_t elapsed = g_calls[5][IDX_A].finished_tb - t->call_started;
if (g_calls[5][IDX_A].reason != RADIO_CALL_NO_ANSWER || elapsed < RADIO_CALL_RING_TB || elapsed > RADIO_CALL_RING_TB + 1000) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test one minute deadline failed reason=%d elapsed_tb=%llu",
g_calls[5][IDX_A].reason, (unsigned long long)elapsed); t->result = 2; uasync_stop(t->ua); return;
}
for (int n = 0; n < 6; ++n) for (int i = 0; i < 3; ++i) {
if (g_calls[n][i].finished > 1 || g_calls[n][i].incoming > 1) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test duplicated radio call event id=%d node=%d", n + 100, i);
t->result = 2; uasync_stop(t->ua); return;
}
}
t->call_started = now; ++t->call_step;
}
break;
case 9:
if (now - t->call_started >= 5000) {
if (radio_call_busy(A) || radio_call_busy(B) || radio_call_busy(C) || !check_radio_idle_timers(t)) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test finished invitations retain timers or busy state");
t->result = 2; uasync_stop(t->ua); return;
}
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "test radio calls passed: 2s/60s, accept/decline/cancel/busy, lost result/ACK, idle has no timer");
call_set_phone_state(C, 2, CALL_POLICY_WARN_PHONE);
radio_action(A, 108, RADIO_CALL_START, nc); ++t->call_step;
}
break;
case 10:
if (g_calls[8][IDX_A].state != RADIO_CALL_WAIT_DECISION) break;
if (g_calls[8][IDX_C].incoming || g_member_phone[IDX_A][IDX_C]) goto phone_failed;
call_set_phone_state(C, 1, CALL_POLICY_WARN_PHONE);
t->call_started = now; ++t->call_step; break;
case 11:
if (now - t->call_started < 10000) break;
if (g_calls[8][IDX_C].incoming) goto phone_failed; // Освободившийся телефон сам не начинает вызов.
g_drop_force = 2;
radio_action(A, 108, RADIO_CALL_CONTINUE, 0); ++t->call_step; break;
case 12:
if (g_calls[8][IDX_A].state != RADIO_CALL_RINGING) break;
if (g_calls[8][IDX_C].incoming != 1 || g_drop_force > 0) goto phone_failed;
radio_action(C, 108, RADIO_CALL_DECLINE, 0); ++t->call_step; break;
case 13:
if (!g_calls[8][IDX_A].finished) break;
radio_action(A, 108, RADIO_CALL_CONTINUE, 0); // Запоздалое UI-подтверждение не оживляет попытку.
call_set_phone_state(C, 2, CALL_POLICY_PUBLISH_PHONE);
radio_action(A, 109, RADIO_CALL_START, nc); ++t->call_step; break;
case 14:
if (g_calls[9][IDX_A].state != RADIO_CALL_RINGING || g_member_phone[IDX_A][IDX_C] != 0xC0) break;
if (g_calls[9][IDX_C].incoming != 1 || g_calls[8][IDX_C].incoming != 1) goto phone_failed;
{
uint8_t member[CHAT_MEMBER_DISPLAY_SIZE];
if (chat_core_get_single_member(A, CH_ID, nc, member) || (member[10] & 0xC0) != 0xC0) goto phone_failed;
}
radio_action(A, 109, RADIO_CALL_CANCEL, 0); ++t->call_step; break;
case 15:
if (!g_calls[9][IDX_C].finished) break;
call_set_phone_state(C, 2, CALL_POLICY_WARN_PHONE | CALL_POLICY_REJECT_PHONE);
radio_action(A, 110, RADIO_CALL_START, nc); ++t->call_step; break;
case 16:
if (!g_calls[10][IDX_A].finished || g_member_phone[IDX_A][IDX_C]) break;
if (g_calls[10][IDX_A].reason != RADIO_CALL_PHONE_BUSY || g_calls[10][IDX_C].incoming) goto phone_failed;
call_set_phone_state(C, 0, CALL_POLICY_BLOCK_CALLS);
radio_action(A, 111, RADIO_CALL_START, nc); ++t->call_step; break;
case 17:
if (g_calls[11][IDX_A].state != RADIO_CALL_RINGING) break;
radio_action(A, 111, RADIO_CALL_CANCEL, 0); ++t->call_step; break;
case 18:
if (!g_calls[11][IDX_C].finished) break;
call_set_phone_state(C, 2, CALL_POLICY_BLOCK_RADIO | CALL_POLICY_WARN_PHONE);
radio_action(A, 112, RADIO_CALL_START, nc); ++t->call_step; break;
case 19:
if (!g_calls[12][IDX_A].finished) break;
if (g_calls[12][IDX_A].reason != RADIO_CALL_DECLINED || g_calls[12][IDX_C].incoming) goto phone_failed;
call_set_phone_state(C, 2, CALL_POLICY_WARN_PHONE);
t->call_started = now;
radio_action(A, 113, RADIO_CALL_START, nc); ++t->call_step; break;
case 20:
if (!g_calls[13][IDX_A].finished) break;
if (g_calls[13][IDX_A].reason != RADIO_CALL_NO_ANSWER || g_calls[13][IDX_C].incoming ||
now - t->call_started < RADIO_CALL_RING_TB || now - t->call_started > RADIO_CALL_RING_TB + 1000) goto phone_failed;
call_set_phone_state(C, 0, 0);
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "phone policy passed: hidden/published BGP, warn, override with loss, reject, independent blocks, 60s choice");
t->phase = P_RADIO_ON; break;
phone_failed:
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "phone policy failed step=%d", t->call_step);
t->result = 2; uasync_stop(t->ua); return;
}
break;
}
case P_RADIO_ON:
radio_set_active(A, gid, 1);
radio_set_active(B, gid, 1);
radio_set_active(C, gid, 1);
t->phase = P_WAIT_FLAGS;
break;
case P_WAIT_FLAGS:
if (node_radio(A, nb) && node_radio(A, nc) && node_radio(B, na) && node_radio(B, nc) && node_radio(C, na) && node_radio(C, nb))
t->phase = P_TALK1;
break;
case P_TALK1:
for (int i = 0; i < 3; i++) {
if (!check_member_radios(t->inst[i], i, 1)) { t->result = 2; uasync_stop(t->ua); return; }
struct chat_member_list_req* req = u_calloc(1, sizeof(*req));
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test: member request OOM"); t->result = 2; uasync_stop(t->ua); return; }
req->inst = t->inst[i]; strcpy(req->ch_id, CH_ID);
chat_core_request_member_list_trampoline(req);
if (g_member_lists[i] != 1) { t->result = 2; uasync_stop(t->ua); return; }
}
memset(g_rx, 0, sizeof(g_rx));
do_talk(A);
t->phase = P_WAIT_RX1;
break;
case P_WAIT_RX1:
if (g_rx[IDX_B].frames == N_FRAMES && g_rx[IDX_B].fin_seen && g_rx[IDX_B].seq_ok
&& g_rx[IDX_C].frames == N_FRAMES && g_rx[IDX_C].fin_seen && g_rx[IDX_C].seq_ok) {
if (chat_core_toggle_member_radio_mute(B, CH_ID, na) < 0 || !radio_member_muted(B, gid, na) ||
radio_member_muted(C, gid, na)) { t->result = 2; uasync_stop(t->ua); return; }
memset(g_rx, 0, sizeof(g_rx)); do_talk(A);
t->phase = P_WAIT_MUTED_RELAY;
}
break;
case P_WAIT_MUTED_RELAY:
if (g_rx[IDX_C].frames == N_FRAMES && g_rx[IDX_C].fin_seen && g_rx[IDX_C].seq_ok) {
if (g_rx[IDX_B].frames || g_rx[IDX_B].fin_seen || !radio_is_active(B, gid) ||
chat_core_toggle_member_radio_mute(B, CH_ID, na) < 0 || radio_member_muted(B, gid, na)) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test: locally muted relay blocked downstream or local mute failed");
t->result = 2; uasync_stop(t->ua); return;
}
t->phase = P_RADIO_OFF_C;
}
break;
case P_RADIO_OFF_C:
radio_set_active(C, gid, 0);
t->phase = P_WAIT_OFF_C;
break;
case P_WAIT_OFF_C:
/* ждём, пока A и B увидят radio=0 у C (обрезание ветви C) */
if (!node_radio(A, nc) && !node_radio(B, nc)) {
memset(g_rx, 0, sizeof(g_rx));
t->phase = P_TALK2;
}
break;
case P_TALK2:
for (int i = 0; i < 3; i++)
if (!check_member_radios(t->inst[i], i, 0)) { t->result = 2; uasync_stop(t->ua); return; }
do_talk(A);
t->phase = P_WAIT_RX2;
break;
case P_WAIT_RX2:
if (g_rx[IDX_B].frames == N_FRAMES && g_rx[IDX_B].fin_seen && g_rx[IDX_B].seq_ok
&& g_rx[IDX_C].frames == 0 && !g_rx[IDX_C].fin_seen)
t->phase = P_REENABLE_C;
break;
case P_REENABLE_C:
radio_set_active(C, gid, 1);
t->phase = P_WAIT_REENABLE_C;
break;
case P_WAIT_REENABLE_C:
if (node_radio(A, nc) && node_radio(B, nc)) t->phase = P_TALK_STALLED;
break;
case P_TALK_STALLED: {
const uint8_t opus[] = {0xf8, 0xff, 0xfe};
memset(g_rx, 0, sizeof(g_rx));
if (radio_talk_begin(A, gid) < 0 || radio_talk_send(A, gid, opus, sizeof(opus)) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test: cannot start burst without FIN");
t->result = 2; uasync_stop(t->ua); return;
}
t->phase = P_WAIT_STALLED_START;
break;
}
case P_WAIT_STALLED_START:
if (g_rx[IDX_B].frames && g_rx[IDX_C].frames) {
if (!g_talk_on[IDX_B] || !g_talk_on[IDX_C] || g_member_event_bad) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test: missing talk START B=%d C=%d bad=%d",
g_talk_on[IDX_B], g_talk_on[IDX_C], g_member_event_bad);
t->result = 2; uasync_stop(t->ua); return;
}
radio_set_active(C, gid, 0);
if (g_talk_on[IDX_C] || !g_talk_on[IDX_B]) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test: subscription stop did not clear only C: B=%d C=%d",
g_talk_on[IDX_B], g_talk_on[IDX_C]);
t->result = 2; uasync_stop(t->ua); return;
}
t->phase = P_WAIT_STALLED_STOP;
}
break;
case P_WAIT_STALLED_STOP:
if (!g_talk_on[IDX_B]) {
if (g_rx[IDX_B].fin_seen || g_rx[IDX_C].fin_seen || g_member_event_bad) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test: stopped indication without FIN check failed B_fin=%d C_fin=%d bad=%d",
g_rx[IDX_B].fin_seen, g_rx[IDX_C].fin_seen, g_member_event_bad);
t->result = 2; uasync_stop(t->ua); return;
}
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "test: talk STOP without FIN verified (C unsubscribe, B timeout)");
radio_talk_end(A, gid);
radio_set_active(C, gid, 1);
t->phase = P_WAIT_RESTORED_C;
}
break;
case P_WAIT_RESTORED_C:
if (node_radio(A, nc) && node_radio(B, nc)) t->phase = P_WITHDRAW_C;
break;
case P_WITHDRAW_C: {
struct TOPO_GROUP* group = topo_groups_find(A->topo_groups, gid);
struct TOPO_GROUP_NODE* node = topo_node_find_by_id(group, nc);
while (node && node->paths) {
struct ETCP_CONN* from = ((struct TOPO_NODEPATH*)node->paths->head)->conn;
struct TOPOMSG_WITHDRAW_PKT withdraw = { .node_id = nc };
if (topo_group_process_withdraw(group, from, (const uint8_t*)&withdraw, sizeof(withdraw)) < 0) {
t->result = 2; uasync_stop(t->ua); return;
}
node = topo_node_find_by_id(group, nc);
}
if (!radio_is_active(C, gid) || !check_member_radios(A, IDX_A, 0)) {
t->result = 2; uasync_stop(t->ua); return;
}
t->phase = P_DONE;
break;
}
case P_DONE:
if (g_member_event_bad || g_talk_starts[IDX_B] != 4 || g_talk_stops[IDX_B] != 4 ||
g_talk_starts[IDX_C] != 3 || g_talk_stops[IDX_C] != 3 || g_talk_on[IDX_B] || g_talk_on[IDX_C]) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test: talk lifecycle mismatch B=%d/%d C=%d/%d on=%d/%d bad=%d",
g_talk_starts[IDX_B], g_talk_stops[IDX_B], g_talk_starts[IDX_C], g_talk_stops[IDX_C],
g_talk_on[IDX_B], g_talk_on[IDX_C], g_member_event_bad);
t->result = 2; uasync_stop(t->ua); return;
}
t->result = 1;
uasync_stop(t->ua);
return;
}
if (++t->ticks > MAX_TICKS) {
fprintf(stderr, "test: timeout in phase %d (B_rx=%d/%d fin=%d seqok=%d C_rx=%d/%d fin=%d seqok=%d radio_flags: A(B=%d C=%d) B(A=%d C=%d) C(A=%d B=%d))\n",
(int)t->phase,
g_rx[IDX_B].frames, N_FRAMES, g_rx[IDX_B].fin_seen, g_rx[IDX_B].seq_ok,
g_rx[IDX_C].frames, N_FRAMES, g_rx[IDX_C].fin_seen, g_rx[IDX_C].seq_ok,
node_radio(A, nb), node_radio(A, nc), node_radio(B, na), node_radio(B, nc),
node_radio(C, na), node_radio(C, nb));
t->result = 2;
uasync_stop(t->ua);
return;
}
t->timer = uasync_set_timeout(t->ua, TICK_TB, t, rad_tick, "rad_tick");
}
/* ── main ── */
int main(int argc, char** argv) {
(void)argc; (void)argv;
debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR);
if (getenv("UTUN_TEST_DEBUG")) {
debug_set_category_level_by_name("radio", "debug");
debug_set_category_level_by_name("bgp", "info");
debug_set_category_level_by_name("member_sync", "info");
debug_set_category_level_by_name("chat_sync", "info");
}
test_clock_init(TICK_TB);
test_clock_sync_wall_time();
utun_instance_set_tun_init_enabled(0);
srand((unsigned)time(NULL));
char dir[512]; snprintf(dir, sizeof(dir), "/tmp/utun_radio_XXXXXX");
if (test_mkdtemp(dir) != 0) { fprintf(stderr, "mkdtemp failed\n"); return 1; }
test_setenv("UTUN_TEST_DIR", dir, 1);
fill_shared();
write_configs(dir);
printf("=== test_radio ===\n"); fflush(stdout);
TEST("member snapshot: short names have zero padding"); {
const char* names[] = {"", "0x06B9", "Без имени", "123456789012345678901234567890123456789012345678901234567890123"};
int valid = 1;
for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); i++) {
struct chat_member_display member = {0};
member.node_id = UINT64_C(0x212732e6bc9c06b9);
memset(member.name, 0xFF, sizeof(member.name));
snprintf(member.name, sizeof(member.name), "%s", names[i]);
uint8_t wire[CHAT_MEMBER_DISPLAY_SIZE];
chat_core_serialize_member(&member, wire);
char expected[64] = {0};
snprintf(expected, sizeof(expected), "%s", names[i]);
if (memcmp(wire + 15, expected, sizeof(expected))) { valid = 0; break; }
}
if (valid) OK(); else FAIL("name field contains bytes after NUL");
}
TEST("radio: members + mute relay + speaker FIN/timeout/off + withdraw"); {
struct UASYNC* ua = uasync_create();
if (!ua) { FAIL("uasync_create failed"); return 1; }
struct rad_ctx t;
memset(&t, 0, sizeof(t));
t.ua = ua;
t.phase = P_WAIT_CONN;
char cfgA[512], cfgB[512], cfgC[512];
snprintf(cfgA, sizeof(cfgA), "%s/a.conf", dir);
snprintf(cfgB, sizeof(cfgB), "%s/b.conf", dir);
snprintf(cfgC, sizeof(cfgC), "%s/c.conf", dir);
t.inst[IDX_A] = utun_instance_create(ua, cfgA);
t.inst[IDX_B] = utun_instance_create(ua, cfgB);
t.inst[IDX_C] = utun_instance_create(ua, cfgC);
if (!t.inst[IDX_A] || !t.inst[IDX_B] || !t.inst[IDX_C]) {
FAIL("instance create failed A=%p B=%p C=%p", (void*)t.inst[IDX_A], (void*)t.inst[IDX_B], (void*)t.inst[IDX_C]);
uasync_destroy(ua, 0);
return 1;
}
test_etcp_set_peer(t.inst[IDX_B], "to_a", t.inst[IDX_A], "s1");
test_etcp_set_peer(t.inst[IDX_C], "to_b", t.inst[IDX_B], "s1");
utun_instance_init(t.inst[IDX_A]);
utun_instance_init(t.inst[IDX_B]);
utun_instance_init(t.inst[IDX_C]);
memset(g_member_radio, -1, sizeof(g_member_radio));
for (int i = 0; i < 3; i++) chat_event_set_handler(t.inst[i], member_event);
radio_set_frame_cb(t.inst[IDX_A], rx_frame_cb, &g_rx[IDX_A]);
radio_set_frame_cb(t.inst[IDX_B], rx_frame_cb, &g_rx[IDX_B]);
radio_set_frame_cb(t.inst[IDX_C], rx_frame_cb, &g_rx[IDX_C]);
t.timer = uasync_set_timeout(ua, TICK_TB, &t, rad_tick, "rad_tick");
test_clock_mainloop(ua);
int ok = (t.result == 1);
if (t.inst[IDX_A]) utun_instance_destroy(t.inst[IDX_A]);
if (t.inst[IDX_B]) utun_instance_destroy(t.inst[IDX_B]);
if (t.inst[IDX_C]) utun_instance_destroy(t.inst[IDX_C]);
uasync_destroy(ua, 0);
if (ok) OK(); else FAIL("result=%d phase=%d", t.result, (int)t.phase);
}
char cmd[512]; snprintf(cmd, sizeof(cmd), "rm -rf %s", dir); (void)!system(cmd);
printf("\n%d/%d passed, %d failed\n", G_PASSED, G_TOTAL, G_FAILED);
test_clock_finish();
return G_FAILED > 0 ? 1 : 0;
}