feat(companion): 60s mesh probe window + session pre-warm from the VPN service

Implements the app-side recommendations from the node diagnosis
(HANDOFF-2026-07-23): first session through the public tree takes 15s+
when it works at all, far beyond the old ~8s probe window.

- connect(): mesh ULA probed inside a 60s budget, 15s per-phase timeouts.
- ArchyVpnService: session warmer probes every saved node ULA as soon as
  the tunnel is up (5s cadence first minute, then 60s keep-warm) so the
  UI hits a warm session and it never idles out.
- Served APK: 0.5.5 (vc25); handoff updated with 25-ping evidence that
  public-tree discovery currently fails outright (supports the
  private-tree infra decision).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-07-23 23:36:34 +01:00
co-authored by Claude Fable 5
parent 82fcccd113
commit 75ecd1d3ea
5 changed files with 89 additions and 14 deletions
+2 -2
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
@@ -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()
@@ -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"}}"""
@@ -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.
Binary file not shown.