From 82570302802bff515f929c77cb5dde7beff321a5 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Mon, 31 Aug 2026 21:57:36 -0400 Subject: [PATCH] Never let a dropped event stream close the app Both screens that follow a stream retried an `ApiException` and let everything else through, and `Sse.run` opened its connection on a line outside the `try` that maps failures onto that type. So a failure at open time, or anything the framing did not expect, reached the top of the app and closed it -- from a screen whose own comment says failures there are deliberately quiet, because the listing already carries every state the stream would have brought. The open moves inside the guarded region, and both loops now retry on any exception while rethrowing `CancellationException`, which is the screen leaving rather than a failure -- swallowing that one would leave the loop reconnecting to a stream nobody is watching. This is hardening on the path that runs when a screen with a stream opens, not a diagnosed fix: an import list loading against a server missing the events route, and against 121 real transcripts, does not crash here. Co-Authored-By: Claude Opus 5 --- .../main/kotlin/com/example/aiapp/ImportScreen.kt | 12 +++++++++++- .../main/kotlin/com/example/aiapp/SessionScreen.kt | 11 +++++++++-- .../src/main/kotlin/com/example/aiapp/Sse.kt | 13 ++++++++++--- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt index af9297a..9f4fc7f 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt @@ -323,8 +323,18 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio } } } - } catch (_: ApiException) { + } catch (e: kotlinx.coroutines.CancellationException) { + // The screen leaving, not a failure -- and swallowing it would leave this + // loop reconnecting to a stream nobody is watching. + throw e + } catch (_: Exception) { // Retried below; the listing is the truth in the meantime. + // + // Any failure, not only an [ApiException]. A stream is an optimisation over + // the listing here, so nothing it can do is worth taking the app down for -- + // and catching only the failure that was expected means an unexpected one + // reaches the top of the app and closes it, from a screen that is merely + // loading a list. } finally { stream.close() } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index ce673f8..e523c0d 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -718,8 +718,15 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () apply(entry) } } - } catch (e: ApiException) { - streamError = e.message + } catch (e: kotlinx.coroutines.CancellationException) { + // Leaving the screen or going below STARTED. Not a failure, and + // swallowing it would leave this loop reconnecting forever. + throw e + } catch (e: Exception) { + // Any failure, not only an [ApiException]: the stream reconnects from its + // cursor, so there is nothing a failure here can cost that is worth + // closing the app over. Reported on the screen either way. + streamError = e.message ?: e::class.simpleName } finally { stream.close() } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Sse.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Sse.kt index 1e1897f..bbfbd16 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Sse.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Sse.kt @@ -47,9 +47,16 @@ class Sse(private val settings: ServerSettings) { * recovered from, indefinitely. */ fun run(path: String, onOpen: () -> Unit, onFrame: (name: String?, data: String) -> Unit) { - val connection = URL("${settings.baseUrl}$path").openConnection() as HttpURLConnection - this.connection = connection + // Opening is inside the try, not before it. Everything this method can fail at owes the + // caller the same kind of failure -- both callers retry an [ApiException] and let anything + // else reach the top of the app -- and a connection that could not even be constructed + // used to escape as a raw `IOException` from a line no `catch` covered. + var connection: HttpURLConnection? = null try { + connection = + (URL("${settings.baseUrl}$path").openConnection() as HttpURLConnection).also { + this.connection = it + } connection.applyPinnedTls() connection.connectTimeout = CONNECT_TIMEOUT_MS // No read timeout: between events there is nothing to read for as long as the thing @@ -90,7 +97,7 @@ class Sse(private val settings: ServerSettings) { ) } } finally { - connection.disconnect() + connection?.disconnect() this.connection = null } }