Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99b9cf1704 |
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+15
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user