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
59aa992iko
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+43−12
@@ -97,10 +97,16 @@ open class EventStreamClient(
97 * Read SSE events from [events], racing each read against an idle-timeout watchdog.
97 * Read SSE events from [events], racing each read against an idle-timeout watchdog.
98 * Throws [IOException] on idle timeout so the outer reconnect loop fires; breaks on
98 * Throws [IOException] on idle timeout so the outer reconnect loop fires; breaks on
99 * clean stream close or scope cancellation.
99 * 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.
100 */
105 */
101 private suspend fun readSseEvents(
106 private suspend fun readSseEvents(
102 scope: CoroutineScope,
107 scope: CoroutineScope,
103 events: kotlinx.coroutines.channels.ReceiveChannel<ServerSentEvent>,
108 events: kotlinx.coroutines.channels.ReceiveChannel<ServerSentEvent>,
109+ onReconnectConsumed: () -> Unit,
104 send: suspend (BusEvent) -> Unit,
110 send: suspend (BusEvent) -> Unit,
105 ) {
111 ) {
106 while (scope.isActive) {
112 while (scope.isActive) {
@@ -108,17 +114,20 @@ open class EventStreamClient(
108 // read), so a socket the peer never closed would otherwise hang until OS TCP
114 // read), so a socket the peer never closed would otherwise hang until OS TCP
109 // keepalive kicks in — minutes to hours on stock Linux. If nothing arrives
115 // keepalive kicks in — minutes to hours on stock Linux. If nothing arrives
110 // within the idle timeout, treat the connection as half-open and drop it.
116 // within the idle timeout, treat the connection as half-open and drop it.
117+ var consumedReconnectSignal = false
111 val result: ChannelResult<ServerSentEvent>? = select {
118 val result: ChannelResult<ServerSentEvent>? = select {
112 events.onReceiveCatching { it }
119 events.onReceiveCatching { it }
113 onTimeout(idleTimeoutMs) { null }
120 onTimeout(idleTimeoutMs) { null }
114 // A triggerReconnect() issued while a read is active must interrupt it now:
121 // A triggerReconnect() issued while a read is active must interrupt it now:
115 // return the same null sentinel as the idle timeout so a half-open socket
122 // return the same null sentinel as the idle timeout so a half-open socket
116 // (no TCP RST) is dropped and reconnected promptly instead of waiting out
123 // (no TCP RST) is dropped and reconnected promptly instead of waiting out
117− // idleTimeoutMs (~90s). Consuming the signal here also stops the backoff
124+ // idleTimeoutMs (~90s).
118− // select below from re-triggering on the stale request.
119− reconnectSignal.onReceive { null }
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")
120 }
130 }
121− if (result == null) throw IOException("SSE read dropped (idle timeout or reconnect requested), reconnecting")
122 val sse = result.getOrNull()
131 val sse = result.getOrNull()
123 if (sse == null) {
132 if (sse == null) {
124 // The producer channel is closed. A non-null cause means `incoming`
133 // The producer channel is closed. A non-null cause means `incoming`
@@ -158,6 +167,12 @@ open class EventStreamClient(
158 var backoffMs = initialBackoffMs
167 var backoffMs = initialBackoffMs
159 while (isActive) {
168 while (isActive) {
160 _state.value = ConnectionState.Connecting
169 _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
161 try {
176 try {
162 // The SSE stream is long-lived: disable the request-level and socket
177 // The SSE stream is long-lived: disable the request-level and socket
163 // timeouts (the client default is 60s for REST) in the request config
178 // timeouts (the client default is 60s for REST) in the request config
@@ -221,7 +236,7 @@ open class EventStreamClient(
221 // never escalate the exponential ramp.
236 // never escalate the exponential ramp.
222 var receivedAny = false
237 var receivedAny = false
223 try {
238 try {
224− readSseEvents(scope, events) {
239+ readSseEvents(scope, events, { reconnectConsumedDuringRead = true }) {
225 if (!receivedAny) {
240 if (!receivedAny) {
226 receivedAny = true
241 receivedAny = true
227 backoffMs = initialBackoffMs
242 backoffMs = initialBackoffMs
@@ -272,14 +287,30 @@ open class EventStreamClient(
272 }
287 }
273 _state.value = ConnectionState.Disconnected
288 _state.value = ConnectionState.Disconnected
274 if (!isActive) break
289 if (!isActive) break
275− // Wait for the backoff, but allow a reconnect signal to cut it short.
290+ // Wait for the backoff, but allow a reconnect signal to cut it short. When
276− var signaled = false
291+ // readSseEvents consumed a triggerReconnect() signal to drop an active read,
277− val jitter = ((backoffMs * NetworkConfig.retryJitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
292+ // the signal is already spent so the select below can't receive it — pass a
278− select {
293+ // zero backoff in that case so the reconnect is immediate (the whole point of
279− reconnectSignal.onReceive { signaled = true }
294+ // triggerReconnect: network handoff, manual refresh, ...).
280− onTimeout((backoffMs + jitter).coerceAtLeast(0)) { /* normal backoff elapsed */ }
295+ val signaled = waitForBackoffOrSignal(reconnectConsumedDuringRead, backoffMs)
281− }
282 backoffMs = if (signaled) initialBackoffMs else (backoffMs * 2).coerceAtMost(maxBackoffMs)
296 backoffMs = if (signaled) initialBackoffMs else (backoffMs * 2).coerceAtMost(maxBackoffMs)
283 }
297 }
284 }
298 }
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+ }
285}
316}
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+8
@@ -125,6 +125,14 @@ object NetworkConfig {
125 /** Grace period before a cold StateFlow is stopped after its last subscriber leaves. */
125 /** Grace period before a cold StateFlow is stopped after its last subscriber leaves. */
126 const val stateFlowSubscriptionTimeoutMs = 5_000L
126 const val stateFlowSubscriptionTimeoutMs = 5_000L
127
127
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+
128 // --- Draft persistence (ChatViewModel) ---
136 // --- Draft persistence (ChatViewModel) ---
129
137
130 /** Debounce delay before persisting a draft to disk after the user stops typing. */
138 /** 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(
244 withRetry { client!!.delete("session/${encode(sessionId)}/share").body() }
244 withRetry { client!!.delete("session/${encode(sessionId)}/share").body() }
245 } catch (t: ClientRequestException) {
245 } catch (t: ClientRequestException) {
246 if (t.response.status.value != 404) throw t
246 if (t.response.status.value != 404) throw t
247− client!!.get("session/${encode(sessionId)}").body()
247+ withRetry { client!!.get("session/${encode(sessionId)}").body() }
248 }
248 }
249 }
249 }
250
250
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+6
@@ -606,6 +606,12 @@ internal class MessageStore {
606 private fun removePart(messageId: String, partId: String): Boolean {
606 private fun removePart(messageId: String, partId: String): Boolean {
607 val current = messages[messageId] ?: return false
607 val current = messages[messageId] ?: return false
608 if (current.parts.remove(partId) == null) return false
608 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)
609 current.invalidate()
615 current.invalidate()
610 return true
616 return true
611 }
617 }
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+4
@@ -1029,6 +1029,8 @@ open class AppContainer private constructor(
1029 val api = OpencodeApiClient(client)
1029 val api = OpencodeApiClient(client)
1030 api.ping()
1030 api.ping()
1031 true
1031 true
1032+ } catch (c: kotlinx.coroutines.CancellationException) {
1033+ throw c
1032 } catch (e: Exception) {
1034 } catch (e: Exception) {
1033 val status = responseStatusCode(e)
1035 val status = responseStatusCode(e)
1034 if (status == 401 || status == 403) false else throw e
1036 if (status == 401 || status == 403) false else throw e
@@ -1043,6 +1045,8 @@ open class AppContainer private constructor(
1043 val api = OpencodeApiClient(client)
1045 val api = OpencodeApiClient(client)
1044 api.ping()
1046 api.ping()
1045 ProbeResult.Reachable
1047 ProbeResult.Reachable
1048+ } catch (c: kotlinx.coroutines.CancellationException) {
1049+ throw c
1046 } catch (e: Exception) {
1050 } catch (e: Exception) {
1047 val status = responseStatusCode(e)
1051 val status = responseStatusCode(e)
1048 if (status == 401 || status == 403) {
1052 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
6import androidx.core.app.RemoteInput
6import androidx.core.app.RemoteInput
7import soy.iko.opencode.OpencodeApp
7import soy.iko.opencode.OpencodeApp
8import soy.iko.opencode.data.model.PermissionResponse
8import soy.iko.opencode.data.model.PermissionResponse
9+import soy.iko.opencode.data.network.NetworkConfig
10+import java.util.concurrent.atomic.AtomicBoolean
9
11
10/**
12/**
11 * Handles the action buttons on the app's notifications so the user can respond without
13 * 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
19 * The receiver is not exported (see the manifest); the [PendingIntent]s that target it are
21 * The receiver is not exported (see the manifest); the [PendingIntent]s that target it are
20 * created by the app itself. Work is done on the process-lived app scope via [OpencodeApp]'s
22 * created by the app itself. Work is done on the process-lived app scope via [OpencodeApp]'s
21 * container, and [goAsync] keeps the receiver alive until the (network) call resolves.
23 * 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).
22 */
29 */
23class NotificationActionReceiver : BroadcastReceiver() {
30class NotificationActionReceiver : BroadcastReceiver() {
24
31
@@ -33,13 +40,16 @@ class NotificationActionReceiver : BroadcastReceiver() {
33 it.wire == intent.getStringExtra(EXTRA_RESPONSE)
40 it.wire == intent.getStringExtra(EXTRA_RESPONSE)
34 } ?: return
41 } ?: return
35 val pending = goAsync()
42 val pending = goAsync()
43+ val finished = AtomicBoolean(false)
44+ val finishOnce = { if (finished.compareAndSet(false, true)) pending.finish() }
45+ watchdog(finishOnce)
36 container.respondToPermissionFromNotification(sessionId, permissionId, response, profileId) { success ->
46 container.respondToPermissionFromNotification(sessionId, permissionId, response, profileId) { success ->
37 // Only dismiss the notification once the tool was actually answered. If
47 // Only dismiss the notification once the tool was actually answered. If
38 // there was no live connection (respond is a no-op) or the call failed,
48 // there was no live connection (respond is a no-op) or the call failed,
39 // leave it up so the user can retry — otherwise it vanishes with the
49 // leave it up so the user can retry — otherwise it vanishes with the
40 // permission request left unanswered.
50 // permission request left unanswered.
41 if (success) SessionNotifications.cancelPermission(context, sessionId)
51 if (success) SessionNotifications.cancelPermission(context, sessionId)
42− pending.finish()
52+ finishOnce()
43 }
53 }
44 }
54 }
45 ACTION_REPLY -> {
55 ACTION_REPLY -> {
@@ -47,17 +57,34 @@ class NotificationActionReceiver : BroadcastReceiver() {
47 ?.getCharSequence(KEY_REPLY_TEXT)?.toString()?.trim()
57 ?.getCharSequence(KEY_REPLY_TEXT)?.toString()?.trim()
48 ?.takeIf { it.isNotEmpty() } ?: return
58 ?.takeIf { it.isNotEmpty() } ?: return
49 val pending = goAsync()
59 val pending = goAsync()
60+ val finished = AtomicBoolean(false)
61+ val finishOnce = { if (finished.compareAndSet(false, true)) pending.finish() }
62+ watchdog(finishOnce)
50 container.sendPromptFromNotification(sessionId, text) { enqueued ->
63 container.sendPromptFromNotification(sessionId, text) { enqueued ->
51 // The reply is durably queued (and flushed now if online); clear the
64 // The reply is durably queued (and flushed now if online); clear the
52 // "session ready" notification. If the enqueue failed, leave it up so the
65 // "session ready" notification. If the enqueue failed, leave it up so the
53 // reply isn't silently lost and the user can retry.
66 // reply isn't silently lost and the user can retry.
54 if (enqueued) SessionNotifications.cancel(context, sessionId)
67 if (enqueued) SessionNotifications.cancel(context, sessionId)
55− pending.finish()
68+ finishOnce()
56 }
69 }
57 }
70 }
58 }
71 }
59 }
72 }
60
73
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+
61 companion object {
88 companion object {
62 const val ACTION_PERMISSION = "soy.iko.opencode.action.PERMISSION"
89 const val ACTION_PERMISSION = "soy.iko.opencode.action.PERMISSION"
63 const val ACTION_REPLY = "soy.iko.opencode.action.REPLY"
90 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
11import androidx.compose.runtime.LaunchedEffect
11import androidx.compose.runtime.LaunchedEffect
12import androidx.compose.runtime.getValue
12import androidx.compose.runtime.getValue
13import androidx.compose.runtime.mutableStateOf
13import androidx.compose.runtime.mutableStateOf
14−import androidx.compose.runtime.remember
15import androidx.compose.runtime.rememberCoroutineScope
14import androidx.compose.runtime.rememberCoroutineScope
15+import androidx.compose.runtime.saveable.rememberSaveable
16import androidx.compose.runtime.setValue
16import androidx.compose.runtime.setValue
17import androidx.compose.ui.unit.dp
17import androidx.compose.ui.unit.dp
18import androidx.navigation.NavType
18import androidx.navigation.NavType
@@ -73,8 +73,11 @@ fun OpencodeApp(container: AppContainer) {
73 // consumes it via consumePendingSharedMedia). Without this, re-sharing the *same* images
73 // consumes it via consumePendingSharedMedia). Without this, re-sharing the *same* images
74 // after consumption compares equal structurally ([A,B] == [A,B]) and would be treated as
74 // after consumption compares equal structurally ([A,B] == [A,B]) and would be treated as
75 // "already handled", silently skipping the session-list navigation the second time.
75 // "already handled", silently skipping the session-list navigation the second time.
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.
76− var handledShareText by remember { mutableStateOf<String?>(null) }
79+ var handledShareText by rememberSaveable { mutableStateOf<String?>(null) }
77− var handledSharedMedia by remember { mutableStateOf<List<String>>(emptyList()) }
80+ var handledSharedMedia by rememberSaveable { mutableStateOf<List<String>>(emptyList()) }
78 LaunchedEffect(pendingShare, pendingSharedMedia, connection) {
81 LaunchedEffect(pendingShare, pendingSharedMedia, connection) {
79 // Reset the remembered handled state for each channel independently as it drains,
82 // Reset the remembered handled state for each channel independently as it drains,
80 // so re-sharing the same payload after consumption is still recognized as new. A
83 // 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(
148 // session list — the best source of session titles. Cheap, idempotent, and off the main
148 // session list — the best source of session titles. Cheap, idempotent, and off the main
149 // thread (the store write dispatches to IO); the widget refresh no-ops when none is placed.
149 // thread (the store write dispatches to IO); the widget refresh no-ops when none is placed.
150 val platformContext = LocalContext.current.applicationContext
150 val platformContext = LocalContext.current.applicationContext
151− LaunchedEffect(state.sessions) {
151+ SyncShortcutsAndWidget(platformContext, state)
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− }
159
152
160 // Close the open dropdown on back press instead of navigating away.
153 // Close the open dropdown on back press instead of navigating away.
161 BackHandler(enabled = showServerMenu) { showServerMenu = false }
154 BackHandler(enabled = showServerMenu) { showServerMenu = false }
@@ -577,6 +570,33 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
577/** A session plus its nesting [depth] (0 = top level) for the sub-session tree. */
570/** A session plus its nesting [depth] (0 = top level) for the sub-session tree. */
578private data class SessionNode(val session: soy.iko.opencode.data.model.Session, val depth: Int)
571private data class SessionNode(val session: soy.iko.opencode.data.model.Session, val depth: Int)
579
572
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+
580/**
600/**
581 * Order [sessions] so each child (a session whose `parentID` is also present) appears
601 * Order [sessions] so each child (a session whose `parentID` is also present) appears
582 * immediately after its parent, tracking depth for indentation. The input order (already
602 * 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 {
21 * [ClientRequestException] embeds in its message and may contain auth or paths).
21 * [ClientRequestException] embeds in its message and may contain auth or paths).
22 * Use this instead of logging the full exception object when it may originate
22 * Use this instead of logging the full exception object when it may originate
23 * from a Ktor HTTP call.
23 * 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.
24 */
29 */
25fun safeExceptionSummary(e: Throwable): String {
30fun safeExceptionSummary(e: Throwable): String {
26 var current: Throwable? = e
31 var current: Throwable? = e
27 var hops = 0
32 var hops = 0
28 while (current != null && hops < 8) {
33 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+ }
30 if (status != null) return "${current.javaClass.simpleName}($status)"
39 if (status != null) return "${current.javaClass.simpleName}($status)"
31 current = current.cause
40 current = current.cause
32 hops++
41 hops++