git.iko.soy/opencode-android
on master help sign in
Fix 8 bugs: probe CancellationException swallow, unshare missing withRetry, safeExceptionSummary 5xx/SSE status, SSE reconnect backoff delay, notif receiver goAsync timeout, share re-nav after process death, removePart pivot leak, AppShortcuts wipe on transient failure
commit59aa9924152aa2f90bb1f3651ce7aa86b054d498 authoriko <mail@iko.soy> dateFri 3 Jul 2026, 01:40 (3 months ago) parent49952c3
9 files changed+135−27
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+43−12 app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+8 app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+1−1 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+6 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+4 app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.kt+29−2 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+6−3 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+28−8 app/src/main/java/soy/iko/opencode/util/Coroutines.kt+10−1
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+43−12
@@ -97,10 +97,16 @@ open class EventStreamClient(
9797 * Read SSE events from [events], racing each read against an idle-timeout watchdog.
9898 * Throws [IOException] on idle timeout so the outer reconnect loop fires; breaks on
9999 * clean stream close or scope cancellation.
100+ *
101+ * If the idle watchdog tripped because a [triggerReconnect] signal was consumed
102+ * (rather than a genuine idle timeout), [onReconnectConsumed] is invoked so the outer
103+ * reconnect loop can reconnect immediately instead of waiting out the backoff — the
104+ * signal is already consumed, so the backoff `select` couldn't be cut short.
100105 */
101106 private suspend fun readSseEvents(
102107 scope: CoroutineScope,
103108 events: kotlinx.coroutines.channels.ReceiveChannel<ServerSentEvent>,
109+ onReconnectConsumed: () -> Unit,
104110 send: suspend (BusEvent) -> Unit,
105111 ) {
106112 while (scope.isActive) {
@@ -108,17 +114,20 @@ open class EventStreamClient(
108114 // read), so a socket the peer never closed would otherwise hang until OS TCP
109115 // keepalive kicks in — minutes to hours on stock Linux. If nothing arrives
110116 // within the idle timeout, treat the connection as half-open and drop it.
117+ var consumedReconnectSignal = false
111118 val result: ChannelResult<ServerSentEvent>? = select {
112119 events.onReceiveCatching { it }
113120 onTimeout(idleTimeoutMs) { null }
114121 // A triggerReconnect() issued while a read is active must interrupt it now:
115122 // return the same null sentinel as the idle timeout so a half-open socket
116123 // (no TCP RST) is dropped and reconnected promptly instead of waiting out
117− // idleTimeoutMs (~90s). Consuming the signal here also stops the backoff
118− // select below from re-triggering on the stale request.
119− reconnectSignal.onReceive { null }
124+ // idleTimeoutMs (~90s).
125+ reconnectSignal.onReceive { consumedReconnectSignal = true; null }
126+ }
127+ if (result == null) {
128+ if (consumedReconnectSignal) onReconnectConsumed()
129+ throw IOException("SSE read dropped (idle timeout or reconnect requested), reconnecting")
120130 }
121− if (result == null) throw IOException("SSE read dropped (idle timeout or reconnect requested), reconnecting")
122131 val sse = result.getOrNull()
123132 if (sse == null) {
124133 // The producer channel is closed. A non-null cause means `incoming`
@@ -158,6 +167,12 @@ open class EventStreamClient(
158167 var backoffMs = initialBackoffMs
159168 while (isActive) {
160169 _state.value = ConnectionState.Connecting
170+ // Set by readSseEvents when a triggerReconnect() signal interrupted an active
171+ // read. The signal is consumed to drop the socket, so the backoff select below
172+ // can't also see it — without this flag the reconnect would wait the full
173+ // backoff despite the caller asking for an immediate reconnect (network
174+ // handoff, manual refresh, ...).
175+ var reconnectConsumedDuringRead = false
161176 try {
162177 // The SSE stream is long-lived: disable the request-level and socket
163178 // timeouts (the client default is 60s for REST) in the request config
@@ -221,7 +236,7 @@ open class EventStreamClient(
221236 // never escalate the exponential ramp.
222237 var receivedAny = false
223238 try {
224− readSseEvents(scope, events) {
239+ readSseEvents(scope, events, { reconnectConsumedDuringRead = true }) {
225240 if (!receivedAny) {
226241 receivedAny = true
227242 backoffMs = initialBackoffMs
@@ -272,14 +287,30 @@ open class EventStreamClient(
272287 }
273288 _state.value = ConnectionState.Disconnected
274289 if (!isActive) break
275− // Wait for the backoff, but allow a reconnect signal to cut it short.
276− var signaled = false
277− val jitter = ((backoffMs * NetworkConfig.retryJitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
278− select {
279− reconnectSignal.onReceive { signaled = true }
280− onTimeout((backoffMs + jitter).coerceAtLeast(0)) { /* normal backoff elapsed */ }
281− }
290+ // Wait for the backoff, but allow a reconnect signal to cut it short. When
291+ // readSseEvents consumed a triggerReconnect() signal to drop an active read,
292+ // the signal is already spent so the select below can't receive it — pass a
293+ // zero backoff in that case so the reconnect is immediate (the whole point of
294+ // triggerReconnect: network handoff, manual refresh, ...).
295+ val signaled = waitForBackoffOrSignal(reconnectConsumedDuringRead, backoffMs)
282296 backoffMs = if (signaled) initialBackoffMs else (backoffMs * 2).coerceAtMost(maxBackoffMs)
283297 }
284298 }
299+
300+ /** Suspend for the backoff unless [reconnectConsumedDuringRead] (immediate) or a new
301+ * [triggerReconnect] arrives during the wait. Returns true when the reconnect should
302+ * skip the backoff ramp (signal-driven), false when the full backoff elapsed. */
303+ private suspend fun waitForBackoffOrSignal(
304+ reconnectConsumedDuringRead: Boolean,
305+ backoffMs: Long,
306+ ): Boolean {
307+ if (reconnectConsumedDuringRead) return true
308+ var signaled = false
309+ val jitter = ((backoffMs * NetworkConfig.retryJitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
310+ select {
311+ reconnectSignal.onReceive { signaled = true }
312+ onTimeout((backoffMs + jitter).coerceAtLeast(0)) { /* normal backoff elapsed */ }
313+ }
314+ return signaled
315+ }
285316}
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+8
@@ -125,6 +125,14 @@ object NetworkConfig {
125125 /** Grace period before a cold StateFlow is stopped after its last subscriber leaves. */
126126 const val stateFlowSubscriptionTimeoutMs = 5_000L
127127
128+ /** Maximum time a notification action BroadcastReceiver may hold its goAsync()
129+ * PendingResult open. The system ANRs a receiver that doesn't call finish() within
130+ * ~10s, so this must stay below that; a slow permission-respond retry (withRetry
131+ * exponential backoff up to ~90s) or a blocked outbox enqueue could otherwise blow
132+ * past the window. The watchdog finishes the PendingResult at this deadline so the
133+ * receiver never ANRs; the underlying network call keeps running on the app scope. */
134+ const val notificationReceiverTimeoutMs = 8_000L
135+
128136 // --- Draft persistence (ChatViewModel) ---
129137
130138 /** Debounce delay before persisting a draft to disk after the user stops typing. */
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+1−1
@@ -244,7 +244,7 @@ open class OpencodeApiClient private constructor(
244244 withRetry { client!!.delete("session/${encode(sessionId)}/share").body() }
245245 } catch (t: ClientRequestException) {
246246 if (t.response.status.value != 404) throw t
247− client!!.get("session/${encode(sessionId)}").body()
247+ withRetry { client!!.get("session/${encode(sessionId)}").body() }
248248 }
249249 }
250250
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+6
@@ -606,6 +606,12 @@ internal class MessageStore {
606606 private fun removePart(messageId: String, partId: String): Boolean {
607607 val current = messages[messageId] ?: return false
608608 if (current.parts.remove(partId) == null) return false
609+ // Drop the pivot entry too so the set doesn't accumulate ids of removed parts for
610+ // the rest of the session (it's otherwise only cleared on reseed/eviction). No
611+ // correctness impact today — a stale entry just falls back to the REST snapshot's
612+ // version in seed() — but keeping it symmetric with handleMessageRemoved/eviction
613+ // bounds the set's growth over a long-lived session.
614+ streamedSincePivot.remove(partId)
609615 current.invalidate()
610616 return true
611617 }
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+4
@@ -1029,6 +1029,8 @@ open class AppContainer private constructor(
10291029 val api = OpencodeApiClient(client)
10301030 api.ping()
10311031 true
1032+ } catch (c: kotlinx.coroutines.CancellationException) {
1033+ throw c
10321034 } catch (e: Exception) {
10331035 val status = responseStatusCode(e)
10341036 if (status == 401 || status == 403) false else throw e
@@ -1043,6 +1045,8 @@ open class AppContainer private constructor(
10431045 val api = OpencodeApiClient(client)
10441046 api.ping()
10451047 ProbeResult.Reachable
1048+ } catch (c: kotlinx.coroutines.CancellationException) {
1049+ throw c
10461050 } catch (e: Exception) {
10471051 val status = responseStatusCode(e)
10481052 if (status == 401 || status == 403) {
app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.kt+29−2
@@ -6,6 +6,8 @@ import android.content.Intent
66import androidx.core.app.RemoteInput
77import soy.iko.opencode.OpencodeApp
88import soy.iko.opencode.data.model.PermissionResponse
9+import soy.iko.opencode.data.network.NetworkConfig
10+import java.util.concurrent.atomic.AtomicBoolean
911
1012/**
1113 * Handles the action buttons on the app's notifications so the user can respond without
@@ -19,6 +21,11 @@ import soy.iko.opencode.data.model.PermissionResponse
1921 * The receiver is not exported (see the manifest); the [PendingIntent]s that target it are
2022 * created by the app itself. Work is done on the process-lived app scope via [OpencodeApp]'s
2123 * container, and [goAsync] keeps the receiver alive until the (network) call resolves.
24+ *
25+ * A watchdog finishes the [PendingResult] within [NetworkConfig.notificationReceiverTimeoutMs]
26+ * so a slow network call (e.g. a permission respond retried with exponential backoff) can't
27+ * ANR the receiver. The underlying call keeps running on the app scope; only the receiver's
28+ * lifetime is bounded. [finishOnce] guards against a double finish() (which would throw).
2229 */
2330class NotificationActionReceiver : BroadcastReceiver() {
2431
@@ -33,13 +40,16 @@ class NotificationActionReceiver : BroadcastReceiver() {
3340 it.wire == intent.getStringExtra(EXTRA_RESPONSE)
3441 } ?: return
3542 val pending = goAsync()
43+ val finished = AtomicBoolean(false)
44+ val finishOnce = { if (finished.compareAndSet(false, true)) pending.finish() }
45+ watchdog(finishOnce)
3646 container.respondToPermissionFromNotification(sessionId, permissionId, response, profileId) { success ->
3747 // Only dismiss the notification once the tool was actually answered. If
3848 // there was no live connection (respond is a no-op) or the call failed,
3949 // leave it up so the user can retry — otherwise it vanishes with the
4050 // permission request left unanswered.
4151 if (success) SessionNotifications.cancelPermission(context, sessionId)
42− pending.finish()
52+ finishOnce()
4353 }
4454 }
4555 ACTION_REPLY -> {
@@ -47,17 +57,34 @@ class NotificationActionReceiver : BroadcastReceiver() {
4757 ?.getCharSequence(KEY_REPLY_TEXT)?.toString()?.trim()
4858 ?.takeIf { it.isNotEmpty() } ?: return
4959 val pending = goAsync()
60+ val finished = AtomicBoolean(false)
61+ val finishOnce = { if (finished.compareAndSet(false, true)) pending.finish() }
62+ watchdog(finishOnce)
5063 container.sendPromptFromNotification(sessionId, text) { enqueued ->
5164 // The reply is durably queued (and flushed now if online); clear the
5265 // "session ready" notification. If the enqueue failed, leave it up so the
5366 // reply isn't silently lost and the user can retry.
5467 if (enqueued) SessionNotifications.cancel(context, sessionId)
55− pending.finish()
68+ finishOnce()
5669 }
5770 }
5871 }
5972 }
6073
74+ /** Schedule a deadline after which the receiver's [PendingResult] is finished even if
75+ * the network call hasn't resolved, so the system can't ANR the receiver. The
76+ * underlying work continues on the app scope. */
77+ private fun watchdog(finishOnce: () -> Unit) {
78+ Thread {
79+ try {
80+ Thread.sleep(NetworkConfig.notificationReceiverTimeoutMs)
81+ } catch (_: InterruptedException) {
82+ return@Thread
83+ }
84+ finishOnce()
85+ }.apply { isDaemon = true; name = "notif-receiver-watchdog"; start() }
86+ }
87+
6188 companion object {
6289 const val ACTION_PERMISSION = "soy.iko.opencode.action.PERMISSION"
6390 const val ACTION_REPLY = "soy.iko.opencode.action.REPLY"
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+6−3
@@ -11,8 +11,8 @@ import androidx.compose.runtime.CompositionLocalProvider
1111import androidx.compose.runtime.LaunchedEffect
1212import androidx.compose.runtime.getValue
1313import androidx.compose.runtime.mutableStateOf
14−import androidx.compose.runtime.remember
1514import androidx.compose.runtime.rememberCoroutineScope
15+import androidx.compose.runtime.saveable.rememberSaveable
1616import androidx.compose.runtime.setValue
1717import androidx.compose.ui.unit.dp
1818import androidx.navigation.NavType
@@ -73,8 +73,11 @@ fun OpencodeApp(container: AppContainer) {
7373 // consumes it via consumePendingSharedMedia). Without this, re-sharing the *same* images
7474 // after consumption compares equal structurally ([A,B] == [A,B]) and would be treated as
7575 // "already handled", silently skipping the session-list navigation the second time.
76− var handledShareText by remember { mutableStateOf<String?>(null) }
77− var handledSharedMedia by remember { mutableStateOf<List<String>>(emptyList()) }
76+ //
77+ // rememberSaveable (not remember) so a process-kill restore doesn't reset the dedup
78+ // state and re-navigate to SESSIONS for a share the user already handled before the kill.
79+ var handledShareText by rememberSaveable { mutableStateOf<String?>(null) }
80+ var handledSharedMedia by rememberSaveable { mutableStateOf<List<String>>(emptyList()) }
7881 LaunchedEffect(pendingShare, pendingSharedMedia, connection) {
7982 // Reset the remembered handled state for each channel independently as it drains,
8083 // so re-sharing the same payload after consumption is still recognized as new. A
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+28−8
@@ -148,14 +148,7 @@ fun SessionListScreen(
148148 // session list — the best source of session titles. Cheap, idempotent, and off the main
149149 // thread (the store write dispatches to IO); the widget refresh no-ops when none is placed.
150150 val platformContext = LocalContext.current.applicationContext
151− LaunchedEffect(state.sessions) {
152− val sessions = state.sessions
153− if (sessions.isEmpty()) return@LaunchedEffect
154− val recents = sessions.take(RecentSessionsStore.MAX).map { RecentSession(it.id, it.displayTitle) }
155− RecentSessionsStore.write(platformContext, recents)
156− AppShortcuts.update(platformContext, recents.firstOrNull())
157− SessionsWidgetProvider.refresh(platformContext)
158− }
151+ SyncShortcutsAndWidget(platformContext, state)
159152
160153 // Close the open dropdown on back press instead of navigating away.
161154 BackHandler(enabled = showServerMenu) { showServerMenu = false }
@@ -577,6 +570,33 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
577570/** A session plus its nesting [depth] (0 = top level) for the sub-session tree. */
578571private data class SessionNode(val session: soy.iko.opencode.data.model.Session, val depth: Int)
579572
573+/**
574+ * Keep the home-screen widget, launcher shortcuts, and "Resume last" in sync with the
575+ * loaded session list. When the list is populated, write recents and point "Resume last"
576+ * at the most recent session. When the server is genuinely empty (done loading, no error),
577+ * clear "Resume last" so it doesn't point at a deleted/nonexistent session. A transient
578+ * load failure (error set) leaves existing shortcuts intact — wiping them on a network
579+ * blip would vanish a valid shortcut until the next successful load.
580+ */
581+@Composable
582+private fun SyncShortcutsAndWidget(
583+ platformContext: android.content.Context,
584+ state: SessionListState,
585+) {
586+ val serverIsEmpty = state.sessions.isEmpty() && state.error == null && !state.loading
587+ LaunchedEffect(serverIsEmpty) {
588+ if (serverIsEmpty) AppShortcuts.update(platformContext, null)
589+ }
590+ LaunchedEffect(state.sessions) {
591+ val sessions = state.sessions
592+ if (sessions.isEmpty()) return@LaunchedEffect
593+ val recents = sessions.take(RecentSessionsStore.MAX).map { RecentSession(it.id, it.displayTitle) }
594+ RecentSessionsStore.write(platformContext, recents)
595+ AppShortcuts.update(platformContext, recents.firstOrNull())
596+ SessionsWidgetProvider.refresh(platformContext)
597+ }
598+}
599+
580600/**
581601 * Order [sessions] so each child (a session whose `parentID` is also present) appears
582602 * immediately after its parent, tracking depth for indentation. The input order (already
app/src/main/java/soy/iko/opencode/util/Coroutines.kt+10−1
@@ -21,12 +21,21 @@ inline fun <T> runCatchingCancellable(block: () -> T): Result<T> = try {
2121 * [ClientRequestException] embeds in its message and may contain auth or paths).
2222 * Use this instead of logging the full exception object when it may originate
2323 * from a Ktor HTTP call.
24+ *
25+ * Extracts the HTTP status code when available — it's safe to log (not a URL) and
26+ * makes 4xx/5xx REST and SSE-establishment failures actionable. Walks the cause
27+ * chain so a [ServerResponseException] (5xx) or [SSEClientException] wrapping a
28+ * [ClientRequestException] still surfaces its status.
2429 */
2530fun safeExceptionSummary(e: Throwable): String {
2631 var current: Throwable? = e
2732 var hops = 0
2833 while (current != null && hops < 8) {
29− val status = (current as? io.ktor.client.plugins.ClientRequestException)?.response?.status?.value
34+ val status = when (current) {
35+ is io.ktor.client.plugins.ResponseException -> current.response.status.value
36+ is io.ktor.client.plugins.sse.SSEClientException -> current.response?.status?.value
37+ else -> null
38+ }
3039 if (status != null) return "${current.javaClass.simpleName}($status)"
3140 current = current.cause
3241 hops++