feat(tunnel): pipelined polls with adaptive depth, wseq ordering, STUN blocking (#1115)

feat(tunnel): pipelined full-tunnel polls, ordered writes, and STUN blocking

Merged trusted PR #1115 by @yyoyoian-pixel after local verification and a small maintainer fix on the PR branch.

---
Answered via LLM, Supervised @therealaleph
This commit is contained in:
yyoyoian-pixel
2026-05-16 16:46:08 +02:00
committed by GitHub
parent d822d67a26
commit 919b13b166
14 changed files with 1422 additions and 209 deletions
+1
View File
@@ -17,6 +17,7 @@
prompt.
-->
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!--
App-launcher visibility filter. Complements QUERY_ALL_PACKAGES:
@@ -108,6 +108,8 @@ data class MhrvConfig(
val coalesceMaxMs: Int = 1000,
/** Block QUIC (UDP/443). QUIC over TCP tunnel causes meltdown. */
val blockQuic: Boolean = true,
/** Block STUN/TURN ports (3478/5349/19302). Forces WebRTC TCP fallback. */
val blockStun: Boolean = true,
val upstreamSocks5: String = "",
/**
@@ -231,6 +233,7 @@ data class MhrvConfig(
if (coalesceStepMs != 10) put("coalesce_step_ms", coalesceStepMs)
if (coalesceMaxMs != 1000) put("coalesce_max_ms", coalesceMaxMs)
put("block_quic", blockQuic)
put("block_stun", blockStun)
if (upstreamSocks5.isNotBlank()) {
put("upstream_socks5", upstreamSocks5.trim())
}
@@ -344,6 +347,7 @@ object ConfigStore {
if (cfg.coalesceStepMs != defaults.coalesceStepMs) obj.put("coalesce_step_ms", cfg.coalesceStepMs)
if (cfg.coalesceMaxMs != defaults.coalesceMaxMs) obj.put("coalesce_max_ms", cfg.coalesceMaxMs)
if (cfg.blockQuic != defaults.blockQuic) obj.put("block_quic", cfg.blockQuic)
if (cfg.blockStun != defaults.blockStun) obj.put("block_stun", cfg.blockStun)
if (cfg.upstreamSocks5.isNotBlank()) obj.put("upstream_socks5", cfg.upstreamSocks5)
if (cfg.passthroughHosts.isNotEmpty()) obj.put("passthrough_hosts", JSONArray().apply { cfg.passthroughHosts.forEach { put(it) } })
if (cfg.tunnelDoh != defaults.tunnelDoh) obj.put("tunnel_doh", cfg.tunnelDoh)
@@ -449,6 +453,7 @@ object ConfigStore {
coalesceStepMs = obj.optInt("coalesce_step_ms", 10),
coalesceMaxMs = obj.optInt("coalesce_max_ms", 1000),
blockQuic = obj.optBoolean("block_quic", true),
blockStun = obj.optBoolean("block_stun", true),
upstreamSocks5 = obj.optString("upstream_socks5", ""),
passthroughHosts = obj.optJSONArray("passthrough_hosts")?.let { arr ->
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
@@ -35,6 +35,7 @@ class MhrvVpnService : VpnService() {
private var proxyHandle: Long = 0L
private var tun2proxyThread: Thread? = null
private val tun2proxyRunning = AtomicBoolean(false)
private var debugOverlay: PipelineDebugOverlay? = null
// Idempotency guard. teardown() is reachable from three paths:
// 1. ACTION_STOP onStartCommand branch (background thread)
@@ -149,6 +150,7 @@ class MhrvVpnService : VpnService() {
Log.i(TAG, "PROXY_ONLY mode: listeners up, skipping VpnService/TUN")
VpnState.setProxyHandle(proxyHandle)
VpnState.setRunning(true)
showDebugOverlay()
return
}
@@ -314,6 +316,16 @@ class MhrvVpnService : VpnService() {
// a failed-to-establish run.
VpnState.setProxyHandle(proxyHandle)
VpnState.setRunning(true)
showDebugOverlay()
}
private fun showDebugOverlay() {
if (debugOverlay != null) return
if (!android.provider.Settings.canDrawOverlays(this)) {
Log.w(TAG, "overlay permission not granted — skipping debug overlay")
return
}
debugOverlay = PipelineDebugOverlay(this).also { it.show() }
}
/**
@@ -434,6 +446,10 @@ class MhrvVpnService : VpnService() {
Log.w(TAG, "tun2proxy thread still alive after join timeout — proceeding anyway")
}
// Hide debug overlay before flipping UI state.
debugOverlay?.hide()
debugOverlay = null
// Flip UI state last — the button reverts to Connect only after
// the native-side cleanup actually happened, not optimistically.
VpnState.setProxyHandle(0L)
@@ -110,6 +110,13 @@ object Native {
*/
external fun statsJson(handle: Long): String
/**
* Pipeline debug overlay snapshot. Returns a JSON blob with elevated
* session count, batch semaphore usage, and recent ramp/drop events.
* Temporary — for debugging pipeline behavior on-device.
*/
external fun pipelineDebugJson(): String
/**
* Start tun2proxy via its CLI args C API (`tun2proxy_run_with_cli_args`).
* Resolved at runtime via dlsym from libtun2proxy.so — no fork needed.
@@ -0,0 +1,174 @@
package com.therealaleph.mhrv
import android.content.Context
import android.graphics.Color
import android.graphics.PixelFormat
import android.os.Handler
import android.os.Looper
import android.util.TypedValue
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
import android.widget.LinearLayout
import android.widget.TextView
import org.json.JSONObject
/**
* Transparent system overlay showing pipeline debug stats.
* Draggable, semi-transparent, shown on top of all apps.
* Temporary — remove when pipelining is validated.
*/
class PipelineDebugOverlay(private val context: Context) {
private val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private val handler = Handler(Looper.getMainLooper())
private var root: View? = null
private lateinit var tvElevated: TextView
private lateinit var tvBatches: TextView
private lateinit var tvEvents: TextView
private val pollInterval = 500L
fun show() {
if (root != null) return
val dp = { px: Int ->
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, px.toFloat(), context.resources.displayMetrics).toInt()
}
val layout = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(Color.argb(160, 0, 0, 0))
setPadding(dp(8), dp(6), dp(8), dp(6))
}
val titleTv = TextView(context).apply {
text = "Pipeline Debug"
setTextColor(Color.argb(220, 100, 255, 100))
textSize = 11f
}
layout.addView(titleTv)
tvElevated = TextView(context).apply {
setTextColor(Color.WHITE)
textSize = 10f
}
layout.addView(tvElevated)
tvBatches = TextView(context).apply {
setTextColor(Color.WHITE)
textSize = 10f
}
layout.addView(tvBatches)
tvEvents = TextView(context).apply {
setTextColor(Color.argb(200, 200, 200, 200))
textSize = 9f
maxLines = 8
}
layout.addView(tvEvents)
val params = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
PixelFormat.TRANSLUCENT,
).apply {
gravity = Gravity.TOP or Gravity.START
x = dp(8)
y = dp(80)
}
// Draggable
var startX = 0
var startY = 0
var startTouchX = 0f
var startTouchY = 0f
layout.setOnTouchListener { _, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
startX = params.x
startY = params.y
startTouchX = event.rawX
startTouchY = event.rawY
true
}
MotionEvent.ACTION_MOVE -> {
params.x = startX + (event.rawX - startTouchX).toInt()
params.y = startY + (event.rawY - startTouchY).toInt()
wm.updateViewLayout(layout, params)
true
}
else -> false
}
}
root = layout
wm.addView(layout, params)
schedulePoll()
}
fun hide() {
handler.removeCallbacksAndMessages(null)
root?.let {
try { wm.removeView(it) } catch (_: Throwable) {}
}
root = null
}
private fun schedulePoll() {
handler.postDelayed(::poll, pollInterval)
}
private fun poll() {
if (root == null) return
Thread {
try {
val json = Native.pipelineDebugJson()
handler.post { applyJson(json) }
} catch (_: Throwable) {}
schedulePoll()
}.start()
}
private fun applyJson(json: String) {
if (root == null) return
try {
if (json.isNotBlank()) {
val obj = JSONObject(json)
val elevated = obj.optInt("elevated", 0)
val maxElev = obj.optInt("max_elevated", 0)
val batches = obj.optInt("active_batches", 0)
val maxBatch = obj.optInt("max_batch_slots", 0)
val sessions = obj.optInt("active_sessions", 0)
tvElevated.text = "Sessions: $sessions Elevated: $elevated / $maxElev"
tvBatches.text = "Batches: $batches / $maxBatch"
val sessArr = obj.optJSONArray("sessions")
val sessLines = if (sessArr != null && sessArr.length() > 0) {
(0 until sessArr.length()).joinToString("\n") { i ->
val s = sessArr.getJSONObject(i)
val sid = s.optString("sid", "?")
val d = s.optInt("depth", 0)
val inf = s.optInt("inflight", 0)
val e = if (s.optBoolean("elevated", false)) " E" else ""
"$sid d=$d f=$inf$e"
}
} else ""
val arr = obj.optJSONArray("events")
val evtLines = if (arr != null && arr.length() > 0) {
val start = maxOf(0, arr.length() - 5)
(start until arr.length()).joinToString("\n") { arr.getString(it) }
} else ""
tvEvents.text = listOf(sessLines, evtLines).filter { it.isNotEmpty() }.joinToString("\n---\n")
}
} catch (_: Throwable) {}
}
}
@@ -491,6 +491,7 @@ fun HomeScreen(
// client-side estimate only sees what this device relayed,
// not what other devices on the same deployment consumed.
UsageTodayCard()
PipelineDebugCard()
CollapsibleSection(title = stringResource(R.string.sec_live_logs), initiallyExpanded = false) {
LiveLogPane()
@@ -1287,6 +1288,28 @@ private fun AdvancedSettings(
)
}
// Block STUN/TURN toggle
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
"Block STUN/TURN",
style = MaterialTheme.typography.bodyMedium,
)
Text(
"Reject STUN/TURN ports (3478/5349/19302). Forces WebRTC apps (Meet, WhatsApp) to TCP fallback — instant connect.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = cfg.blockStun,
onCheckedChange = { onChange(cfg.copy(blockStun = it)) },
)
}
// Block DoH toggle
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -1645,6 +1668,104 @@ private fun UsageRow(label: String, value: String) {
}
}
@Composable
private fun PipelineDebugCard() {
val isRunning by VpnState.isRunning.collectAsState()
if (!isRunning) return
var json by remember { mutableStateOf("") }
LaunchedEffect(isRunning) {
if (!isRunning) return@LaunchedEffect
while (true) {
val result = withContext(Dispatchers.IO) {
runCatching { Native.pipelineDebugJson() }
}
json = result.getOrDefault("")
if (result.isFailure) {
android.util.Log.e("PipeDbg", "pipelineDebugJson failed", result.exceptionOrNull())
}
delay(500)
}
}
val obj = remember(json) {
if (json.isBlank()) null
else runCatching { JSONObject(json) }.getOrNull()
}
if (obj == null) return
val elevated = obj.optInt("elevated", 0)
val maxElevated = obj.optInt("max_elevated", 0)
val batches = obj.optInt("active_batches", 0)
val maxBatches = obj.optInt("max_batch_slots", 0)
val events = remember(json) {
val arr = obj.optJSONArray("events") ?: return@remember emptyList<String>()
(0 until arr.length()).map { arr.getString(it) }
}
Spacer(Modifier.height(8.dp))
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
"Pipeline Debug",
style = MaterialTheme.typography.titleSmall,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("Elevated", style = MaterialTheme.typography.bodySmall)
Text(
"$elevated / $maxElevated",
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("Batches in-flight", style = MaterialTheme.typography.bodySmall)
Text(
"$batches / $maxBatches",
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
)
}
if (events.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
Text("Events", style = MaterialTheme.typography.labelSmall)
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 150.dp)
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(6.dp)
) {
val listState = rememberLazyListState()
LaunchedEffect(events.size) {
if (events.isNotEmpty()) listState.animateScrollToItem(events.size - 1)
}
LazyColumn(state = listState) {
items(events) { ev ->
Text(
ev,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
)
}
}
}
}
}
}
}
private fun fmtBytes(b: Long): String {
val k = 1024L
val m = k * k