Compare commits

...
Author SHA1 Message Date
Bailey DixonandClaude Opus 4.8 99b9cf1704 fix(android): currentSession() must not re-throw network errors (crash)
An on-device crash (FATAL EXCEPTION: main, SocketTimeoutException,
Caused by SocketException "Software caused connection abort") over a
Tailscale connection. Full trace recovered from a background logcat
capture pinned it to DashboardApiClient.currentSession().

Root cause: currentSession() returns Result<DashboardAuthSession> but did
a raw okHttpClient.newCall(req).execute() with NO try/catch — the lone
outlier among the client's methods (executeJson/executeJsonElement/
audioRoutesPresent all catch). The execute() ran on Dispatchers.IO
(correct), but a transient stale-pooled-connection abort re-threw out of
withContext(IO). The caller chain — ConnectionViewModel.probeStandardVoice()
-> viewModelScope.launch (Dispatchers.Main.immediate, the Suppressed frame
in the trace) — used try/finally with no catch, so the exception was
uncaught on the main thread and killed the app. (execute() being off-main
is why StrictMode never fired; the uncaught propagation was the bug.)

Fix:
- currentSession() wraps its request in try/catch -> Result.failure on any
  exception, honoring the Result contract callers rely on (mirrors
  executeJson()).
- Defense-in-depth: probeStandardVoice() gains a catch (rethrowing
  CancellationException) that degrades availability state instead of
  letting any probe sub-call crash the Main coroutine.

