Skip to content

Commit 919b13b

Browse files
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
1 parent d822d67 commit 919b13b

14 files changed

Lines changed: 1412 additions & 199 deletions

File tree

android/app/src/main/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
prompt.
1818
-->
1919
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
20+
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
2021

2122
<!--
2223
App-launcher visibility filter. Complements QUERY_ALL_PACKAGES:

android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ data class MhrvConfig(
108108
val coalesceMaxMs: Int = 1000,
109109
/** Block QUIC (UDP/443). QUIC over TCP tunnel causes meltdown. */
110110
val blockQuic: Boolean = true,
111+
/** Block STUN/TURN ports (3478/5349/19302). Forces WebRTC TCP fallback. */
112+
val blockStun: Boolean = true,
111113
val upstreamSocks5: String = "",
112114

113115
/**
@@ -231,6 +233,7 @@ data class MhrvConfig(
231233
if (coalesceStepMs != 10) put("coalesce_step_ms", coalesceStepMs)
232234
if (coalesceMaxMs != 1000) put("coalesce_max_ms", coalesceMaxMs)
233235
put("block_quic", blockQuic)
236+
put("block_stun", blockStun)
234237
if (upstreamSocks5.isNotBlank()) {
235238
put("upstream_socks5", upstreamSocks5.trim())
236239
}
@@ -344,6 +347,7 @@ object ConfigStore {
344347
if (cfg.coalesceStepMs != defaults.coalesceStepMs) obj.put("coalesce_step_ms", cfg.coalesceStepMs)
345348
if (cfg.coalesceMaxMs != defaults.coalesceMaxMs) obj.put("coalesce_max_ms", cfg.coalesceMaxMs)
346349
if (cfg.blockQuic != defaults.blockQuic) obj.put("block_quic", cfg.blockQuic)
350+
if (cfg.blockStun != defaults.blockStun) obj.put("block_stun", cfg.blockStun)
347351
if (cfg.upstreamSocks5.isNotBlank()) obj.put("upstream_socks5", cfg.upstreamSocks5)
348352
if (cfg.passthroughHosts.isNotEmpty()) obj.put("passthrough_hosts", JSONArray().apply { cfg.passthroughHosts.forEach { put(it) } })
349353
if (cfg.tunnelDoh != defaults.tunnelDoh) obj.put("tunnel_doh", cfg.tunnelDoh)
@@ -449,6 +453,7 @@ object ConfigStore {
449453
coalesceStepMs = obj.optInt("coalesce_step_ms", 10),
450454
coalesceMaxMs = obj.optInt("coalesce_max_ms", 1000),
451455
blockQuic = obj.optBoolean("block_quic", true),
456+
blockStun = obj.optBoolean("block_stun", true),
452457
upstreamSocks5 = obj.optString("upstream_socks5", ""),
453458
passthroughHosts = obj.optJSONArray("passthrough_hosts")?.let { arr ->
454459
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }

android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ class MhrvVpnService : VpnService() {
3535
private var proxyHandle: Long = 0L
3636
private var tun2proxyThread: Thread? = null
3737
private val tun2proxyRunning = AtomicBoolean(false)
38+
private var debugOverlay: PipelineDebugOverlay? = null
3839

3940
// Idempotency guard. teardown() is reachable from three paths:
4041
// 1. ACTION_STOP onStartCommand branch (background thread)
@@ -149,6 +150,7 @@ class MhrvVpnService : VpnService() {
149150
Log.i(TAG, "PROXY_ONLY mode: listeners up, skipping VpnService/TUN")
150151
VpnState.setProxyHandle(proxyHandle)
151152
VpnState.setRunning(true)
153+
showDebugOverlay()
152154
return
153155
}
154156

@@ -314,6 +316,16 @@ class MhrvVpnService : VpnService() {
314316
// a failed-to-establish run.
315317
VpnState.setProxyHandle(proxyHandle)
316318
VpnState.setRunning(true)
319+
showDebugOverlay()
320+
}
321+
322+
private fun showDebugOverlay() {
323+
if (debugOverlay != null) return
324+
if (!android.provider.Settings.canDrawOverlays(this)) {
325+
Log.w(TAG, "overlay permission not granted — skipping debug overlay")
326+
return
327+
}
328+
debugOverlay = PipelineDebugOverlay(this).also { it.show() }
317329
}
318330

319331
/**
@@ -434,6 +446,10 @@ class MhrvVpnService : VpnService() {
434446
Log.w(TAG, "tun2proxy thread still alive after join timeout — proceeding anyway")
435447
}
436448

449+
// Hide debug overlay before flipping UI state.
450+
debugOverlay?.hide()
451+
debugOverlay = null
452+
437453
// Flip UI state last — the button reverts to Connect only after
438454
// the native-side cleanup actually happened, not optimistically.
439455
VpnState.setProxyHandle(0L)

android/app/src/main/java/com/therealaleph/mhrv/Native.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,13 @@ object Native {
110110
*/
111111
external fun statsJson(handle: Long): String
112112

113+
/**
114+
* Pipeline debug overlay snapshot. Returns a JSON blob with elevated
115+
* session count, batch semaphore usage, and recent ramp/drop events.
116+
* Temporary — for debugging pipeline behavior on-device.
117+
*/
118+
external fun pipelineDebugJson(): String
119+
113120
/**
114121
* Start tun2proxy via its CLI args C API (`tun2proxy_run_with_cli_args`).
115122
* Resolved at runtime via dlsym from libtun2proxy.so — no fork needed.
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
package com.therealaleph.mhrv
2+
3+
import android.content.Context
4+
import android.graphics.Color
5+
import android.graphics.PixelFormat
6+
import android.os.Handler
7+
import android.os.Looper
8+
import android.util.TypedValue
9+
import android.view.Gravity
10+
import android.view.MotionEvent
11+
import android.view.View
12+
import android.view.WindowManager
13+
import android.widget.LinearLayout
14+
import android.widget.TextView
15+
import org.json.JSONObject
16+
17+
/**
18+
* Transparent system overlay showing pipeline debug stats.
19+
* Draggable, semi-transparent, shown on top of all apps.
20+
* Temporary — remove when pipelining is validated.
21+
*/
22+
class PipelineDebugOverlay(private val context: Context) {
23+
24+
private val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
25+
private val handler = Handler(Looper.getMainLooper())
26+
private var root: View? = null
27+
28+
private lateinit var tvElevated: TextView
29+
private lateinit var tvBatches: TextView
30+
private lateinit var tvEvents: TextView
31+
32+
private val pollInterval = 500L
33+
34+
fun show() {
35+
if (root != null) return
36+
37+
val dp = { px: Int ->
38+
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, px.toFloat(), context.resources.displayMetrics).toInt()
39+
}
40+
41+
val layout = LinearLayout(context).apply {
42+
orientation = LinearLayout.VERTICAL
43+
setBackgroundColor(Color.argb(160, 0, 0, 0))
44+
setPadding(dp(8), dp(6), dp(8), dp(6))
45+
}
46+
47+
val titleTv = TextView(context).apply {
48+
text = "Pipeline Debug"
49+
setTextColor(Color.argb(220, 100, 255, 100))
50+
textSize = 11f
51+
}
52+
layout.addView(titleTv)
53+
54+
tvElevated = TextView(context).apply {
55+
setTextColor(Color.WHITE)
56+
textSize = 10f
57+
}
58+
layout.addView(tvElevated)
59+
60+
tvBatches = TextView(context).apply {
61+
setTextColor(Color.WHITE)
62+
textSize = 10f
63+
}
64+
layout.addView(tvBatches)
65+
66+
tvEvents = TextView(context).apply {
67+
setTextColor(Color.argb(200, 200, 200, 200))
68+
textSize = 9f
69+
maxLines = 8
70+
}
71+
layout.addView(tvEvents)
72+
73+
val params = WindowManager.LayoutParams(
74+
WindowManager.LayoutParams.WRAP_CONTENT,
75+
WindowManager.LayoutParams.WRAP_CONTENT,
76+
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
77+
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
78+
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
79+
PixelFormat.TRANSLUCENT,
80+
).apply {
81+
gravity = Gravity.TOP or Gravity.START
82+
x = dp(8)
83+
y = dp(80)
84+
}
85+
86+
// Draggable
87+
var startX = 0
88+
var startY = 0
89+
var startTouchX = 0f
90+
var startTouchY = 0f
91+
layout.setOnTouchListener { _, event ->
92+
when (event.action) {
93+
MotionEvent.ACTION_DOWN -> {
94+
startX = params.x
95+
startY = params.y
96+
startTouchX = event.rawX
97+
startTouchY = event.rawY
98+
true
99+
}
100+
MotionEvent.ACTION_MOVE -> {
101+
params.x = startX + (event.rawX - startTouchX).toInt()
102+
params.y = startY + (event.rawY - startTouchY).toInt()
103+
wm.updateViewLayout(layout, params)
104+
true
105+
}
106+
else -> false
107+
}
108+
}
109+
110+
root = layout
111+
wm.addView(layout, params)
112+
schedulePoll()
113+
}
114+
115+
fun hide() {
116+
handler.removeCallbacksAndMessages(null)
117+
root?.let {
118+
try { wm.removeView(it) } catch (_: Throwable) {}
119+
}
120+
root = null
121+
}
122+
123+
private fun schedulePoll() {
124+
handler.postDelayed(::poll, pollInterval)
125+
}
126+
127+
private fun poll() {
128+
if (root == null) return
129+
Thread {
130+
try {
131+
val json = Native.pipelineDebugJson()
132+
handler.post { applyJson(json) }
133+
} catch (_: Throwable) {}
134+
schedulePoll()
135+
}.start()
136+
}
137+
138+
private fun applyJson(json: String) {
139+
if (root == null) return
140+
try {
141+
if (json.isNotBlank()) {
142+
val obj = JSONObject(json)
143+
val elevated = obj.optInt("elevated", 0)
144+
val maxElev = obj.optInt("max_elevated", 0)
145+
val batches = obj.optInt("active_batches", 0)
146+
val maxBatch = obj.optInt("max_batch_slots", 0)
147+
148+
val sessions = obj.optInt("active_sessions", 0)
149+
tvElevated.text = "Sessions: $sessions Elevated: $elevated / $maxElev"
150+
tvBatches.text = "Batches: $batches / $maxBatch"
151+
152+
val sessArr = obj.optJSONArray("sessions")
153+
val sessLines = if (sessArr != null && sessArr.length() > 0) {
154+
(0 until sessArr.length()).joinToString("\n") { i ->
155+
val s = sessArr.getJSONObject(i)
156+
val sid = s.optString("sid", "?")
157+
val d = s.optInt("depth", 0)
158+
val inf = s.optInt("inflight", 0)
159+
val e = if (s.optBoolean("elevated", false)) " E" else ""
160+
"$sid d=$d f=$inf$e"
161+
}
162+
} else ""
163+
164+
val arr = obj.optJSONArray("events")
165+
val evtLines = if (arr != null && arr.length() > 0) {
166+
val start = maxOf(0, arr.length() - 5)
167+
(start until arr.length()).joinToString("\n") { arr.getString(it) }
168+
} else ""
169+
170+
tvEvents.text = listOf(sessLines, evtLines).filter { it.isNotEmpty() }.joinToString("\n---\n")
171+
}
172+
} catch (_: Throwable) {}
173+
}
174+
}

0 commit comments

Comments
 (0)