Merge pull request 'feat(companion): 60s mesh probe window + session pre-warm from the VPN service' (#111) from feat/mesh-probe-window-prewarm into main
All checks were successful
Demo images / Build & push demo images (push) Successful in 2m47s

This commit is contained in:
lfg2025 2026-07-23 22:36:45 +00:00
commit 128b78d083
5 changed files with 89 additions and 14 deletions

View File

@ -11,8 +11,8 @@ android {
applicationId = "com.archipelago.app" applicationId = "com.archipelago.app"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 24 versionCode = 25
versionName = "0.5.4" versionName = "0.5.5"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true

View File

@ -10,10 +10,15 @@ import android.os.Build
import android.util.Log import android.util.Log
import com.archipelago.app.MainActivity import com.archipelago.app.MainActivity
import com.archipelago.app.R import com.archipelago.app.R
import com.archipelago.app.data.ServerPreferences
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
@ -27,6 +32,7 @@ import kotlinx.coroutines.launch
class ArchyVpnService : VpnService() { class ArchyVpnService : VpnService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var warmerJob: Job? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) { if (intent?.action == ACTION_STOP) {
@ -89,10 +95,57 @@ class ArchyVpnService : VpnService() {
val fd = pfd.detachFd() val fd = pfd.detachFd()
val result = FipsNative.start(identity.secret, peersJson, fd) val result = FipsNative.start(identity.secret, peersJson, fd)
Log.i(TAG, "mesh start: $result") Log.i(TAG, "mesh start: $result")
if (result.contains("\"error\"")) shutdown() if (result.contains("\"error\"")) {
shutdown()
} else {
startSessionWarmer()
}
}
/**
* Pre-warm + keep-warm mesh sessions to every known node ULA.
*
* Discovery + first session through the public tree can take 15s+
* (HANDOFF-2026-07-23 node diagnosis) paying that cost here, the
* moment the tunnel is up, means the connect probe and WebView hit an
* established session instead of timing out on a cold one. The periodic
* touch afterwards keeps the session from idling out. Failed connects
* are expected and cheap; the attempt itself is what drives discovery.
*/
private fun startSessionWarmer() {
warmerJob?.cancel()
warmerJob = scope.launch {
val prefs = ServerPreferences(this@ArchyVpnService)
var round = 0
while (isActive && FipsNative.isRunning()) {
val ulas = try {
prefs.savedServers.first().mapNotNull { it.meshIp.ifBlank { null } }.distinct()
} catch (_: Exception) {
emptyList()
}
for (ula in ulas) {
try {
java.net.Socket().use { s ->
s.connect(
java.net.InetSocketAddress(java.net.InetAddress.getByName(ula), 80),
20_000,
)
}
} catch (_: Exception) {
// Cold path / node away — the connect attempt still
// drove session establishment; try again next round.
}
}
round++
// Aggressive for the first ~minute (session bring-up), then a
// slow keep-warm tick that costs nearly nothing.
delay(if (round < 12) 5_000 else 60_000)
}
}
} }
private fun shutdown() { private fun shutdown() {
warmerJob?.cancel()
FipsNative.stop() FipsNative.stop()
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()

View File

@ -185,13 +185,17 @@ fun ServerConnectScreen(
useHttps = false, useHttps = false,
port = "", port = "",
) )
// The tunnel + mesh route need a moment on cold start — and on // Mesh discovery + first session can take 15s+ through the
// a first-ever pairing the VPN consent dialog is on screen at // public tree (HANDOFF-2026-07-23 node diagnosis), and on a
// the same time, so give the user time to tap it. // first-ever pairing the VPN consent dialog is on screen at
for (attempt in 0 until 6) { // the same time — so probe patiently inside a 60s budget with
if (attempt > 0) delay(2000) // per-attempt timeouts wide enough to ride out TCP
reachable = testConnection(meshServer) // retransmit backoff. The VPN service pre-warms the session
if (reachable) break // in parallel (ArchyVpnService.startSessionWarmer).
val deadline = System.currentTimeMillis() + 60_000
while (!reachable && System.currentTimeMillis() < deadline) {
reachable = testConnection(meshServer, timeoutMs = 15_000)
if (!reachable) delay(3000)
} }
} }
isConnecting = false isConnecting = false
@ -673,8 +677,10 @@ private fun sanitizeAddress(input: String): String {
.trimEnd('/') .trimEnd('/')
} }
/** Test RPC connectivity. Accepts self-signed certs for local LAN servers. */ /** Test RPC connectivity. Accepts self-signed certs for local LAN servers.
private suspend fun testConnection(server: ServerEntry): Boolean { * [timeoutMs] is per-phase (connect / read) mesh probes need far more
* patience than LAN ones (first session through the tree can take 15s+). */
private suspend fun testConnection(server: ServerEntry, timeoutMs: Int = 5000): Boolean {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
val url = URL("${server.toUrl()}/rpc/v1") val url = URL("${server.toUrl()}/rpc/v1")
@ -694,8 +700,8 @@ private suspend fun testConnection(server: ServerEntry): Boolean {
} }
connection.requestMethod = "POST" connection.requestMethod = "POST"
connection.connectTimeout = 5000 connection.connectTimeout = timeoutMs
connection.readTimeout = 5000 connection.readTimeout = timeoutMs
connection.setRequestProperty("Content-Type", "application/json") connection.setRequestProperty("Content-Type", "application/json")
connection.doOutput = true connection.doOutput = true
val body = """{"method":"server.echo","params":{"message":"ping"}}""" val body = """{"method":"server.echo","params":{"message":"ping"}}"""

View File

@ -126,3 +126,19 @@ Chain of findings, each verified live:
broader public mesh. broader public mesh.
- **Upstream:** report the direct-peer session-path asymmetry to - **Upstream:** report the direct-peer session-path asymmetry to
jmcorgan/fips (0.4.1). jmcorgan/fips (0.4.1).
## App-side recommendations implemented (23:3023:50, Mac agent — 0.5.5/vc25)
- Connect probe: mesh ULA now probed inside a **60s budget** with 15s
per-phase timeouts (rides out TCP retransmit backoff), replacing the old
~8s window.
- **Session pre-warm**: the VPN service starts probing every saved node ULA
the moment the tunnel is up (5s cadence for the first minute, then a 60s
keep-warm tick) — discovery/handshake cost is paid in the background, and
the session never idles out while the mesh is connected.
- Extra evidence for the infra decision: from the phone on 5G, a 25-packet
ping burst to the node's ULA (25s continuous traffic, post-nginx-fix)
still got **zero replies** — discovery through the public tree isn't just
slow, it's failing outright. The private-tree option (detach fleet from
the public v0l mesh, root at vps2) looks like the real fix; app-side
patience only helps once discovery succeeds at all.