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
All checks were successful
Demo images / Build & push demo images (push) Successful in 2m47s
This commit is contained in:
commit
128b78d083
@ -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:30–23: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.
Loading…
x
Reference in New Issue
Block a user