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:
1 parent
d7c692a4ec
commit
aff2cb90e2
4 files changed
+71
-31
No files matched your search
@@ -51,6 +51,9 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.AtomicReference
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -171,6 +174,7 @@ fun SessionScreen(
|
||||
// than listed here: a hardcoded list is a claim about a machine.
|
||||
var offeredModels by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
// The resume cursor, written from the stream's IO thread.
|
||||
val lastSeq = remember { AtomicLong(0) }
|
||||
val activeStream = remember { AtomicReference<EventStream?>(null) }
|
||||
@@ -243,43 +247,65 @@ fun SessionScreen(
|
||||
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
|
||||
while (true) {
|
||||
val stream = EventStream(settings, summary.id)
|
||||
activeStream.set(stream)
|
||||
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
stream.run(
|
||||
after = lastSeq.get(),
|
||||
onReset = {
|
||||
// Too far behind to continue from: what is on
|
||||
// screen is a stale prefix of a conversation
|
||||
// that has moved on, and the window arriving
|
||||
// next is not adjacent to it. Dropping the rows
|
||||
// is what makes this the same as opening the
|
||||
// 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)
|
||||
streamError = null
|
||||
while (true) {
|
||||
val stream = EventStream(settings, summary.id)
|
||||
activeStream.set(stream)
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
stream.run(
|
||||
after = lastSeq.get(),
|
||||
// Connected, measured rather than inferred: this is what
|
||||
// takes a failure off the screen, and nothing else does.
|
||||
// Clearing on the first event instead meant an idle
|
||||
// session kept displaying an error it had recovered from.
|
||||
onOpen = { streamError = null },
|
||||
onReset = {
|
||||
// Too far behind to continue from: what is on
|
||||
// screen is a stale prefix of a conversation
|
||||
// that has moved on, and the window arriving
|
||||
// next is not adjacent to it. Dropping the rows
|
||||
// is what makes this the same as opening the
|
||||
// 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 {
|
||||
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;
|
||||
// closing the stream is what unblocks it when this screen goes away.
|
||||
// The screen going away entirely, which the lifecycle scope above does
|
||||
// not cover: a composable can leave the composition while the activity
|
||||
// stays started.
|
||||
DisposableEffect(summary.id) { onDispose { activeStream.get()?.close() } }
|
||||
|
||||
// Whether the view is pinned to the newest message. The list is laid
|
||||
|
||||
Reference in new issue
Block a user