Browse Source

Fix Android external network monitoring and startup readiness

Select frontend network callbacks for auto_sockets=android instead of opening a denied netlink monitor. Preserve monitor descriptor ownership, keep native monitoring failures fatal, and release caller config on failed Android core creation and restart.

Register ConnectivityManager before requesting startup, report readiness after SERVICE_STARTED, and refresh addresses after initialization with serialized updates and generation checks. Add regression coverage for denied netlink, socket failures, address updates, and fd=0 preservation. Record the separate cancellation issue for startup before readiness.

Validation: full check.sh passed, including proxy/burst/load integrations. Clean Android build installed successfully on SM-A525F and Q8; logcat confirms both cores initialized and entered the poll loop with external monitoring and post-start address refresh.
master
evgeny 1 week ago
parent
commit
a96d5926c7
  1. 6
      doc/service_lifecycle.md
  2. 7
      doc/tasks.md
  3. 21
      src/transport_layer/auto_socket.c
  4. 4
      src/transport_layer/socket_monitor.c
  5. 1
      src/transport_layer/socket_monitor.h
  6. 3
      tests/Makefile.am
  7. 49
      tests/test_tun_route.c
  8. 1
      tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt
  9. 126
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/NetworkChangeMonitor.kt
  10. 20
      tools/chatgui-android/app/src/main/java/com/utun/chat/headless/HeadlessService.kt
  11. 3
      tools/chatgui-android/libutun_lite/instance_lite.c

6
doc/service_lifecycle.md

@ -35,6 +35,12 @@ listen-сокетов, мониторинга, TUN и всех `route_subnet` п
Маршрут, на добавлении которого ядро вернуло ошибку, не становится собственностью
UTUN и при откате не удаляется. Ошибка одного удаления не прекращает очистку остальных.
`auto_sockets=android` выбирает внешнее управление сетью: frontend регистрирует
ConnectivityManager до запроса запуска и передаёт конфигурацию сокетов через JNI.
Оба нативных монитора ОС в этом режиме не создаются. Рабочие listen-сокеты по-прежнему
должны открыться полностью. Android подтверждает запуск по `SERVICE_STARTED`, а не
по успешному созданию потока; после события передаётся свежий снимок адресов.
| Владелец | Ресурсы |
|---|---|
| Ядро | Идентичность узла, реестр узлов и групп, SQLite, транспортные сокеты, NCD, маршрутизатор, общая репликация, время, мониторинг |

7
doc/tasks.md

@ -4,6 +4,13 @@
## Текущие задачи
[ ] **Android: отмена асинхронного запуска до готовности ядра** — при проверке
мониторинга 12.10.2026 обнаружено: `NativeLib.stop()` пропускает native stop,
если `isReady()==false`, а `instance_lite_stop()` возвращается при `g_inst==NULL`.
Поэтому запрос stop до завершения конструктора может не отменить запускаемый
поток. Согласовать отмену с состоянием потока и единым сигналом завершения;
проверить stop до SERVICE_STARTED, отказ конструктора и последующий start.
[ ] **Android: повторные отказы persisted node snapshots** — замечено 12.10.2026
на устройстве D2AR10P3600820 при проверке live media. В сохранённой БД ряд узлов
регулярно получает `invalid persisted snapshot` в `topo_node_sqlite_snapshot_load`,

21
src/transport_layer/auto_socket.c

