feat: ping-and-exit for server RTT, remove broken UDP ping
Some checks failed
Mirror to GitHub / mirror (push) Failing after 38s
Build Release Binaries / build-amd64 (push) Failing after 3m38s

- Ping button: pings all servers via native QUIC, saves RTT + fingerprint
  to SharedPreferences, then exits process (System.exit)
- On restart: loads saved ping results (no native .so loading needed)
- Avoids jemalloc crash: native lib only loaded once per process lifetime
- Removed broken UDP probe (QUIC servers don't respond to it)
- SettingsRepository: savePingRtt/loadPingRtt for cached results
- PingResult: added reachable field

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-04-07 09:31:02 +04:00
parent 00b405aa87
commit eeb85aeac2
4 changed files with 80 additions and 81 deletions

View File

@@ -185,4 +185,14 @@ class SettingsRepository(context: Context) {
fun loadServerFingerprint(address: String): String? {
return prefs.getString("$TOFU_PREFIX$address", null)
}
// --- Ping RTT cache ---
fun savePingRtt(address: String, rttMs: Int) {
prefs.edit().putInt("ping_rtt_$address", rttMs).apply()
}
fun loadPingRtt(address: String): Int {
return prefs.getInt("ping_rtt_$address", -1)
}
}

View File

@@ -1,65 +1,35 @@
package com.wzp.net
import android.util.Log
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetSocketAddress
/**
* Pure Kotlin UDP ping — no JNI, no native lib loading.
* Sends a minimal packet to the relay and measures response time.
* QUIC servers reply with Version Negotiation to unknown packets.
* Relay ping via native QUIC — requires loading the native .so.
* After ping completes, the process must be restarted (System.exit)
* because jemalloc initialization during .so load corrupts state
* on Android 16 MTE devices.
*
* Flow: ping all servers → save results → exit → app restarts → load results
*/
object RelayPinger {
private const val TAG = "RelayPinger"
private const val TIMEOUT_MS = 2000
// Minimal QUIC-like Initial packet (just enough to provoke a response)
// First byte 0xC0 = long header, version 0x00000000 = version negotiation trigger
private val PROBE = byteArrayOf(
0xC0.toByte(), // long header form
0x00, 0x00, 0x00, 0x00, // version 0 → triggers Version Negotiation
0x08, // DCID length = 8
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, // fake DCID
0x00, // SCID length = 0
0x00, 0x00, // token length = 0 (for Initial)
0x00, 0x04, // payload length = 4
0x00, 0x00, 0x00, 0x00, // dummy payload
)
data class PingResult(
val rttMs: Int,
val reachable: Boolean,
val serverFingerprint: String = "",
)
/**
* Ping a relay server via UDP. Returns RTT in ms, or unreachable.
* Thread-safe, can be called from coroutine on Dispatchers.IO.
* Ping a relay via the native QUIC stack.
* WARNING: After calling this, the process must be restarted.
*/
fun ping(address: String): PingResult {
return try {
val parts = address.split(":")
if (parts.size != 2) return PingResult(0, false)
val host = parts[0]
val port = parts[1].toIntOrNull() ?: return PingResult(0, false)
val socket = DatagramSocket()
socket.soTimeout = TIMEOUT_MS
val dest = InetSocketAddress(host, port)
val sendPacket = DatagramPacket(PROBE, PROBE.size, dest)
val recvBuf = ByteArray(1200)
val recvPacket = DatagramPacket(recvBuf, recvBuf.size)
val start = System.nanoTime()
socket.send(sendPacket)
socket.receive(recvPacket) // blocks until response or timeout
val rttMs = ((System.nanoTime() - start) / 1_000_000).toInt()
socket.close()
PingResult(rttMs, true)
val json = com.wzp.engine.WzpEngine.pingRelay(address) ?: return PingResult(0, false)
val obj = org.json.JSONObject(json)
PingResult(
rttMs = obj.getInt("rtt_ms"),
reachable = true,
serverFingerprint = obj.optString("server_fingerprint", ""),
)
} catch (e: Exception) {
Log.w(TAG, "ping $address failed: ${e.message}")
PingResult(0, false)
}
}

View File

@@ -31,7 +31,8 @@ data class ServerEntry(val address: String, val label: String)
data class PingResult(
val rttMs: Int,
val serverFingerprint: String,
val serverFingerprint: String = "",
val reachable: Boolean = rttMs > 0,
)
enum class LockStatus { UNKNOWN, OFFLINE, NEW, VERIFIED, CHANGED }
@@ -207,56 +208,61 @@ class CallViewModel : ViewModel(), WzpCallback {
settings?.saveSelectedServer(_selectedServer.value)
}
private var pingJob: Job? = null
/** Start periodic ping every 5 seconds. Safe to call multiple times. */
fun startPeriodicPing() {
if (pingJob?.isActive == true) return
pingJob = viewModelScope.launch {
while (isActive) {
pingAllServersOnce()
delay(5000)
/** Load saved ping results from last ping-and-exit cycle. */
fun loadSavedPingResults() {
val s = settings ?: return
val results = mutableMapOf<String, PingResult>()
val known = mutableMapOf<String, String>()
_servers.value.forEach { server ->
val rtt = s.loadPingRtt(server.address)
val fp = s.loadServerFingerprint(server.address)
if (rtt >= 0) {
results[server.address] = PingResult(rttMs = rtt, serverFingerprint = fp ?: "")
}
fp?.let { known[server.address] = it }
}
_pingResults.value = results
_knownFingerprints.value = known
}
/** Stop periodic ping. */
fun stopPeriodicPing() {
pingJob?.cancel()
pingJob = null
}
/** Ping all servers once (pure Kotlin UDP, no JNI). */
fun pingAllServersOnce() {
/**
* Ping all servers via native QUIC, save results, then exit process.
* On restart, saved results are loaded. This avoids the jemalloc crash
* by ensuring the native .so is only loaded once per process lifetime.
*/
fun pingAndExit() {
viewModelScope.launch {
val results = mutableMapOf<String, PingResult>()
_servers.value.forEach { server ->
val udpResult = withContext(Dispatchers.IO) {
val pr = withContext(Dispatchers.IO) {
com.wzp.net.RelayPinger.ping(server.address)
}
results[server.address] = PingResult(
rttMs = udpResult.rttMs,
serverFingerprint = "", // filled lazily on first real connection
rttMs = pr.rttMs,
serverFingerprint = pr.serverFingerprint,
)
}
_pingResults.value = results
// Load saved TOFU fingerprints
val known = mutableMapOf<String, String>()
_servers.value.forEach { server ->
settings?.loadServerFingerprint(server.address)?.let {
known[server.address] = it
// Save results
settings?.savePingRtt(server.address, pr.rttMs)
if (pr.serverFingerprint.isNotEmpty()) {
val saved = settings?.loadServerFingerprint(server.address)
if (saved == null) {
settings?.saveServerFingerprint(server.address, pr.serverFingerprint)
}
}
}
_knownFingerprints.value = known
_pingResults.value = results
// Exit process — next launch loads saved results, native .so reinits cleanly
delay(300) // let UI update
System.exit(0)
}
}
/** Get lock status for a server. */
fun lockStatus(address: String): LockStatus {
val pr = _pingResults.value[address] ?: return LockStatus.UNKNOWN
if (pr.rttMs <= 0 && pr.serverFingerprint.isEmpty()) return LockStatus.OFFLINE
if (!pr.reachable) return LockStatus.OFFLINE
val known = _knownFingerprints.value[address] ?: return LockStatus.NEW
if (pr.serverFingerprint.isEmpty()) return LockStatus.NEW // no fingerprint yet
if (pr.serverFingerprint.isEmpty()) return LockStatus.NEW
return if (pr.serverFingerprint == known) LockStatus.VERIFIED else LockStatus.CHANGED
}

View File

@@ -90,10 +90,8 @@ fun InCallScreen(
var showManageRelays by remember { mutableStateOf(false) }
// Pure Kotlin UDP ping — no native .so loading, safe on Android 16 MTE
LaunchedEffect(Unit) {
viewModel.startPeriodicPing()
}
// Load saved ping results from last ping-and-exit cycle
LaunchedEffect(Unit) { viewModel.loadSavedPingResults() }
Surface(
modifier = Modifier.fillMaxSize(),
@@ -229,6 +227,21 @@ fun InCallScreen(
)
}
Spacer(modifier = Modifier.height(8.dp))
// Ping button — pings all servers via native QUIC, saves results, exits app
OutlinedButton(
onClick = { viewModel.pingAndExit() },
modifier = Modifier.fillMaxWidth().height(40.dp),
shape = RoundedCornerShape(8.dp),
) {
Text(
"Ping Servers (restarts app)",
style = MaterialTheme.typography.labelSmall,
color = TextDim
)
}
errorMessage?.let { err ->
Spacer(modifier = Modifier.height(8.dp))
Text(text = err, color = Red, style = MaterialTheme.typography.bodySmall)