Stop reporting the app being switched away from as a failure

Backgrounding the app left "Lost the event stream
(SocketTimeoutException: null)" waiting at the top on return. Android
stops the activity, the socket dies with it, and the reconnect loop --
which kept running on a phone nobody was looking at -- recorded the
failure. Switching apps is a choice somebody made, not a fault to report.

Worse, it could not clear. `streamError` was reset when an event arrived,
so a session that reconnected and then sat idle displayed a connection
error it had already recovered from, indefinitely. That is the expensive
half: a stale failure is indistinguishable from a live one.

So the stream now runs only while the screen is at least STARTED, which
makes the drop a deliberate close rather than an error (EventStream
already distinguishes them), and resuming reconnects from the same
cursor. What takes a failure off the screen is `onOpen` -- the measured
moment the server accepted the connection -- rather than the first event
to follow it.

The message that does get shown leads with what will happen next rather
than with the exception's class name, which named nothing the reader
could act on.

lifecycle-runtime-compose is declared rather than inherited from
activity-compose, for the reason core-ktx already is: this code calls
repeatOnLifecycle and LocalLifecycleOwner directly now, and a transitive
could change under it. 2.11.0, the current stable.

Verified on the emulator against an idle session, which is the case the
old code could never clear: backgrounded 35s, returned, no banner -- and
a message sent afterwards arrived live, so the reconnect genuinely
reattached rather than merely staying quiet. Build, lint and ktfmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-29 05:36:04 -04:00
1 parent d7c692a4ec
commit aff2cb90e2
4 files changed
+71 -31

No files matched your search

