@@ -4,10 +4,11 @@ import soy.iko.opencode.data.model.BusEvent
4import android.util.Log
4import android.util.Log
5import io.ktor.client.HttpClient
5import io.ktor.client.HttpClient
6import io.ktor.client.plugins.sse.sse
6import io.ktor.client.plugins.sse.sse
7+import io.ktor.sse.ServerSentEvent
7import kotlinx.coroutines.CancellationException
8import kotlinx.coroutines.CancellationException
8import kotlinx.coroutines.CoroutineScope
9import kotlinx.coroutines.CoroutineScope
9import kotlinx.coroutines.channels.Channel
10import kotlinx.coroutines.channels.Channel
10−import kotlinx.coroutines.delay
11+import kotlinx.coroutines.channels.ChannelResult
11import kotlinx.coroutines.flow.Flow
12import kotlinx.coroutines.flow.Flow
12import kotlinx.coroutines.flow.MutableStateFlow
13import kotlinx.coroutines.flow.MutableStateFlow
13import kotlinx.coroutines.flow.SharedFlow
14import kotlinx.coroutines.flow.SharedFlow
@@ -15,10 +16,12 @@ import kotlinx.coroutines.flow.StateFlow
15import kotlinx.coroutines.flow.SharingStarted
16import kotlinx.coroutines.flow.SharingStarted
16import kotlinx.coroutines.flow.asStateFlow
17import kotlinx.coroutines.flow.asStateFlow
17import kotlinx.coroutines.flow.channelFlow
18import kotlinx.coroutines.flow.channelFlow
19+import kotlinx.coroutines.flow.produceIn
18import kotlinx.coroutines.flow.shareIn
20import kotlinx.coroutines.flow.shareIn
19import kotlinx.coroutines.isActive
21import kotlinx.coroutines.isActive
20import kotlinx.coroutines.selects.onTimeout
22import kotlinx.coroutines.selects.onTimeout
21import kotlinx.coroutines.selects.select
23import kotlinx.coroutines.selects.select
24+import java.io.IOException
24 * Owns the single long-lived `GET /event` SSE subscription and exposes it as a hot
27 * Owns the single long-lived `GET /event` SSE subscription and exposes it as a hot
@@ -52,22 +55,45 @@ class EventStreamClient(
52 fun triggerReconnect() { reconnectSignal.trySend(Unit) }
55 fun triggerReconnect() { reconnectSignal.trySend(Unit) }
54 private fun stream(): Flow<BusEvent> = channelFlow {
57 private fun stream(): Flow<BusEvent> = channelFlow {
55 var backoffMs = INITIAL_BACKOFF_MS
59 var backoffMs = INITIAL_BACKOFF_MS
56 while (isActive) {
60 while (isActive) {
57 _state.value = ConnectionState.Connecting
61 _state.value = ConnectionState.Connecting
59 client.sse("event") {
63 client.sse("event") {
60 backoffMs = INITIAL_BACKOFF_MS
64 backoffMs = INITIAL_BACKOFF_MS
61− incoming.collect { sse ->
65+ // `incoming` is a cold Flow; bridge it to a ReceiveChannel so each
66+ // element can be raced against an idle timeout via select.
67+ val events = incoming.produceIn(scope)
70+ // The OkHttp engine is configured with readTimeout=0 (the SSE
71+ // stream must never time out on read), so a socket the peer never
72+ // closed would otherwise hang until the OS TCP keepalive kicks in
73+ // — minutes to hours on stock Linux. If nothing arrives within
74+ // IDLE_TIMEOUT_MS, treat the connection as half-open and drop it
75+ // so the outer loop reconnects.
76+ val result: ChannelResult<ServerSentEvent>? = select {
77+ events.onReceiveCatching { it }
78+ onTimeout(IDLE_TIMEOUT_MS) { null }
80+ // Timeout: drop and reconnect.
81+ if (result == null) throw IOException("SSE idle timeout, reconnecting")
82+ // Stream closed cleanly: exit to reconnect without a warning log.
83+ val sse = result.getOrNull() ?: break
62− val data = sse.data ?: return@collect
84+ val data = sse.data ?: continue
85+ val event = runCatching {
63− val event = runCatching { OpencodeJson.decodeFromString(BusEvent.serializer(), data) }
86+ OpencodeJson.decodeFromString(BusEvent.serializer(), data)
64− .getOrNull() ?: return@collect
87+ }.getOrNull() ?: continue
65− // Any successfully decoded event means the stream is live;
88+ // Any successfully decoded event means the stream is live;
66− // don't wait for a ServerConnected event to show "Connected".
89+ // don't wait for a ServerConnected event to show "Connected".
67− if (_state.value != ConnectionState.Connected) {
90+ if (_state.value != ConnectionState.Connected) {
68− _state.value = ConnectionState.Connected
91+ _state.value = ConnectionState.Connected
70− send(event)
95+ } finally {
73 } catch (c: CancellationException) {
99 } catch (c: CancellationException) {
@@ -91,5 +117,7 @@ class EventStreamClient(
91 private const val TAG = "EventStream"
117 private const val TAG = "EventStream"
92 const val INITIAL_BACKOFF_MS = 500L
118 const val INITIAL_BACKOFF_MS = 500L
93 const val MAX_BACKOFF_MS = 10_000L
119 const val MAX_BACKOFF_MS = 10_000L
120+ /** Max gap between events before a silent/half-open connection is dropped. */
121+ const val IDLE_TIMEOUT_MS = 90_000L