Test: DashboardApiClientTest.currentSession_onConnectionAbort_returnsFailure_doesNotThrow
(MockWebServer DISCONNECT_AT_START) asserts a connection abort yields
Result.failure, not a throw. :app:testSideloadDebugUnitTest green (25/25).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:51:51 -04:00
5 changed files with 60 additions and 8 deletions
+4
View File
@@ -20,6 +20,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **Desktop CLI: smoother pairing.** The multi-endpoint probe shows per-endpoint progress and latency; a near-expiry session warns before it fails and prints the exact re-pair command; and a bare `ws://host` (no port) defaults to `:8767`.
- **Desktop CLI: voice + consent transparency.** `voice` now surfaces enhanced-voice capabilities (Gemini tone tags / persona, xAI speech tags); the desktop-tool consent prompt is clear that it persists per relay and points at `hermes-relay audit`; and computer-use's observe → grant → act flow is documented in `--help`.
### Fixed
- **Crash when a dashboard connection drops mid-check.** A transient network blip on the dashboard session check (e.g. a pooled connection aborting over Tailscale) could close the app: the check returned a result type but re-threw the network error instead of reporting it, and it surfaced on the main thread. The check now reports the failure cleanly, and the connection probe degrades gracefully instead of ever crashing.
## [1.2.3] - 2026-06-23
### Fixed
+10
View File
@@ -1,5 +1,15 @@
# Hermes-Relay — Dev Log
## 2026-06-24 — Fix SocketTimeoutException crash from DashboardApiClient.currentSession()
**Why.** An in-app crash report (`FATAL EXCEPTION: main`, `SocketTimeoutException`, `Caused by: java.net.SocketException: Software caused connection abort`) captured on-device over a Tailscale connection. The visible dialog truncated the trace; the full stack was recovered from a background `adb logcat` capture that happened to be running when it fired.
**Root cause.** `DashboardApiClient.currentSession()` declared `Result<DashboardAuthSession>` but performed a **raw `okHttpClient.newCall(req).execute()` with no try/catch** — the lone outlier among the client's methods (`executeJson`/`executeJsonElement`/`audioRoutesPresent` all catch). Its `.execute()` correctly ran on `Dispatchers.IO`, but a transient network failure (a stale pooled connection aborting over Tailscale) **re-threw** out of `withContext(IO)`. The caller chain — `ConnectionViewModel.probeStandardVoice()` → `viewModelScope.launch` (`Dispatchers.Main.immediate`, the `Suppressed` frame in the trace) — used `try/finally` with **no `catch`**, so the exception was uncaught on the main thread and killed the app. The `.execute()` being off-main is why StrictMode never fired; the uncaught *propagation* to the Main coroutine was the bug.
**Fix.** (1) `currentSession()` now wraps its request in `try/catch`, returning `Result.failure` on any exception — honoring the `Result` contract every caller relies on (mirrors `executeJson`). (2) Defense-in-depth: `probeStandardVoice()` gained a `catch` (rethrowing `CancellationException`) that degrades the voice/gateway availability state instead of letting any probe sub-call crash the Main coroutine.
**Verification.** New `DashboardApiClientTest.currentSession_onConnectionAbort_returnsFailure_doesNotThrow` (MockWebServer `DISCONNECT_AT_START`) asserts a connection abort yields `Result.failure`, not a throw. `:app:testSideloadDebugUnitTest` + `:app:lintSideloadDebug` green. On-device confirmation pending a build.
## 2026-06-23 — Fix NetworkOnMainThreadException crash on TLS connect
**Why.** Two external bug reports (#118, #124) and the later comment on #70 reported the app hard-closing on connect over an encrypted link (Tailscale Serve / public HTTPS). The auto-captured traces were identical: `android.os.NetworkOnMainThreadException` from `okhttp3.ConnectionPool.evictAll()`, with a suppressed `Dispatchers.Main.immediate [Cancelling]` frame — i.e. a `viewModelScope` coroutine.
@@ -514,15 +514,26 @@ class DashboardApiClient(
.get()
.build()
okHttpClient.newCall(request).execute().use { response ->
if (response.code == 401 || response.code == 403) {
return@withContext Result.success(DashboardAuthSession(authenticated = false))
// try/catch is NOT optional here: currentSession() returns a Result and
// callers (probeStandardVoice on a viewModelScope/Main coroutine) rely
// on it NEVER throwing. A raw execute() re-threw transient network
// failures — e.g. a stale pooled connection over Tailscale aborting
// ("Software caused connection abort") — straight past withContext(IO)
// and crashed the app on the main thread. Mirror executeJson()'s
// contract: every failure becomes Result.failure.
try {
okHttpClient.newCall(request).execute().use { response ->
when {
response.code == 401 || response.code == 403 ->
Result.success(DashboardAuthSession(authenticated = false))
!response.isSuccessful ->
Result.failure(apiFailure(response, "Dashboard session"))
else ->
Result.success(parseAuthSession(response.readJsonObject(json)))
}
}
if (!response.isSuccessful) {
return@withContext Result.failure(apiFailure(response, "Dashboard session"))
}
val root = response.readJsonObject(json)
Result.success(parseAuthSession(root))
} catch (e: Exception) {
Result.failure(e)
}
}
@@ -3446,6 +3446,18 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
url = dashboardUrl,
)
}
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
// Defense-in-depth: this runs in a viewModelScope (Main) coroutine,
// so an unexpected throw from any probe sub-call would crash the
// app (see the currentSession() stale-connection crash). A probe
// failure must only degrade the UI, never be fatal.
android.util.Log.w("ConnectionVM", "probeStandardVoice failed: ${e.message}")
_standardVoiceAvailability.value = StandardVoiceAvailability.Unreachable
_standardAudioApiReachable.value = false
_serverChatDisplaySettings.value = null
updateGatewayAvailability(GatewayAvailability.Unreachable)
} finally {
client.shutdown()
}
@@ -7,6 +7,7 @@ import kotlinx.serialization.json.jsonObject
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.SocketPolicy
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -56,6 +57,20 @@ class DashboardApiClientTest {
assertEquals("0.16.0", status.version)
}
@Test
fun currentSession_onConnectionAbort_returnsFailure_doesNotThrow() = runTest {
// Reproduces the crash: a stale pooled connection aborting mid-flight
// ("Software caused connection abort"). currentSession() returns a
// Result, so a network failure MUST surface as Result.failure — never a
// throw that escapes withContext(IO) and crashes the Main coroutine.
server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START))
val client = DashboardApiClient(baseUrl = server.url("/").toString())
val result = client.currentSession()
assertTrue("network abort must be Result.failure, not a throw", result.isFailure)
}
@Test
fun getStatus_acceptsProviderObjects() = runTest {
server.enqueue(