+1
View File
@@ -142,5 +142,6 @@ dependencies {
implementation(libs.compose.ui) implementation(libs.compose.ui)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.zxing.embedded) implementation(libs.zxing.embedded)
} }
@@ -31,12 +31,17 @@ class EventStream(private val settings: ServerSettings, private val sessionId: S
/** /**
* Streams events after [after] into [onEvent] until the stream drops. * Streams events after [after] into [onEvent] until the stream drops.
* *
* [onOpen] fires once the server has accepted the connection. That is the measured moment the
* stream is live again, and the only honest thing to clear a previous failure on: an earlier
* version cleared on the first event instead, so an idle session went on displaying a
* connection error that had already been recovered from, indefinitely.
*
* [onReset] fires when the server answers that the cursor is too far behind to continue from: * [onReset] fires when the server answers that the cursor is too far behind to continue from:
* everything already displayed is stale and the events that follow are a fresh window, so the * everything already displayed is stale and the events that follow are a fresh window, so the
* caller drops what it holds and rebuilds -- the same thing it does when the screen opens. It * caller drops what it holds and rebuilds -- the same thing it does when the screen opens. It
* arrives before those events, so a caller that clears on it stays in order. * arrives before those events, so a caller that clears on it stays in order.
*/ */
fun run(after: Long, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) { fun run(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) {
val connection = val connection =
URL("${settings.baseUrl}/sessions/$sessionId/events?after=$after").openConnection() URL("${settings.baseUrl}/sessions/$sessionId/events?after=$after").openConnection()
as HttpURLConnection as HttpURLConnection
@@ -55,6 +60,7 @@ class EventStream(private val settings: ServerSettings, private val sessionId: S
throw ApiException(detail ?: "HTTP ${connection.responseCode} for the event stream") throw ApiException(detail ?: "HTTP ${connection.responseCode} for the event stream")
} }
onOpen()
val reader = connection.inputStream.bufferedReader() val reader = connection.inputStream.bufferedReader()
// SSE framing: `data:` and `event:` lines accumulate until a // SSE framing: `data:` and `event:` lines accumulate until a
// blank line ends the frame. `id:` (the seq) is also inside the // blank line ends the frame. `id:` (the seq) is also inside the
@@ -83,7 +89,7 @@ class EventStream(private val settings: ServerSettings, private val sessionId: S
} catch (e: IOException) { } catch (e: IOException) {
if (!closed) { if (!closed) {
throw ApiException( throw ApiException(
"Lost the event stream (${e::class.simpleName}: ${e.message})", "Can't reach the server -- retrying. (${e.message ?: e::class.simpleName})",
e, e,
) )
} }
@@ -51,6 +51,9 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.window.PopupProperties
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -171,6 +174,7 @@ fun SessionScreen(
// than listed here: a hardcoded list is a claim about a machine. // than listed here: a hardcoded list is a claim about a machine.
var offeredModels by remember { mutableStateOf<List<String>>(emptyList()) } var offeredModels by remember { mutableStateOf<List<String>>(emptyList()) }
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
// The resume cursor, written from the stream's IO thread. // The resume cursor, written from the stream's IO thread.
val lastSeq = remember { AtomicLong(0) } val lastSeq = remember { AtomicLong(0) }
val activeStream = remember { AtomicReference<EventStream?>(null) } val activeStream = remember { AtomicReference<EventStream?>(null) }
@@ -243,43 +247,65 @@ fun SessionScreen(
ready = true ready = true
} }
LaunchedEffect(summary.id, ready) { // Only while the screen is actually on screen. Android stops the
// activity when somebody switches away, and the socket dies with it --
// which arrived as "Lost the event stream (SocketTimeoutException)"
// waiting at the top on their return. Switching apps is a choice
// somebody made, not a fault to report, and reconnecting on a phone
// that has been backgrounded is work nobody is watching. Stopping the
// stream deliberately makes the drop a close rather than an error (see
// EventStream.close), and resuming reconnects from the same cursor.
LaunchedEffect(summary.id, ready, lifecycleOwner) {
if (!ready) return@LaunchedEffect if (!ready) return@LaunchedEffect
while (true) { lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
val stream = EventStream(settings, summary.id)
activeStream.set(stream)
try { try {
withContext(Dispatchers.IO) { while (true) {
stream.run( val stream = EventStream(settings, summary.id)
after = lastSeq.get(), activeStream.set(stream)
onReset = { try {
// Too far behind to continue from: what is on withContext(Dispatchers.IO) {
// screen is a stale prefix of a conversation stream.run(
// that has moved on, and the window arriving after = lastSeq.get(),
// next is not adjacent to it. Dropping the rows // Connected, measured rather than inferred: this is what
// is what makes this the same as opening the // takes a failure off the screen, and nothing else does.
// screen -- `apply` refills them, and scrolling // Clearing on the first event instead meant an idle
// up pages the rest back in as it always does. // session kept displaying an error it had recovered from.
items = listOf() onOpen = { streamError = null },
loaded = listOf() onReset = {
oldestSeq = 0L // Too far behind to continue from: what is on
moreHistory = true // screen is a stale prefix of a conversation
}, // that has moved on, and the window arriving
) { entry -> // next is not adjacent to it. Dropping the rows
apply(entry) // is what makes this the same as opening the
streamError = null // screen -- `apply` refills them, and scrolling
// up pages the rest back in as it always does.
items = listOf()
loaded = listOf()
oldestSeq = 0L
moreHistory = true
},
) { entry ->
apply(entry)
}
}
} catch (e: ApiException) {
streamError = e.message
} finally {
stream.close()
} }
delay(RECONNECT_DELAY_MS)
} }
} catch (e: ApiException) {
streamError = e.message
} finally { } finally {
stream.close() // Cancellation -- going below STARTED, or leaving the screen --
// cannot interrupt a blocking socket read. Closing is what
// unblocks it, and what marks the drop deliberate.
activeStream.getAndSet(null)?.close()
} }
delay(RECONNECT_DELAY_MS)
} }
} }
// Coroutine cancellation can't interrupt a blocking socket read; // The screen going away entirely, which the lifecycle scope above does
// closing the stream is what unblocks it when this screen goes away. // not cover: a composable can leave the composition while the activity
// stays started.
DisposableEffect(summary.id) { onDispose { activeStream.get()?.close() } } DisposableEffect(summary.id) { onDispose { activeStream.get()?.close() } }
// Whether the view is pinned to the newest message. The list is laid // Whether the view is pinned to the newest message. The list is laid
+7
View File
@@ -13,6 +13,12 @@ androidx-activityCompose = "1.13.0"
# .toUri), and a transitive it merely inherited could change under it. # .toUri), and a transitive it merely inherited could change under it.
androidx-core-ktx = "1.19.0" androidx-core-ktx = "1.19.0"
zxing-embedded = "4.3.0" zxing-embedded = "4.3.0"
# Declared rather than inherited for the same reason as core-ktx: SessionScreen
# now calls repeatOnLifecycle/LocalLifecycleOwner directly, to hold the event
# stream open only while the screen is on screen. Latest stable, checked
# 2026-08-29 against Google Maven; activity-compose alone would have pulled
# 2.9.4.
androidx-lifecycle = "2.11.0"
# The Kotlin formatter, run at its defaults (see CODE_RULES rule 27). ktfmt # The Kotlin formatter, run at its defaults (see CODE_RULES rule 27). ktfmt
# itself is Kotlin-org owned and has almost nothing to configure, which is # itself is Kotlin-org owned and has almost nothing to configure, which is
# the point; this is the Gradle wrapper for it. Checked 2026-08-28. # the point; this is the Gradle wrapper for it. Checked 2026-08-28.
@@ -25,6 +31,7 @@ desugar-jdk-libs = "2.1.5"
[libraries] [libraries]
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core-ktx" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core-ktx" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
# In-app QR scanner: a ready-made scanning Activity (camera preview, runtime # In-app QR scanner: a ready-made scanning Activity (camera preview, runtime
# permission prompt, flashlight toggle) reached through the AndroidX Activity # permission prompt, flashlight toggle) reached through the AndroidX Activity
# Result API (ScanContract, added in 4.3.0). Fully offline -- no Play # Result API (ScanContract, added in 4.3.0). Fully offline -- no Play