1package soy.iko.opencode.data.network
3import soy.iko.opencode.data.model.BusEvent
5import io.ktor.client.HttpClient
6import io.ktor.client.plugins.timeout
7import io.ktor.client.plugins.sse.sse
8import io.ktor.sse.ServerSentEvent
9import kotlinx.coroutines.CancellationException
10import kotlinx.coroutines.CoroutineScope
11import kotlinx.coroutines.channels.BufferOverflow
12import kotlinx.coroutines.channels.Channel
13import kotlinx.coroutines.channels.ChannelResult
14import kotlinx.coroutines.flow.Flow
15import kotlinx.coroutines.flow.MutableStateFlow
16import kotlinx.coroutines.flow.SharedFlow
17import kotlinx.coroutines.flow.StateFlow
18import kotlinx.coroutines.flow.SharingStarted
19import kotlinx.coroutines.flow.asStateFlow
20import kotlinx.coroutines.flow.buffer
21import kotlinx.coroutines.flow.channelFlow
22import kotlinx.coroutines.flow.produceIn
23import kotlinx.coroutines.flow.shareIn
24import kotlinx.coroutines.isActive
25import kotlinx.coroutines.selects.onTimeout
26import kotlinx.coroutines.selects.select
27import java.io.IOException
28import kotlin.random.Random
31 * Owns the single long-lived `GET /event` SSE subscription and exposes it as a hot
32 * [SharedFlow] of [BusEvent]. The connection is opened when the first collector
33 * subscribes (WhileSubscribed) and reconnects with backoff on drop.
35 * Ordering contract: callers that send a prompt must already be collecting [events]
36 * (directly or via the repository) so early `message.part.updated` deltas aren't missed.
38@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
39class EventStreamClient(
40 private val client: HttpClient,
41 scope: CoroutineScope,
43 enum class ConnectionState { Disconnected, Connecting, Connected }
45 private val _state = MutableStateFlow(ConnectionState.Disconnected)
46 val state: StateFlow<ConnectionState> = _state.asStateFlow()
48 val events: SharedFlow<BusEvent> = stream()
49 // Buffer so a slow subscriber (e.g. a reducer under lock) can't suspend send()
50 // and stall the SSE read loop. Drop oldest on overflow — the next event carries
51 // the freshest state, so a dropped interim delta is harmless.
52 .buffer(NetworkConfig.sseEventBufferCapacity, BufferOverflow.DROP_OLDEST)
55 started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000),
59 /** Conflated channel used to cut the reconnect backoff short when network returns. */
60 private val reconnectSignal = Channel<Unit>(Channel.CONFLATED)
62 /** Returns true if the exception is an SSE auth failure (401/403) that should stop retrying. */
63 private fun isSseAuthFailure(e: Throwable): Boolean =
64 e is io.ktor.client.plugins.ClientRequestException &&
65 (e.response.status.value == 401 || e.response.status.value == 403)
67 /** Request an immediate reconnect, skipping any in-progress backoff. */
68 fun triggerReconnect() { reconnectSignal.trySend(Unit) }
71 * Read SSE events from [events], racing each read against an idle-timeout watchdog.
72 * Throws [IOException] on idle timeout so the outer reconnect loop fires; breaks on
73 * clean stream close or scope cancellation.
75 private suspend fun readSseEvents(
76 scope: CoroutineScope,
77 events: kotlinx.coroutines.channels.ReceiveChannel<ServerSentEvent>,
78 send: suspend (BusEvent) -> Unit,
80 while (scope.isActive) {
81 // The OkHttp engine has readTimeout=0 (the SSE stream must never time out on
82 // read), so a socket the peer never closed would otherwise hang until OS TCP
83 // keepalive kicks in — minutes to hours on stock Linux. If nothing arrives
84 // within the idle timeout, treat the connection as half-open and drop it.
85 val result: ChannelResult<ServerSentEvent>? = select {
86 events.onReceiveCatching { it }
87 onTimeout(NetworkConfig.sseIdleTimeoutMs) { null }
89 if (result == null) throw IOException("SSE idle timeout, reconnecting")
90 val sse = result.getOrNull() ?: break
93 // A comment or keep-alive event (no data field) resets the idle watchdog
94 // — servers send `: ping` comments to keep the connection alive through
95 // proxies. Without this, a server that only sends comments would still
96 // hit the idle timeout and reconnect unnecessarily.
100 OpencodeJson.decodeFromString(BusEvent.serializer(), data)
101 } catch (e: kotlinx.serialization.SerializationException) {
102 Log.d("EventStream", "Skipping unparseable SSE event", e)
105 if (_state.value != ConnectionState.Connected) {
106 _state.value = ConnectionState.Connected
112 private fun stream(): Flow<BusEvent> = channelFlow {
114 // Drain any stale reconnect signal from a previous collection cycle so it
115 // doesn't spuriously reset the backoff on startup (WhileSubscribed may have
116 // stopped the upstream while triggerReconnect() queued a signal).
117 while (reconnectSignal.tryReceive().isSuccess) { /* drain */ }
118 var backoffMs = NetworkConfig.sseInitialBackoffMs
120 _state.value = ConnectionState.Connecting
122 // The SSE stream is long-lived: disable the request-level and socket
123 // timeouts (the client default is 60s for REST) in the request config
124 // so the connection isn't killed mid-stream. The idle-timeout watchdog
125 // below handles half-open connections instead.
130 requestTimeoutMillis = Long.MAX_VALUE
131 // Disable the socket (read) timeout entirely for the SSE stream.
132 // A finite value would kill the connection whenever the server
133 // is quiet for longer than that (idle sessions, slow agent runs),
134 // causing a reconnect storm. Half-open detection is handled by
135 // the idle-timeout watchdog below plus OkHttp's pingInterval on
136 // HTTP/2 connections.
137 socketTimeoutMillis = Long.MAX_VALUE
141 backoffMs = NetworkConfig.sseInitialBackoffMs
142 // Clear any stale reconnect signal from a triggerReconnect() call
143 // made while the previous stream was healthy, so it doesn't suppress
144 // the next legitimate backoff.
145 while (reconnectSignal.tryReceive().isSuccess) { /* drain */ }
146 // `incoming` is a cold Flow; bridge it to a ReceiveChannel so each
147 // element can be raced against an idle timeout via select.
148 val events = incoming.produceIn(scope)
150 readSseEvents(scope, events) { send(it) }
155 } catch (c: CancellationException) {
157 } catch (e: Exception) {
158 // If the scope was cancelled (e.g. connection close), the closed-client
159 // exception is expected — don't log a spurious "stream error" warning.
160 if (isSseAuthFailure(e)) {
161 // Credentials are wrong — retrying won't help. Log and stop.
162 Log.w("EventStream", "SSE auth failed (401/403), stopping retries", e)
163 _state.value = ConnectionState.Disconnected
165 } else if (isActive) {
166 Log.w("EventStream", "SSE stream error, will retry", e)
169 _state.value = ConnectionState.Disconnected
171 // Wait for the backoff, but allow a reconnect signal to cut it short.
173 val jitter = ((backoffMs * 0.2) * (Random.nextDouble() * 2 - 1)).toLong()
175 reconnectSignal.onReceive { signaled = true }
176 onTimeout(backoffMs + jitter) { /* normal backoff elapsed */ }
178 backoffMs = if (signaled) NetworkConfig.sseInitialBackoffMs else (backoffMs * 2).coerceAtMost(NetworkConfig.sseMaxBackoffMs)
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list