Skip to content

Commit 79e0f6c

Browse files
vahidlazioclaude
andcommitted
feat(tunnel): optimist pipelining, fast-path uploads, debug overlay, STUN blocking
Pipeline improvements: - Optimist start at depth 2 (free, no permit), drop to 1 on 2 consecutive empties - Elevation permit only for depth 3+ with 32KB download threshold (prevents keep-alive sessions like Telegram from over-elevating) - Fast-path uploads bypass full pipeline with +4 cap and 20ms coalesce - Data-op preference: 20ms client read check before sending empty polls - 1s stagger always applied for batch separation - Client socket close breaks immediately (no waiting for in-flight polls) - consecutive_data no longer resets on single empties Android: - Pipeline debug overlay (SYSTEM_ALERT_WINDOW) with per-session tracking - Tokio worker threads 4 (was 2) to prevent burst stalls - STUN/TURN port blocking (3478/5349/19302) for instant WebRTC TCP fallback Tunnel-node: - LONGPOLL_DEADLINE 4s (must stay below client batch timeout) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 488c8af commit 79e0f6c

9 files changed

Lines changed: 604 additions & 44 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/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+
}

android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,7 @@ fun HomeScreen(
491491
// client-side estimate only sees what this device relayed,
492492
// not what other devices on the same deployment consumed.
493493
UsageTodayCard()
494+
PipelineDebugCard()
494495

495496
CollapsibleSection(title = stringResource(R.string.sec_live_logs), initiallyExpanded = false) {
496497
LiveLogPane()
@@ -1645,6 +1646,104 @@ private fun UsageRow(label: String, value: String) {
16451646
}
16461647
}
16471648

1649+
@Composable
1650+
private fun PipelineDebugCard() {
1651+
val isRunning by VpnState.isRunning.collectAsState()
1652+
if (!isRunning) return
1653+
1654+
var json by remember { mutableStateOf("") }
1655+
LaunchedEffect(isRunning) {
1656+
if (!isRunning) return@LaunchedEffect
1657+
while (true) {
1658+
val result = withContext(Dispatchers.IO) {
1659+
runCatching { Native.pipelineDebugJson() }
1660+
}
1661+
json = result.getOrDefault("")
1662+
if (result.isFailure) {
1663+
android.util.Log.e("PipeDbg", "pipelineDebugJson failed", result.exceptionOrNull())
1664+
}
1665+
delay(500)
1666+
}
1667+
}
1668+
1669+
val obj = remember(json) {
1670+
if (json.isBlank()) null
1671+
else runCatching { JSONObject(json) }.getOrNull()
1672+
}
1673+
if (obj == null) return
1674+
1675+
val elevated = obj.optInt("elevated", 0)
1676+
val maxElevated = obj.optInt("max_elevated", 0)
1677+
val batches = obj.optInt("active_batches", 0)
1678+
val maxBatches = obj.optInt("max_batch_slots", 0)
1679+
val events = remember(json) {
1680+
val arr = obj.optJSONArray("events") ?: return@remember emptyList<String>()
1681+
(0 until arr.length()).map { arr.getString(it) }
1682+
}
1683+
1684+
Spacer(Modifier.height(8.dp))
1685+
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
1686+
Column(
1687+
modifier = Modifier.padding(12.dp),
1688+
verticalArrangement = Arrangement.spacedBy(4.dp),
1689+
) {
1690+
Text(
1691+
"Pipeline Debug",
1692+
style = MaterialTheme.typography.titleSmall,
1693+
)
1694+
Row(
1695+
modifier = Modifier.fillMaxWidth(),
1696+
horizontalArrangement = Arrangement.SpaceBetween,
1697+
) {
1698+
Text("Elevated", style = MaterialTheme.typography.bodySmall)
1699+
Text(
1700+
"$elevated / $maxElevated",
1701+
style = MaterialTheme.typography.bodySmall,
1702+
fontFamily = FontFamily.Monospace,
1703+
)
1704+
}
1705+
Row(
1706+
modifier = Modifier.fillMaxWidth(),
1707+
horizontalArrangement = Arrangement.SpaceBetween,
1708+
) {
1709+
Text("Batches in-flight", style = MaterialTheme.typography.bodySmall)
1710+
Text(
1711+
"$batches / $maxBatches",
1712+
style = MaterialTheme.typography.bodySmall,
1713+
fontFamily = FontFamily.Monospace,
1714+
)
1715+
}
1716+
if (events.isNotEmpty()) {
1717+
Spacer(Modifier.height(4.dp))
1718+
Text("Events", style = MaterialTheme.typography.labelSmall)
1719+
Box(
1720+
modifier = Modifier
1721+
.fillMaxWidth()
1722+
.heightIn(max = 150.dp)
1723+
.clip(RoundedCornerShape(4.dp))
1724+
.background(MaterialTheme.colorScheme.surfaceVariant)
1725+
.padding(6.dp)
1726+
) {
1727+
val listState = rememberLazyListState()
1728+
LaunchedEffect(events.size) {
1729+
if (events.isNotEmpty()) listState.animateScrollToItem(events.size - 1)
1730+
}
1731+
LazyColumn(state = listState) {
1732+
items(events) { ev ->
1733+
Text(
1734+
ev,
1735+
style = MaterialTheme.typography.bodySmall,
1736+
fontFamily = FontFamily.Monospace,
1737+
fontSize = 10.sp,
1738+
)
1739+
}
1740+
}
1741+
}
1742+
}
1743+
}
1744+
}
1745+
}
1746+
16481747
private fun fmtBytes(b: Long): String {
16491748
val k = 1024L
16501749
val m = k * k

src/android_jni.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_startProxy(
199199
// Try to build the runtime first — if allocation fails we want to
200200
// know before spinning up anything stateful.
201201
let rt = match tokio::runtime::Builder::new_multi_thread()
202-
.worker_threads(2)
202+
.worker_threads(4)
203203
.enable_all()
204204
.thread_name("mhrv-worker")
205205
.build()
@@ -483,6 +483,20 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_statsJson<'a>(
483483
env.new_string(out).map(|s| s.into_raw()).unwrap_or(std::ptr::null_mut())
484484
}
485485

486+
/// `Native.pipelineDebugJson()` -> String. Snapshot of pipeline debug state:
487+
/// elevated session count, batch semaphore usage, recent ramp/drop events.
488+
/// Temporary — for the debug overlay.
489+
#[no_mangle]
490+
pub extern "system" fn Java_com_therealaleph_mhrv_Native_pipelineDebugJson<'a>(
491+
env: JNIEnv<'a>,
492+
_class: JClass,
493+
) -> jstring {
494+
let out = safe(String::new(), AssertUnwindSafe(|| {
495+
crate::tunnel_client::pipeline_debug::to_json()
496+
}));
497+
env.new_string(out).map(|s| s.into_raw()).unwrap_or(std::ptr::null_mut())
498+
}
499+
486500
// ---------------------------------------------------------------------------
487501
// tun2proxy CLI API wrapper (dlsym — no fork or patch needed)
488502
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)