@ -1187,8 +1187,7 @@ static void auto_socket_netlink_cb(int fd, void* arg) {
static int auto_socket_init_monitor(struct AUTO_SOCKET* as) {
as->nl_sock = socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE);
if (as->nl_sock < 0) {
DEBUG_INFO(DEBUG_CATEGORY_AS, "[as] netlink socket: %s — relying on JNI/scan_all for updates", strerror(errno));
return 0;
DEBUG_ERROR(DEBUG_CATEGORY_AS, "[as] netlink socket failed: %s", strerror(errno)); return -1;
}
struct sockaddr_nl nladdr;
@ -1196,12 +1195,15 @@ static int auto_socket_init_monitor(struct AUTO_SOCKET* as) {
nladdr.nl_family = AF_NETLINK;
nladdr.nl_groups = RTMGRP_LINK | RTMGRP_IPV4_IFADDR | RTMGRP_IPV6_IFADDR | RTMGRP_IPV4_ROUTE | RTMGRP_IPV6_ROUTE;
if (bind(as->nl_sock, (struct sockaddr*)&nladdr, sizeof(nladdr)) < 0) {
DEBUG_INFO(DEBUG_CATEGORY_AS, "[as] netlink bind: %s — relying on JNI/scan_all for updates", strerror(errno));
close(as->nl_sock); as->nl_sock = -1; return 0;
DEBUG_ERROR(DEBUG_CATEGORY_AS, "[as] netlink bind failed: %s", strerror(errno));
close(as->nl_sock); as->nl_sock = -1; return -1;
}
if (socket_set_nonblocking(as->nl_sock) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_AS, "[as] netlink nonblocking mode failed: %s", strerror(errno));
close(as->nl_sock); as->nl_sock = -1; return -1;
}
socket_set_nonblocking(as->nl_sock);
as->uasync_handle = uasync_add_socket(as->instance->ua, as->nl_sock, auto_socket_netlink_cb, NULL, NULL, "netlink_autosock", as);
if (!as->uasync_handle) { DEBUG_ERROR(DEBUG_CATEGORY_AS, "[as] uasync_add_socket failed"); close(as->nl_sock); as->nl_sock = -1; return 0; }
if (!as->uasync_handle) { DEBUG_ERROR(DEBUG_CATEGORY_AS, "[as] uasync_add_socket failed"); close(as->nl_sock); as->nl_sock = -1; return -1; }
DEBUG_INFO(DEBUG_CATEGORY_AS, "[as] netlink monitor started: fd=%d", (int)as->nl_sock);
return 0;
}
@ -1303,6 +1305,11 @@ int auto_socket_init(struct UTUN_INSTANCE* inst) {
struct AUTO_SOCKET* as = u_calloc(1, sizeof(*as));
if (!as) { DEBUG_ERROR(DEBUG_CATEGORY_AS, "[as] alloc failed"); return -1; }
#ifdef __linux__
as->nl_sock = -1;
#elif defined(__FreeBSD__) || defined(__FreeBSD_kernel__)
as->route_sock = -1;
#endif
as->instance = inst;
as->mode = (uint8_t)inst->config->global.auto_sockets;
inst->auto_socket_state = as;
@ -1343,7 +1350,7 @@ void auto_socket_destroy(struct UTUN_INSTANCE* inst) {
struct AUTO_SOCKET* as = (struct AUTO_SOCKET*)inst->auto_socket_state;
inst->auto_socket_state = NULL;
auto_socket_destroy_monitor(as);
if (as->mode != CFG_AUTO_SOCKETS_ANDROID) auto_socket_destroy_monitor(as);
while (as->ifaces) {
struct auto_sock_iface* ifa = as->ifaces;

4
src/transport_layer/socket_monitor.c

@ -413,6 +413,10 @@ static void WINAPI route_change_cb(PVOID ctx, PMIB_IPFORWARD_ROW2 row, MIB_NOTIF
int socket_monitor_init(struct UTUN_INSTANCE* instance) {
if (!instance) { DEBUG_ERROR(DEBUG_CATEGORY_SOCKET, "instance is NULL"); return -1; }
if (instance->socket_monitor) { DEBUG_ERROR(DEBUG_CATEGORY_SOCKET, "socket monitor already initialized"); return -1; }
if (instance->config && instance->config->global.auto_sockets == CFG_AUTO_SOCKETS_ANDROID) {
DEBUG_INFO(DEBUG_CATEGORY_SOCKET, "Socket monitoring delegated to external network callbacks (auto_sockets=android)");
return 0;
}
DEBUG_INFO(DEBUG_CATEGORY_SOCKET, "initializing socket monitor");
#ifdef __linux__

1
src/transport_layer/socket_monitor.h

@ -12,6 +12,7 @@ extern "C" {
struct UTUN_INSTANCE;
struct ETCP_SOCKET;
/* В режиме auto_sockets=android адресами управляет frontend; нативный монитор не создаётся. */
int socket_monitor_init(struct UTUN_INSTANCE* instance);
void socket_monitor_destroy(struct UTUN_INSTANCE* instance);

3
tests/Makefile.am

@ -832,7 +832,8 @@ test_services_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS)
test_tun_route_SOURCES = test_tun_route.c
test_tun_route_LDADD = $(LIBUTUN) $(COMMON_LIBS)
test_tun_route_LDFLAGS = -Wl,--wrap=sendto -Wl,--wrap=recvfrom -Wl,--wrap=poll -Wl,--wrap=tun_platform_init
test_tun_route_LDFLAGS = -Wl,--wrap=sendto -Wl,--wrap=recvfrom -Wl,--wrap=poll -Wl,--wrap=tun_platform_init \
-Wl,--wrap=socket -Wl,--wrap=bind
test_member_files_SOURCES = test_member_files.c
test_member_files_CFLAGS = $(test_services_CFLAGS)

49
tests/test_tun_route.c

@ -3,6 +3,8 @@
#include <errno.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/socket.h>
#include <linux/netlink.h>
#include <linux/rtnetlink.h>
@ -12,6 +14,8 @@
#include "tun_route.h"
#include "tun_if.h"
#include "chat/chat_core.h"
#include "transport_layer/auto_socket.h"
#include "transport_layer/etcp_connections.h"
#include "../lib/debug_config.h"
#include "../lib/mem.h"
@ -20,6 +24,19 @@ static uint32_t added[8], removed[8], request_seq;
static uint16_t request_cmd;
static int ack_error;
static int fail_tun;
static int deny_netlink, netlink_calls;
int __real_socket(int, int, int);
int __wrap_socket(int domain, int type, int protocol) {
if (domain == AF_NETLINK) netlink_calls++;
return __real_socket(domain, type, protocol);
}
int __real_bind(int, const struct sockaddr*, socklen_t);
int __wrap_bind(int fd, const struct sockaddr* addr, socklen_t len) {
if (deny_netlink && addr->sa_family == AF_NETLINK) { errno = EACCES; return -1; }
return __real_bind(fd, addr, len);
}
int __real_tun_platform_init(struct tun_if*, const char*, const char*, int);
int __wrap_tun_platform_init(struct tun_if* tun, const char* name, const char* ip, int mtu) {
@ -135,6 +152,7 @@ static void check_mock(void) {
/* Ошибка второго listen-сокета отклоняет весь запуск, включая начальный auto_sockets=android. */
static void check_socket_failure(int automatic) {
int stdin_flags = fcntl(STDIN_FILENO, F_GETFD); assert(stdin_flags >= 0);
int blocker = socket(AF_INET, SOCK_STREAM, 0); assert(blocker >= 0);
struct sockaddr_in address = { .sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK) };
assert(bind(blocker, (struct sockaddr*)&address, sizeof(address)) == 0 && listen(blocker, 1) == 0);
@ -147,9 +165,38 @@ static void check_socket_failure(int automatic) {
struct UASYNC* ua = uasync_create(); assert(ua);
assert(!utun_instance_create_from_str(ua, config));
uasync_drain_immediate(ua); uasync_destroy(ua, 0); close(blocker);
assert(fcntl(STDIN_FILENO, F_GETFD) == stdin_flags);
DEBUG_INFO(DEBUG_CATEGORY_TUN, "PASS second socket failure rejects startup and cleans opened sockets automatic=%d", automatic);
}
/* Android/JNI backend запускается без разрешений netlink и принимает свежие адреса без рестарта. */
static void check_external_monitor(void) {
const char* config = "[global]\ntun_enabled=no\nauto_sockets=android\n"
"[server:external]\naddr=127.0.0.1:0\ntransport=udp\ntype=private\n"
"[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\n";
int stdin_flags = fcntl(STDIN_FILENO, F_GETFD); assert(stdin_flags >= 0);
struct UASYNC* ua = uasync_create(); assert(ua);
deny_netlink = 1; netlink_calls = 0;
struct UTUN_INSTANCE* inst = utun_instance_create_from_str(ua, config);
assert(inst && !inst->socket_monitor && inst->auto_socket_state && !netlink_calls);
inst->config->global.db_sync_enabled = 0;
assert(utun_core_start(inst) == 0 && chat_service_start(inst) == 0);
sqlite3* db = inst->topo_sqlite_db; void* chat = inst->chat_core;
struct utun_config* updated = parse_config_from_buf(config, strlen(config), "external-update"); assert(updated);
((struct sockaddr_in*)&updated->servers->ip)->sin_addr.s_addr = htonl(0x7f000002);
auto_socket_reload_config(inst, updated);
assert(inst->core_started && inst->chat_core == chat && inst->topo_sqlite_db == db);
assert(((struct sockaddr_in*)&inst->etcp_sockets->local_addr)->sin_addr.s_addr == htonl(0x7f000002));
assert(!netlink_calls);
destroy(inst, ua); assert(fcntl(STDIN_FILENO, F_GETFD) == stdin_flags);
DEBUG_INFO(DEBUG_CATEGORY_TUN, "PASS external monitoring: no netlink, core/chat ready, address update preserves DB and fd=0");
const char* native = "[global]\ntun_enabled=no\n[server:local]\naddr=127.0.0.1:0\ntype=private\n";
ua = uasync_create(); assert(ua);
assert(!utun_instance_create_from_str(ua, native) && netlink_calls > 0);
uasync_drain_immediate(ua); uasync_destroy(ua, 0); deny_netlink = 0;
DEBUG_INFO(DEBUG_CATEGORY_TUN, "PASS native monitoring failure remains fatal");
}
static void check_control_failure(void) {
int blocker = socket(AF_INET, SOCK_STREAM, 0); assert(blocker >= 0);
struct sockaddr_in address = { .sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK) };
@ -189,6 +236,6 @@ int main(int argc, char** argv) {
utun_instance_set_tun_init_enabled(1);
size_t baseline = u_get_allocated_count();
if (argc == 2 && !strcmp(argv[1], "--real")) check_real();
else { check_mock(); check_socket_failure(0); check_socket_failure(1); check_control_failure(); }
else { check_mock(); check_socket_failure(0); check_socket_failure(1); check_control_failure(); check_external_monitor(); }
assert(u_get_allocated_count() == baseline); return 0;
}

1
tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt

@ -31,6 +31,7 @@ object AppEventHandler : ChatEventCallback {
val events: SharedFlow<Pair<Int, ByteArray?>> = _events
override fun onEvent(type: Int, data: ByteArray?) {
if (type == 12) mainHandler.post { com.utun.chat.headless.HeadlessService.onCoreStarted() }
if (type == 13 || type in 49..50) mainHandler.post { ChatApplication.instance.memberFiles.onEvent(type, data) }
if (type in listOf(4, 8, 9, 12, 13, 23, 24, 25, 31) || type in 49..53)
mainHandler.post { ChatApplication.instance.avatars.onEvent(type, data) }

126
tools/chatgui-android/app/src/main/java/com/utun/chat/data/NetworkChangeMonitor.kt

@ -7,93 +7,99 @@ import android.os.Handler
import android.os.Looper
import com.utun.chat.ChatApplication
/**
* Слушатель смены сети (ConnectivityManager.NetworkCallback).
*
* На Android нативный netlink-мониторинг сокетов недоступен (Permission denied),
* поэтому о смене сети сообщает ConnectivityManager. При смене default-сети
* (mobile↔wifi, смена IP) пересобираем конфиг сокетов и перезапускаем C-ядро,
* чтобы оно забиндилось на актуальный интерфейс.
*
* Регистрируется в HeadlessService (foreground) — работает в фоне.
*/
/** ConnectivityManager передаёт актуальный конфиг сокетов в uasync без рестарта ядра.
* Регистрация живёт в HeadlessService; состояние очереди обновлений — на main thread. */
object NetworkChangeMonitor {
private var started = false
/* Пропуск первого onAvailable: он приходит сразу при регистрации с текущим
* состоянием сети, а не как реальное событие смены. */
private var primed = false
/* Рестарт в процессе — защита от наложения (C-ядро тоже имеет CAS-гарды). */
private var restarting = false
/* Последний известный набор интерфейсов (ifName=ip) для лога «было → стало». */
private var generation = 0
private var updating = false
private var pending = false
private var lastInterfaces: List<Pair<String, String>>? = null
private const val DEBOUNCE_MS = 500L
private val handler = Handler(Looper.getMainLooper())
private val restartRunnable = Runnable { doRestart() }
private val updateRunnable = Runnable { updateSockets() }
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
if (!primed) { primed = true; return }
schedule()
}
override fun onLost(network: Network) {
if (!primed) return
schedule()
}
override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
if (!primed) return
schedule()
}
override fun onAvailable(network: Network) = schedule()
override fun onLost(network: Network) = schedule()
override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) = schedule()
}
private fun schedule() {
handler.removeCallbacks(restartRunnable)
handler.postDelayed(restartRunnable, DEBOUNCE_MS)
handler.post {
if (!started) return@post
handler.removeCallbacks(updateRunnable)
handler.postDelayed(updateRunnable, DEBOUNCE_MS)
}
}
private fun doRestart() {
if (restarting || !NativeLib.isReady()) return
restarting = true
private fun updateSockets() {
if (!started || !NativeLib.isReady()) return
if (updating) { pending = true; return }
updating = true
val token = generation
Thread {
var interfaces: List<Pair<String, String>>? = null
var config: String? = null
try {
val provider = ChatApplication.instance.configProvider
val now = provider.getAvailableInterfaces()
val prev = lastInterfaces
lastInterfaces = now
val prevStr = prev?.joinToString(", ") { (n, ip) -> "$n=$ip" } ?: "n/a"
val nowStr = now.joinToString(", ") { (n, ip) -> "$n=$ip" }
LogManager.addLog("INFO", "NET", "network change: [$prevStr] -> [$nowStr]")
NativeLib.notifyNetworkActive()
NativeLib.updateSockets(provider.buildConfigText())
interfaces = provider.getAvailableInterfaces()
config = provider.buildConfigText()
} catch (e: Exception) {
LogManager.addLog("ERROR", "NET", "network config update failed: ${e.message}")
} finally {
restarting = false
val current = interfaces
val text = config
handler.post {
if (!started || token != generation) return@post
updating = false
if (text != null && current != null && NativeLib.isReady()) {
val previous = lastInterfaces?.joinToString(", ") { (n, ip) -> "$n=$ip" } ?: "n/a"
val next = current.joinToString(", ") { (n, ip) -> "$n=$ip" }
lastInterfaces = current
LogManager.addLog("INFO", "NET", "network change: [$previous] -> [$next]")
NativeLib.notifyNetworkActive()
if (!NativeLib.updateSockets(text)) LogManager.addLog("ERROR", "NET", "socket update rejected")
}
if (pending) { pending = false; schedule() }
}
}
}.start()
}
fun start() {
if (started) return
val ctx = ChatApplication.instance.applicationContext
val cm = ctx.getSystemService(ConnectivityManager::class.java) ?: return
/* Если сети ещё нет — первый onAvailable будет реальным событием. */
primed = cm.activeNetwork == null
cm.registerDefaultNetworkCallback(callback)
// Повторный снимок после SERVICE_STARTED покрывает изменения сети во время инициализации.
fun onCoreStarted() = schedule()
fun start(): Boolean {
if (started) return true
val cm = ChatApplication.instance.getSystemService(ConnectivityManager::class.java)
if (cm == null) { LogManager.addLog("ERROR", "NET", "ConnectivityManager unavailable"); return false }
try {
cm.registerDefaultNetworkCallback(callback)
} catch (e: Exception) {
LogManager.addLog("ERROR", "NET", "network listener registration failed: ${e.message}")
return false
}
generation++
started = true
LogManager.addLog("INFO", "NET", "network change listener started")
return true
}
fun stop() {
if (!started) return
val ctx = ChatApplication.instance.applicationContext
val cm = ctx.getSystemService(ConnectivityManager::class.java) ?: return
cm.unregisterNetworkCallback(callback)
handler.removeCallbacks(restartRunnable)
started = false
generation++
pending = false
updating = false
lastInterfaces = null
handler.removeCallbacks(updateRunnable)
val cm = ChatApplication.instance.getSystemService(ConnectivityManager::class.java)
try {
cm?.unregisterNetworkCallback(callback)
} catch (e: Exception) {
LogManager.addLog("WARN", "NET", "network listener removal failed: ${e.message}")
}
LogManager.addLog("INFO", "NET", "network change listener stopped")
}
}

20
tools/chatgui-android/app/src/main/java/com/utun/chat/headless/HeadlessService.kt

@ -22,6 +22,13 @@ class HeadlessService : Service() {
companion object {
private var currentService: HeadlessService? = null
fun onCoreStarted() {
if (currentService == null || !NativeLib.isReady()) return
LogManager.addLog("INFO", "SRV", "uTun core started")
NetworkActivityMonitor.start()
NetworkChangeMonitor.onCoreStarted()
}
fun refreshLanguage() {
currentService?.let { service ->
service.createNotificationChannel()
@ -45,18 +52,19 @@ class HeadlessService : Service() {
val dbDir = provider.getString("db.path") ?: filesDir.absolutePath
val listenPort = provider.getInt("node.listen_port")
val configText = provider.buildConfigText()
LogManager.addLog("INFO", "SRV", "uTun service starting, port=$listenPort db=$dbDir")
NativeLib.init(dbDir, 9999)
val ok = NativeLib.start(configText)
if (!NetworkChangeMonitor.start()) {
LogManager.addLog("ERROR", "SRV", "uTun startup rejected: network monitoring unavailable")
return
}
val ok = NativeLib.start(provider.buildConfigText())
if (ok) {
LogManager.addLog("INFO", "SRV", "uTun core started")
NetworkActivityMonitor.start()
NetworkChangeMonitor.start()
LogManager.addLog("INFO", "SRV", "uTun startup requested")
} else {
NetworkChangeMonitor.stop()
LogManager.addLog("ERROR", "SRV", "uTun start failed")
}
}

3
tools/chatgui-android/libutun_lite/instance_lite.c

@ -284,6 +284,7 @@ static void* instance_thread(void* arg) {
my_inst = g_inst;
if (!g_inst) {
IL_LOGE("utun_instance_create_from_config failed");
free_config(config);
standby_deinit();
udp_log_detach(g_ua);
uasync_destroy(g_ua, 0);
@ -384,7 +385,7 @@ static void* instance_thread(void* arg) {
g_inst = utun_instance_create_from_config(g_ua, config);
my_inst = g_inst;
if (!g_inst) { IL_LOGE("poll exit: create_from_config failed"); break; }
if (!g_inst) { IL_LOGE("poll exit: create_from_config failed"); free_config(config); break; }
chat_event_set_handler(g_inst, chat_event_forward);
/* Recreate core and chat independently of UTUN. */

Loading…
Cancel
Save