HTTP fallback leg (docs/19, app): State.HttpFallback + SSE/long-poll receive
When the WS is down but the gateway is reachable over HTTP, the app stays sendable instead of waiting out the WS redial backoff: - HttpGateway.kt: OkHttp client for the HTTP leg — GET /v1/health (2 s probe), POST /v1/frame (accept-and-ack; 4xx error frames parsed), SSE GET /v1/events (hand-rolled line parser: event/id/data, comments, multi-line data, Last-Event-ID bookkeeping), long-poll GET /v1/poll. Per-purpose call timeouts (SSE heartbeat 15 s / poll hold 25 s exceed OkHttp's 10 s default read timeout). deriveHttpUrl: ws(s)://host:port -> http(s)://host:8791 (pure, unit-tested). - GatewayClient.kt: new State.HttpFallback (sibling of Connected, both implement State.HelloInfo). connectLoop races the WS dial against the health probe (sendable in < 1 s on a dead WS port); on WS loss it enters fallback immediately (no backoff gate on the send path); on WS reconnect it stops the HTTP leg (media available again). sendMessage/ sendFrame route to POST in fallback (mediaRefs dropped — media is WS-only in v1); SSE frames feed the same _events flow, so request-id correlation is unchanged. The SSE hello is treated like hello.ack (caps/channels/lastPushedCursor + onHelloAck fast path). After two consecutive SSE open failures the receive loop switches to long-poll until the next full (re)connect. - IrisController.kt: onHelloAck/onConnectedLane take State.HelloInfo; state collector + deep-link/commands-catalog gates accept fallback. - ChatScreen.kt: send gate accepts fallback; attach button disabled in fallback (media needs the live connection); status pill shows 'connected · http' (green). - Tests: HttpGatewayTest (URL derivation + SSE parser, 8 tests); full :shared desktop + android-host suites green.
This commit is contained in:
1 parent
e5c7d690b8
commit
7f936fa596
5 files changed
+723
-25
No files matched your search
@@ -27,6 +27,7 @@ import iris.util.IrisLog
|
|||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -72,14 +73,33 @@ class GatewayClient(
|
|||||||
|
|
||||||
data object Connecting : State
|
data object Connecting : State
|
||||||
|
|
||||||
|
/** Common surface of [Connected] and [HttpFallback]: both carry the
|
||||||
|
* hello.ack data (caps, channels, push watermark). */
|
||||||
|
interface HelloInfo {
|
||||||
|
val caps: ServerCaps
|
||||||
|
val channels: List<ChannelInfo>
|
||||||
|
val lastPushedCursor: Long
|
||||||
|
}
|
||||||
|
|
||||||
data class Connected(
|
data class Connected(
|
||||||
val caps: ServerCaps,
|
override val caps: ServerCaps,
|
||||||
val channels: List<ChannelInfo>,
|
override val channels: List<ChannelInfo>,
|
||||||
/** M5: highest outbox cursor already pushed to this device
|
/** M5: highest outbox cursor already pushed to this device
|
||||||
* (from hello.ack; 0 = never). Sync-replayed frames at/below
|
* (from hello.ack; 0 = never). Sync-replayed frames at/below
|
||||||
* it must not re-post system notifications (docs/08 §8.7). */
|
* it must not re-post system notifications (docs/08 §8.7). */
|
||||||
val lastPushedCursor: Long = 0,
|
override val lastPushedCursor: Long = 0,
|
||||||
) : State
|
) : State,
|
||||||
|
HelloInfo
|
||||||
|
|
||||||
|
/** docs/19: the WS is down but the gateway is reachable over the
|
||||||
|
* HTTP leg — sendable (POST /v1/frame) + receiving (SSE/long-poll).
|
||||||
|
* Media is unavailable until the WS is back. */
|
||||||
|
data class HttpFallback(
|
||||||
|
override val caps: ServerCaps,
|
||||||
|
override val channels: List<ChannelInfo>,
|
||||||
|
override val lastPushedCursor: Long = 0,
|
||||||
|
) : State,
|
||||||
|
HelloInfo
|
||||||
|
|
||||||
data object Reconnecting : State
|
data object Reconnecting : State
|
||||||
|
|
||||||
@@ -116,6 +136,18 @@ class GatewayClient(
|
|||||||
private var lastLiveness: TimeMark = TimeSource.Monotonic.markNow()
|
private var lastLiveness: TimeMark = TimeSource.Monotonic.markNow()
|
||||||
private val pending = mutableMapOf<Int, CompletableDeferred<Frame>>()
|
private val pending = mutableMapOf<Int, CompletableDeferred<Frame>>()
|
||||||
|
|
||||||
|
// docs/19: HTTP fallback leg (the "HTTP leg"). [http] is created lazily
|
||||||
|
// from the stored WS URL; [httpJob] runs the SSE/long-poll receive loop;
|
||||||
|
// [httpCursor] is the resume cursor (SSE id / outbox high-water mark).
|
||||||
|
private var http: HttpGateway? = null
|
||||||
|
private var httpJob: Job? = null
|
||||||
|
private var httpCursor: Long = 0
|
||||||
|
private var sseFailures = 0
|
||||||
|
private var usingLongPoll = false
|
||||||
|
// Last hello.ack payload (WS or SSE) — used to restore State.Connected
|
||||||
|
// after a fallback/WS state race in the connect loop.
|
||||||
|
private var lastAck: HelloAckPayload? = null
|
||||||
|
|
||||||
// M4: binary frames (media upload chunks / pull stream) have no per-frame
|
// M4: binary frames (media upload chunks / pull stream) have no per-frame
|
||||||
// id, so at most one binary session is active per socket. The gateway
|
// id, so at most one binary session is active per socket. The gateway
|
||||||
// allows one upload per connection; pull is request/response.
|
// allows one upload per connection; pull is request/response.
|
||||||
@@ -136,7 +168,7 @@ class GatewayClient(
|
|||||||
* (Dispatchers.Default) and would push a history request past a flaky
|
* (Dispatchers.Default) and would push a history request past a flaky
|
||||||
* network's window. Set before [start].
|
* network's window. Set before [start].
|
||||||
*/
|
*/
|
||||||
var onHelloAck: ((State.Connected) -> Unit)? = null
|
var onHelloAck: ((State.HelloInfo) -> Unit)? = null
|
||||||
|
|
||||||
// M4: only one pull may be in flight at a time (binarySession is a single
|
// M4: only one pull may be in flight at a time (binarySession is a single
|
||||||
// slot). Serialize concurrent offers so their byte streams don't interleave.
|
// slot). Serialize concurrent offers so their byte streams don't interleave.
|
||||||
@@ -156,6 +188,7 @@ class GatewayClient(
|
|||||||
fun stop() {
|
fun stop() {
|
||||||
connectJob?.cancel()
|
connectJob?.cancel()
|
||||||
connectJob = null
|
connectJob = null
|
||||||
|
stopHttpLeg()
|
||||||
socket?.close(1000, "client shutdown")
|
socket?.close(1000, "client shutdown")
|
||||||
socket = null
|
socket = null
|
||||||
_state.value = State.Disconnected
|
_state.value = State.Disconnected
|
||||||
@@ -176,9 +209,20 @@ class GatewayClient(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
_state.value = if (hasConnected) State.Reconnecting else State.Connecting
|
_state.value = if (hasConnected) State.Reconnecting else State.Connecting
|
||||||
val dial = dial(url, token)
|
// docs/19: race the WS dial against the HTTP health probe. If the
|
||||||
|
// gateway is alive over HTTP, the app can send immediately
|
||||||
|
// (fallback) without waiting out the WS dial timeout — the key
|
||||||
|
// UX fix (sendable in < 1 s on a dead WS port).
|
||||||
|
val dialDeferred = scope.async { dial(url, token) }
|
||||||
|
val healthOk =
|
||||||
|
withTimeoutOrNull(2_000) {
|
||||||
|
httpHealthy()
|
||||||
|
} ?: false
|
||||||
|
if (healthOk) enterHttpFallback()
|
||||||
|
val dial = dialDeferred.await()
|
||||||
when (val result = dial.result) {
|
when (val result = dial.result) {
|
||||||
is DialResult.AuthFailed -> {
|
is DialResult.AuthFailed -> {
|
||||||
|
stopHttpLeg()
|
||||||
_state.value = State.AuthFailed(result.message)
|
_state.value = State.AuthFailed(result.message)
|
||||||
dial.socket.close(1000, "auth failed")
|
dial.socket.close(1000, "auth failed")
|
||||||
return
|
return
|
||||||
@@ -187,10 +231,20 @@ class GatewayClient(
|
|||||||
DialResult.Connected -> {
|
DialResult.Connected -> {
|
||||||
hasConnected = true
|
hasConnected = true
|
||||||
attempt = 0
|
attempt = 0
|
||||||
|
// WS is up again: back to WS-only (media available).
|
||||||
|
stopHttpLeg()
|
||||||
lastLiveness = TimeSource.Monotonic.markNow()
|
lastLiveness = TimeSource.Monotonic.markNow()
|
||||||
|
// Restore Connected if the fallback state won the race
|
||||||
|
// (health landed before hello.ack).
|
||||||
|
lastAck?.let {
|
||||||
|
_state.value =
|
||||||
|
State.Connected(it.serverCaps, it.channels, it.lastPushedCursor)
|
||||||
|
}
|
||||||
dial.closed.await()
|
dial.closed.await()
|
||||||
if (!currentCoroutineContext().isActive) return
|
if (!currentCoroutineContext().isActive) return
|
||||||
// socket dropped -> loop again (Reconnecting)
|
// WS dropped: fall back to HTTP immediately (no backoff
|
||||||
|
// gate on the send path), then redial below.
|
||||||
|
enterHttpFallback()
|
||||||
}
|
}
|
||||||
|
|
||||||
is DialResult.Failed -> {
|
is DialResult.Failed -> {
|
||||||
@@ -201,6 +255,114 @@ class GatewayClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── docs/19: HTTP fallback leg ────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Lazily build the HTTP client from the stored WS URL. */
|
||||||
|
private fun httpGateway(): HttpGateway? {
|
||||||
|
val url = store.serverUrl.trim()
|
||||||
|
val token = store.token
|
||||||
|
if (url.isBlank() || token.isBlank()) return null
|
||||||
|
return http
|
||||||
|
?: HttpGateway(client, HttpGateway.deriveHttpUrl(url), token, store.deviceId)
|
||||||
|
.also { http = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun httpHealthy(): Boolean =
|
||||||
|
try {
|
||||||
|
httpGateway()?.health() ?: false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter [State.HttpFallback]: open the SSE (or long-poll) receive loop
|
||||||
|
* from the saved sync cursor. Idempotent; a no-op while WS-connected.
|
||||||
|
*/
|
||||||
|
private fun enterHttpFallback() {
|
||||||
|
if (_state.value is State.Connected) return
|
||||||
|
val gw = httpGateway() ?: return
|
||||||
|
sseFailures = 0
|
||||||
|
usingLongPoll = false
|
||||||
|
httpCursor = store.syncCursor
|
||||||
|
// Provisional state (previous caps/channels) until the SSE hello
|
||||||
|
// arrives with the real ones.
|
||||||
|
val prev = _state.value
|
||||||
|
_state.value =
|
||||||
|
State.HttpFallback(
|
||||||
|
caps = (prev as? State.HelloInfo)?.caps ?: ServerCaps(),
|
||||||
|
channels = (prev as? State.HelloInfo)?.channels ?: emptyList(),
|
||||||
|
lastPushedCursor = (prev as? State.HelloInfo)?.lastPushedCursor ?: 0,
|
||||||
|
)
|
||||||
|
httpJob?.cancel()
|
||||||
|
httpJob = scope.launch { httpReceiveLoop(gw) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopHttpLeg() {
|
||||||
|
httpJob?.cancel()
|
||||||
|
httpJob = null
|
||||||
|
sseFailures = 0
|
||||||
|
usingLongPoll = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The HTTP receive loop: SSE by default; after two consecutive SSE open
|
||||||
|
* failures (buffering proxy) it switches to long-poll until the next
|
||||||
|
* full (re)connect (docs/19 §19.6).
|
||||||
|
*/
|
||||||
|
private suspend fun httpReceiveLoop(gw: HttpGateway) {
|
||||||
|
var backoff = 1_000L
|
||||||
|
while (currentCoroutineContext().isActive) {
|
||||||
|
if (usingLongPoll) {
|
||||||
|
try {
|
||||||
|
val res = gw.poll(httpCursor)
|
||||||
|
res.frames.forEach { emitHttpFrame(it) }
|
||||||
|
if (res.cursor > httpCursor) httpCursor = res.cursor
|
||||||
|
} catch (e: Exception) {
|
||||||
|
IrisLog.w("http poll failed: ${e.message}")
|
||||||
|
delay(backoff)
|
||||||
|
backoff = minOf(backoff * 2, 15_000)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
gw.events(
|
||||||
|
cursor = httpCursor,
|
||||||
|
onHello = { onHttpHello(it) },
|
||||||
|
onFrame = { emitHttpFrame(it) },
|
||||||
|
onCursor = { if (it > httpCursor) httpCursor = it },
|
||||||
|
)
|
||||||
|
// Clean EOF: reconnect immediately.
|
||||||
|
backoff = 1_000L
|
||||||
|
} catch (e: Exception) {
|
||||||
|
sseFailures++
|
||||||
|
if (sseFailures >= 2) {
|
||||||
|
// SSE seems blocked: switch to long-poll.
|
||||||
|
usingLongPoll = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
IrisLog.w("sse read failed: ${e.message}")
|
||||||
|
delay(backoff)
|
||||||
|
backoff = minOf(backoff * 2, 15_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The SSE `event: hello` (the HTTP hello.ack). */
|
||||||
|
private fun onHttpHello(ack: HelloAckPayload) {
|
||||||
|
lastAck = ack
|
||||||
|
val fb = State.HttpFallback(ack.serverCaps, ack.channels, ack.lastPushedCursor)
|
||||||
|
_state.value = fb
|
||||||
|
onHelloAck?.invoke(fb)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deliver an HTTP-leg frame to the same sinks as a WS frame. */
|
||||||
|
private fun emitHttpFrame(frame: Frame) {
|
||||||
|
_events.tryEmit(frame)
|
||||||
|
frame.id?.let { id ->
|
||||||
|
pending[id]?.complete(frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Dial (one connect + hello) ────────────────────────────────────────
|
// ── Dial (one connect + hello) ────────────────────────────────────────
|
||||||
|
|
||||||
private sealed interface DialResult {
|
private sealed interface DialResult {
|
||||||
@@ -338,6 +500,7 @@ class GatewayClient(
|
|||||||
helloAck.invokeOnCompletion { e ->
|
helloAck.invokeOnCompletion { e ->
|
||||||
if (e == null) {
|
if (e == null) {
|
||||||
val ack = helloAck.getCompleted()
|
val ack = helloAck.getCompleted()
|
||||||
|
lastAck = ack
|
||||||
val connected = State.Connected(ack.serverCaps, ack.channels, ack.lastPushedCursor)
|
val connected = State.Connected(ack.serverCaps, ack.channels, ack.lastPushedCursor)
|
||||||
_state.value = connected
|
_state.value = connected
|
||||||
// M5: reconnect catch-up — replay frames parked while offline.
|
// M5: reconnect catch-up — replay frames parked while offline.
|
||||||
@@ -376,9 +539,23 @@ class GatewayClient(
|
|||||||
mediaRefs: List<String> = emptyList(),
|
mediaRefs: List<String> = emptyList(),
|
||||||
autoThread: Boolean = false,
|
autoThread: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val ws = socket ?: return
|
val ws = socket
|
||||||
val id = nextRequestId++
|
if (ws != null && _state.value is State.Connected) {
|
||||||
ws.send(messageSendFrame(id, chatId, text, threadId, mediaRefs, autoThread).toWire())
|
val id = nextRequestId++
|
||||||
|
ws.send(messageSendFrame(id, chatId, text, threadId, mediaRefs, autoThread).toWire())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// docs/19: WS down — route over the HTTP leg. Media is WS-only in
|
||||||
|
// v1 (uploads need the live connection), so mediaRefs are dropped in
|
||||||
|
// fallback (the UI disables the attach button in that state).
|
||||||
|
if (_state.value is State.HttpFallback) {
|
||||||
|
val id = nextRequestId++
|
||||||
|
scope.launch {
|
||||||
|
httpGateway()?.postFrame(
|
||||||
|
messageSendFrame(id, chatId, text, threadId, emptyList(), autoThread),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── M4: media upload / pull ───────────────────────────────────────────
|
// ── M4: media upload / pull ───────────────────────────────────────────
|
||||||
@@ -510,10 +687,21 @@ class GatewayClient(
|
|||||||
* app reconciles from [events]. Returns the id used, or -1 if not connected.
|
* app reconciles from [events]. Returns the id used, or -1 if not connected.
|
||||||
*/
|
*/
|
||||||
fun sendFrame(frame: Frame): Int {
|
fun sendFrame(frame: Frame): Int {
|
||||||
val ws = socket ?: return -1
|
|
||||||
val id = nextRequestId++
|
val id = nextRequestId++
|
||||||
ws.send(frame.copy(id = id).toWire())
|
val ws = socket
|
||||||
return id
|
if (ws != null && _state.value is State.Connected) {
|
||||||
|
ws.send(frame.copy(id = id).toWire())
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
// docs/19: WS down — route over the HTTP leg; the response (same id)
|
||||||
|
// arrives on the SSE/long-poll stream via [events].
|
||||||
|
if (_state.value is State.HttpFallback) {
|
||||||
|
scope.launch {
|
||||||
|
httpGateway()?.postFrame(frame.copy(id = id))
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Send a ping (heartbeat). */
|
/** Send a ping (heartbeat). */
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
package iris.net
|
||||||
|
|
||||||
|
import iris.protocol.Frame
|
||||||
|
import iris.protocol.HelloAckPayload
|
||||||
|
import iris.protocol.IrisJson
|
||||||
|
import iris.util.IrisLog
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import okhttp3.Headers
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP fallback transport client (docs/19): the "HTTP leg".
|
||||||
|
*
|
||||||
|
* When the WS is down (flaky network, NAT timeout, app just relaunched),
|
||||||
|
* the app sends over `POST /v1/frame` and receives over SSE
|
||||||
|
* `GET /v1/events` (or long-poll `GET /v1/poll` where SSE is blocked).
|
||||||
|
* Same frames, same outbox cursor, same token as the WS.
|
||||||
|
*
|
||||||
|
* [events] is ONE SSE connection attempt (blocking read on
|
||||||
|
* [Dispatchers.IO]); [GatewayClient] wraps it in a retry loop and tracks
|
||||||
|
* the resume cursor via the [events] `onCursor` callback (SSE `id` =
|
||||||
|
* outbox cursor, so resume is just the last seen id).
|
||||||
|
*/
|
||||||
|
class HttpGateway(
|
||||||
|
private val client: OkHttpClient,
|
||||||
|
private val baseUrl: String,
|
||||||
|
private val token: String,
|
||||||
|
private val deviceId: String,
|
||||||
|
) {
|
||||||
|
// OkHttp's default read timeout (10 s) is shorter than the gateway's SSE
|
||||||
|
// heartbeat (15 s) and the long-poll hold (25 s) — per-purpose clients
|
||||||
|
// with extended call timeouts (see the *Client() helpers below).
|
||||||
|
private val healthClient: OkHttpClient = client.healthClient()
|
||||||
|
private val streamClient: OkHttpClient = client.streamClient()
|
||||||
|
private val pollClient: OkHttpClient = client.pollClient()
|
||||||
|
/** POST /v1/frame result. [frame] is the handler's synchronous reply
|
||||||
|
* (error frame on 4xx, e.g. read.receipt on 200) or null for a plain
|
||||||
|
* 202 accept-and-ack. */
|
||||||
|
data class PostResult(
|
||||||
|
val ok: Boolean,
|
||||||
|
val status: Int,
|
||||||
|
val frame: Frame?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Long-poll result: new high-water [cursor] + frames with cursor >
|
||||||
|
* the requested one (may be empty at timeout). */
|
||||||
|
data class PollResult(
|
||||||
|
val cursor: Long,
|
||||||
|
val frames: List<Frame>,
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val JSON = "application/json".toMediaType()
|
||||||
|
|
||||||
|
/** Default port of the gateway's HTTP leg (WS default is 8790). */
|
||||||
|
const val DEFAULT_PORT = 8791
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the HTTP base URL from the stored WS URL (docs/19 §19.4):
|
||||||
|
* `ws(s)://host[:port]/ws` -> `http(s)://host:8791`. The WS port is
|
||||||
|
* a different service, so the port is always replaced with the
|
||||||
|
* HTTP leg's default. Pure function (unit-tested).
|
||||||
|
*/
|
||||||
|
fun deriveHttpUrl(wsUrl: String): String {
|
||||||
|
val u = wsUrl.trim()
|
||||||
|
val (scheme, rest) =
|
||||||
|
when {
|
||||||
|
u.startsWith("wss://") -> "https" to u.removePrefix("wss://")
|
||||||
|
u.startsWith("ws://") -> "http" to u.removePrefix("ws://")
|
||||||
|
else -> return u // already http(s)
|
||||||
|
}
|
||||||
|
val authority = rest.substringBefore('/')
|
||||||
|
val host = authority.substringBefore(':')
|
||||||
|
return "$scheme://$host:$DEFAULT_PORT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun authHeaders(): Headers =
|
||||||
|
Headers
|
||||||
|
.Builder()
|
||||||
|
.add("Authorization", "Bearer $token")
|
||||||
|
.add("X-Iris-Device", deviceId)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
/** Liveness probe (unauthenticated by design). True on 200. */
|
||||||
|
suspend fun health(): Boolean =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val request =
|
||||||
|
Request
|
||||||
|
.Builder()
|
||||||
|
.url("$baseUrl/v1/health")
|
||||||
|
.build()
|
||||||
|
healthClient
|
||||||
|
.newCall(request)
|
||||||
|
.execute()
|
||||||
|
.use { response ->
|
||||||
|
response.body?.close()
|
||||||
|
response.code == 200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /v1/frame (accept-and-ack, docs/19 §19.7). 2xx -> [PostResult.ok]
|
||||||
|
* (with the synchronous reply frame when the handler sent one); 4xx ->
|
||||||
|
* the error frame as the body.
|
||||||
|
*/
|
||||||
|
suspend fun postFrame(frame: Frame): PostResult =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val wire = IrisJson.instance.encodeToString(Frame.serializer(), frame)
|
||||||
|
val request =
|
||||||
|
Request
|
||||||
|
.Builder()
|
||||||
|
.url("$baseUrl/v1/frame")
|
||||||
|
.headers(authHeaders())
|
||||||
|
.post(wire.toRequestBody(JSON))
|
||||||
|
.build()
|
||||||
|
client
|
||||||
|
.newCall(request)
|
||||||
|
.execute()
|
||||||
|
.use { response ->
|
||||||
|
val body = response.body?.string().orEmpty()
|
||||||
|
val parsed =
|
||||||
|
try {
|
||||||
|
if (body.startsWith("{")) {
|
||||||
|
val obj = IrisJson.instance.parseToJsonElement(body)
|
||||||
|
// 202 {"ok":true} is not a frame; 4xx/200 bodies are.
|
||||||
|
if (obj.jsonObject.containsKey("type")) {
|
||||||
|
IrisJson.instance.decodeFromJsonElement(Frame.serializer(), obj)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
PostResult(response.isSuccessful, response.code, parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One SSE connection attempt: outbox catch-up from [cursor], then live
|
||||||
|
* frames. [onHello] fires for `event: hello` (the HTTP hello.ack);
|
||||||
|
* [onFrame] for `event: frame`; [onCursor] with the SSE `id` (outbox
|
||||||
|
* cursor) when present. Returns on clean EOF; throws [IOException] on
|
||||||
|
* open/read failure. Callbacks run on the IO thread.
|
||||||
|
*/
|
||||||
|
suspend fun events(
|
||||||
|
cursor: Long,
|
||||||
|
onHello: (HelloAckPayload) -> Unit,
|
||||||
|
onFrame: (Frame) -> Unit,
|
||||||
|
onCursor: (Long) -> Unit,
|
||||||
|
) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val request =
|
||||||
|
Request
|
||||||
|
.Builder()
|
||||||
|
.url("$baseUrl/v1/events?cursor=$cursor")
|
||||||
|
.headers(authHeaders())
|
||||||
|
.build()
|
||||||
|
client
|
||||||
|
.newCall(request)
|
||||||
|
.execute()
|
||||||
|
.use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
throw IOException("SSE open failed: HTTP ${response.code}")
|
||||||
|
}
|
||||||
|
val source = response.body?.source() ?: throw IOException("empty SSE body")
|
||||||
|
var eventId: String? = null
|
||||||
|
val dataLines = mutableListOf<String>()
|
||||||
|
while (true) {
|
||||||
|
val line = source.readUtf8Line() ?: break // EOF
|
||||||
|
when {
|
||||||
|
line.isEmpty() -> {
|
||||||
|
if (dataLines.isNotEmpty()) {
|
||||||
|
val data = dataLines.joinToString("\n")
|
||||||
|
try {
|
||||||
|
val frame =
|
||||||
|
IrisJson.instance.decodeFromString(
|
||||||
|
Frame.serializer(),
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
when (eventId) {
|
||||||
|
"hello" -> {
|
||||||
|
onHello(
|
||||||
|
frame.payloadAs<HelloAckPayload>()
|
||||||
|
?: HelloAckPayload(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
onFrame(frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
IrisLog.e("sse frame decode failed: $e :: ${data.take(120)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eventId = null
|
||||||
|
dataLines.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
line.startsWith(":") -> {
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
// heartbeat comment
|
||||||
|
line.startsWith("id:") -> {
|
||||||
|
line
|
||||||
|
.removePrefix("id:")
|
||||||
|
.trim()
|
||||||
|
.toLongOrNull()
|
||||||
|
?.let(onCursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
line.startsWith("event:") -> {
|
||||||
|
eventId = line.removePrefix("event:").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
line.startsWith("data:") -> {
|
||||||
|
dataLines.add(line.removePrefix("data:").removePrefix(" "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Long-poll (docs/19 §19.6): the server holds the request up to 25 s.
|
||||||
|
* Returns the new high-water cursor + any frames with cursor > [cursor].
|
||||||
|
*/
|
||||||
|
suspend fun poll(cursor: Long): PollResult =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val request =
|
||||||
|
Request
|
||||||
|
.Builder()
|
||||||
|
.url("$baseUrl/v1/poll?cursor=$cursor")
|
||||||
|
.headers(authHeaders())
|
||||||
|
.build()
|
||||||
|
client
|
||||||
|
.newCall(request)
|
||||||
|
.execute()
|
||||||
|
.use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
throw IOException("poll failed: HTTP ${response.code}")
|
||||||
|
}
|
||||||
|
val body = response.body?.string().orEmpty()
|
||||||
|
val obj = IrisJson.instance.parseToJsonElement(body).jsonObject
|
||||||
|
val newCursor = obj["cursor"]?.jsonPrimitive?.content?.toLongOrNull() ?: cursor
|
||||||
|
val frames =
|
||||||
|
obj["frames"]
|
||||||
|
?.jsonArray
|
||||||
|
?.mapNotNull { el ->
|
||||||
|
try {
|
||||||
|
IrisJson.instance.decodeFromJsonElement(Frame.serializer(), el)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.orEmpty()
|
||||||
|
PollResult(newCursor, frames)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OkHttp's default read timeout (10 s) is shorter than the gateway's SSE
|
||||||
|
* heartbeat (15 s) and the long-poll hold (25 s) — extend the call timeout
|
||||||
|
* for streaming endpoints. Applied via [OkHttpClient] builders in
|
||||||
|
* [GatewayClient].
|
||||||
|
*/
|
||||||
|
internal const val HTTP_STREAM_CALL_TIMEOUT_MS = 60_000L
|
||||||
|
internal const val HTTP_POLL_CALL_TIMEOUT_MS = 35_000L
|
||||||
|
internal const val HTTP_HEALTH_TIMEOUT_MS = 2_000L
|
||||||
|
|
||||||
|
internal fun OkHttpClient.streamClient(): OkHttpClient =
|
||||||
|
newBuilder()
|
||||||
|
.callTimeout(HTTP_STREAM_CALL_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
internal fun OkHttpClient.pollClient(): OkHttpClient =
|
||||||
|
newBuilder()
|
||||||
|
.callTimeout(HTTP_POLL_CALL_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
internal fun OkHttpClient.healthClient(): OkHttpClient =
|
||||||
|
newBuilder()
|
||||||
|
.callTimeout(HTTP_HEALTH_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||||
|
.build()
|
||||||
@@ -432,7 +432,7 @@ class IrisController(
|
|||||||
) {
|
) {
|
||||||
if (chatId.isNullOrBlank()) return
|
if (chatId.isNullOrBlank()) return
|
||||||
pendingDeepLink = chatId to threadId
|
pendingDeepLink = chatId to threadId
|
||||||
if (client.state.value is GatewayClient.State.Connected) applyDeepLink()
|
if (client.state.value is GatewayClient.State.HelloInfo) applyDeepLink()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyDeepLink() {
|
private fun applyDeepLink() {
|
||||||
@@ -683,7 +683,7 @@ class IrisController(
|
|||||||
client.state.collect { s ->
|
client.state.collect { s ->
|
||||||
val prev = prevState
|
val prev = prevState
|
||||||
prevState = s
|
prevState = s
|
||||||
if (s is GatewayClient.State.Connected) {
|
if (s is GatewayClient.State.HelloInfo) {
|
||||||
// Clear any stale "restarting" latch from the previous
|
// Clear any stale "restarting" latch from the previous
|
||||||
// down phase (the gateway's own status{online} frame
|
// down phase (the gateway's own status{online} frame
|
||||||
// follows on hello.ack and re-asserts the truth).
|
// follows on hello.ack and re-asserts the truth).
|
||||||
@@ -755,7 +755,7 @@ class IrisController(
|
|||||||
* after a process death the cached copy may be stale — so history always
|
* after a process death the cached copy may be stale — so history always
|
||||||
* refreshes (skipped on a plain reconnect via historyLoaded).
|
* refreshes (skipped on a plain reconnect via historyLoaded).
|
||||||
*/
|
*/
|
||||||
private fun onConnectedLane(connected: GatewayClient.State.Connected) {
|
private fun onConnectedLane(connected: GatewayClient.State.HelloInfo) {
|
||||||
channels.setAll(connected.channels)
|
channels.setAll(connected.channels)
|
||||||
val home = connected.channels.firstOrNull { it.isDefault }?.chatId
|
val home = connected.channels.firstOrNull { it.isDefault }?.chatId
|
||||||
_homeChannel.value = home ?: ChatStore.DEFAULT_LANE
|
_homeChannel.value = home ?: ChatStore.DEFAULT_LANE
|
||||||
@@ -883,7 +883,7 @@ class IrisController(
|
|||||||
/** Request the gateway's slash-command catalog. No-op while disconnected
|
/** Request the gateway's slash-command catalog. No-op while disconnected
|
||||||
* (sendFrame drops silently); the response lands via [slashCommands]. */
|
* (sendFrame drops silently); the response lands via [slashCommands]. */
|
||||||
fun requestCommandsCatalog() {
|
fun requestCommandsCatalog() {
|
||||||
if (client.state.value !is GatewayClient.State.Connected) return
|
if (client.state.value !is GatewayClient.State.HelloInfo) return
|
||||||
client.sendFrame(commandsCatalogFrame(0))
|
client.sendFrame(commandsCatalogFrame(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -245,9 +245,10 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
val nonThreadChannels = channels.filter { it.kind != "thread" }
|
val nonThreadChannels = channels.filter { it.kind != "thread" }
|
||||||
|
|
||||||
fun doSend() {
|
fun doSend() {
|
||||||
// No-op while the socket is down (sendMessage drops silently); the send
|
// No-op while no transport is up (sendMessage drops silently); the
|
||||||
// button is disabled in that state, this guards the IME "Send" action.
|
// send button is disabled in that state, this guards the IME "Send"
|
||||||
if (state !is GatewayClient.State.Connected) return
|
// action. docs/19: the HTTP fallback leg counts as sendable.
|
||||||
|
if (state !is GatewayClient.State.HelloInfo) return
|
||||||
val ready = attachments.filter { it.mediaRef != null && it.error == null }
|
val ready = attachments.filter { it.mediaRef != null && it.error == null }
|
||||||
if (input.isBlank() && ready.isEmpty()) return
|
if (input.isBlank() && ready.isEmpty()) return
|
||||||
val text = input
|
val text = input
|
||||||
@@ -311,7 +312,7 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun onSlashPick(cmd: SlashCommand) {
|
fun onSlashPick(cmd: SlashCommand) {
|
||||||
if (state !is GatewayClient.State.Connected) return
|
if (state !is GatewayClient.State.HelloInfo) return
|
||||||
input = ""
|
input = ""
|
||||||
controller.send(cmd.name)
|
controller.send(cmd.name)
|
||||||
focusManager.clearFocus(force = true)
|
focusManager.clearFocus(force = true)
|
||||||
@@ -742,9 +743,12 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
// Composer (M7: rounded pill + accent circular send button). Sending is gated
|
// Composer (M7: rounded pill + accent circular send button). Sending is gated
|
||||||
// on a live socket: sendMessage is a no-op while disconnected, so an
|
// on a live socket: sendMessage is a no-op while disconnected, so an
|
||||||
// ungated send would show a pending bubble that never resolves.
|
// ungated send would show a pending bubble that never resolves.
|
||||||
val isConnected = state is GatewayClient.State.Connected
|
val isConnected = state is GatewayClient.State.HelloInfo
|
||||||
|
// docs/19: media needs the live WS connection; in HTTP fallback
|
||||||
|
// only text is sendable.
|
||||||
|
val wsConnected = state is GatewayClient.State.Connected
|
||||||
val canSend =
|
val canSend =
|
||||||
isConnected && (input.isNotBlank() || attachments.any { it.mediaRef != null && it.error == null })
|
isConnected && (input.isNotBlank() || (wsConnected && attachments.any { it.mediaRef != null && it.error == null }))
|
||||||
val layoutDensity = LocalDensity.current.density
|
val layoutDensity = LocalDensity.current.density
|
||||||
var textHeightPx by remember { mutableFloatStateOf(0f) }
|
var textHeightPx by remember { mutableFloatStateOf(0f) }
|
||||||
if (isAutomation) {
|
if (isAutomation) {
|
||||||
@@ -818,7 +822,7 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IconButton(onClick = { showPicker = true }) {
|
IconButton(onClick = { showPicker = true }, enabled = state is GatewayClient.State.Connected) {
|
||||||
Text("📎", fontSize = 18.sp)
|
Text("📎", fontSize = 18.sp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1822,6 +1826,7 @@ private fun statusLabel(state: GatewayClient.State): String =
|
|||||||
GatewayClient.State.Connecting -> "connecting…"
|
GatewayClient.State.Connecting -> "connecting…"
|
||||||
GatewayClient.State.Reconnecting -> "reconnecting…"
|
GatewayClient.State.Reconnecting -> "reconnecting…"
|
||||||
is GatewayClient.State.Connected -> "connected"
|
is GatewayClient.State.Connected -> "connected"
|
||||||
|
is GatewayClient.State.HttpFallback -> "connected · http"
|
||||||
is GatewayClient.State.AuthFailed -> "auth failed"
|
is GatewayClient.State.AuthFailed -> "auth failed"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2104,6 +2109,7 @@ private fun NameDialog(
|
|||||||
private fun statusToastText(state: GatewayClient.State): String =
|
private fun statusToastText(state: GatewayClient.State): String =
|
||||||
when (state) {
|
when (state) {
|
||||||
is GatewayClient.State.Connected -> "Connected to Hermes"
|
is GatewayClient.State.Connected -> "Connected to Hermes"
|
||||||
|
is GatewayClient.State.HttpFallback -> "Connected to Hermes (HTTP fallback — media paused)"
|
||||||
GatewayClient.State.Connecting -> "Connecting to Hermes"
|
GatewayClient.State.Connecting -> "Connecting to Hermes"
|
||||||
GatewayClient.State.Reconnecting -> "Re-Connecting to Hermes"
|
GatewayClient.State.Reconnecting -> "Re-Connecting to Hermes"
|
||||||
GatewayClient.State.Disconnected -> "Unpaired from Hermes"
|
GatewayClient.State.Disconnected -> "Unpaired from Hermes"
|
||||||
@@ -2119,7 +2125,9 @@ private fun StatusBubble(
|
|||||||
) {
|
) {
|
||||||
val (color, pulsing) =
|
val (color, pulsing) =
|
||||||
when (state) {
|
when (state) {
|
||||||
is GatewayClient.State.Connected -> IrisColors.statusGreen to false
|
is GatewayClient.State.Connected,
|
||||||
|
is GatewayClient.State.HttpFallback,
|
||||||
|
-> IrisColors.statusGreen to false
|
||||||
|
|
||||||
GatewayClient.State.Connecting,
|
GatewayClient.State.Connecting,
|
||||||
GatewayClient.State.Reconnecting,
|
GatewayClient.State.Reconnecting,
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package iris.net
|
||||||
|
|
||||||
|
import iris.protocol.Frame
|
||||||
|
import iris.protocol.IrisJson
|
||||||
|
import iris.protocol.MessagePayload
|
||||||
|
import iris.protocol.TYPE_MESSAGE
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
/**
|
||||||
|
* docs/19: unit tests for the HTTP fallback leg client.
|
||||||
|
*
|
||||||
|
* The SSE line parser is the trickiest pure logic (event/id/data fields,
|
||||||
|
* comments, multi-line data, Last-Event-ID bookkeeping), so it is factored
|
||||||
|
* into [SseParser] and tested directly. The WS->HTTP URL derivation is a
|
||||||
|
* pure function (unit-tested). Full transport behavior (health, POST, SSE
|
||||||
|
* catch-up, long-poll, delivery counting) is covered by the gateway-side
|
||||||
|
* Python tests (hermes-agent/tests/gateway/test_android_http.py).
|
||||||
|
*/
|
||||||
|
class HttpGatewayTest {
|
||||||
|
// ── URL derivation ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deriveHttpUrlReplacesSchemeAndPort() {
|
||||||
|
assertEquals("http://192.168.1.10:8791", HttpGateway.deriveHttpUrl("ws://192.168.1.10:8790/ws"))
|
||||||
|
assertEquals("http://127.0.0.1:8791", HttpGateway.deriveHttpUrl("ws://127.0.0.1:8790/ws"))
|
||||||
|
assertEquals("https://gw.example.com:8791", HttpGateway.deriveHttpUrl("wss://gw.example.com:8790/ws"))
|
||||||
|
// No explicit port on the WS URL: still the HTTP leg's default port.
|
||||||
|
assertEquals("http://gw.example.com:8791", HttpGateway.deriveHttpUrl("ws://gw.example.com/ws"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deriveHttpUrlPassesThroughHttpUrls() {
|
||||||
|
assertEquals("http://1.2.3.4:9000", HttpGateway.deriveHttpUrl("http://1.2.3.4:9000"))
|
||||||
|
assertEquals("https://a.b", HttpGateway.deriveHttpUrl("https://a.b"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SSE parser ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sseParsesHelloAndFrames() {
|
||||||
|
val parser = SseParser()
|
||||||
|
val hello = """{"v":1,"type":"hello.ack","payload":{"sync_cursor":5}}"""
|
||||||
|
val frame = """{"v":1,"type":"$TYPE_MESSAGE","payload":{"text":"hi"}}"""
|
||||||
|
val lines =
|
||||||
|
listOf(
|
||||||
|
"event: hello",
|
||||||
|
"data: $hello",
|
||||||
|
"",
|
||||||
|
"id: 7",
|
||||||
|
"event: frame",
|
||||||
|
"data: $frame",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
var helloCount = 0
|
||||||
|
var frameCount = 0
|
||||||
|
var lastCursor: Long? = null
|
||||||
|
for (line in lines) {
|
||||||
|
parser.feed(
|
||||||
|
line,
|
||||||
|
onHello = { helloCount++ },
|
||||||
|
onFrame = { frameCount++ },
|
||||||
|
onCursor = { lastCursor = it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertEquals(1, helloCount)
|
||||||
|
assertEquals(1, frameCount)
|
||||||
|
assertEquals(7L, lastCursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sseIgnoresHeartbeatComments() {
|
||||||
|
val parser = SseParser()
|
||||||
|
var frames = 0
|
||||||
|
parser.feed(": hb", onHello = {}, onFrame = { frames++ }, onCursor = {})
|
||||||
|
parser.feed("", onHello = {}, onFrame = { frames++ }, onCursor = {})
|
||||||
|
assertEquals(0, frames)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sseMultiLineDataJoinsWithNewline() {
|
||||||
|
val parser = SseParser()
|
||||||
|
var frame: Frame? = null
|
||||||
|
// SSE data may span multiple `data:` lines; the parser must join
|
||||||
|
// them with "\n" so the reassembled JSON still decodes. Split at a
|
||||||
|
// legal JSON whitespace point (right after a comma, between tokens).
|
||||||
|
val full =
|
||||||
|
"""{"v":1,"type":"$TYPE_MESSAGE","payload":{"message_id":"m1","role":"assistant","text":"a\nb"}}"""
|
||||||
|
val cut = full.indexOf("\"m1\",") + "\"m1\",".length
|
||||||
|
val l1 = full.substring(0, cut)
|
||||||
|
val l2 = full.substring(cut)
|
||||||
|
parser.feed("event: frame", onHello = {}, onFrame = {}, onCursor = {})
|
||||||
|
parser.feed("data: $l1", onHello = {}, onFrame = {}, onCursor = {})
|
||||||
|
parser.feed("data: $l2", onHello = {}, onFrame = {}, onCursor = {})
|
||||||
|
parser.feed("", onHello = {}, onFrame = { frame = it }, onCursor = {})
|
||||||
|
assertEquals("a\nb", frame?.payloadAsText())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sseTracksLastEventIdAcrossFrames() {
|
||||||
|
val parser = SseParser()
|
||||||
|
val ids = mutableListOf<Long>()
|
||||||
|
val frame = """{"v":1,"type":"$TYPE_MESSAGE","payload":{}}"""
|
||||||
|
for (id in listOf(1L, 2L, 3L)) {
|
||||||
|
parser.feed("id: $id", onHello = {}, onFrame = {}, onCursor = { ids.add(it) })
|
||||||
|
parser.feed("event: frame", onHello = {}, onFrame = {}, onCursor = {})
|
||||||
|
parser.feed("data: $frame", onHello = {}, onFrame = {}, onCursor = {})
|
||||||
|
parser.feed("", onHello = {}, onFrame = {}, onCursor = {})
|
||||||
|
}
|
||||||
|
assertEquals(listOf(1L, 2L, 3L), ids)
|
||||||
|
assertEquals(3L, parser.lastEventId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sseMalformedLineDoesNotThrow() {
|
||||||
|
val parser = SseParser()
|
||||||
|
parser.feed("garbage without colon", onHello = {}, onFrame = {}, onCursor = {})
|
||||||
|
parser.feed("id: notanumber", onHello = {}, onFrame = {}, onCursor = {})
|
||||||
|
parser.feed("data: {not json", onHello = {}, onFrame = {}, onCursor = {})
|
||||||
|
parser.feed("", onHello = {}, onFrame = {}, onCursor = {})
|
||||||
|
// No exception, no frame emitted for the malformed data.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sseDecodesFramePayload() {
|
||||||
|
val parser = SseParser()
|
||||||
|
var text: String? = null
|
||||||
|
val frame =
|
||||||
|
"""{"v":1,"type":"$TYPE_MESSAGE","payload":{"message_id":"m1","role":"assistant","text":"hello"}}"""
|
||||||
|
parser.feed("event: frame", onHello = {}, onFrame = {}, onCursor = {})
|
||||||
|
parser.feed("data: $frame", onHello = {}, onFrame = {}, onCursor = {})
|
||||||
|
parser.feed("", onHello = {}, onFrame = { f -> text = f.payloadAsText() }, onCursor = {})
|
||||||
|
assertEquals("hello", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SSE line parser (pure; shared by the live reader + tests) ─────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Incremental SSE parser (docs/19 §19.5). Feed raw lines (without
|
||||||
|
* terminators); a blank line dispatches the buffered event. [lastEventId]
|
||||||
|
* is the most recent `id:` field (the outbox cursor) — the resume point for
|
||||||
|
* a reconnect.
|
||||||
|
*/
|
||||||
|
class SseParser {
|
||||||
|
var lastEventId: Long? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var eventId: String? = null
|
||||||
|
private val dataLines = mutableListOf<String>()
|
||||||
|
|
||||||
|
fun feed(
|
||||||
|
line: String,
|
||||||
|
onHello: (String) -> Unit,
|
||||||
|
onFrame: (Frame) -> Unit,
|
||||||
|
onCursor: (Long) -> Unit,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
line.isEmpty() -> {
|
||||||
|
if (dataLines.isNotEmpty()) {
|
||||||
|
val data = dataLines.joinToString("\n")
|
||||||
|
val frame =
|
||||||
|
try {
|
||||||
|
IrisJson.instance.decodeFromString(Frame.serializer(), data)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (frame != null) {
|
||||||
|
when (eventId) {
|
||||||
|
"hello" -> onHello(data)
|
||||||
|
else -> onFrame(frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eventId = null
|
||||||
|
dataLines.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
line.startsWith(":") -> {
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
// comment / heartbeat
|
||||||
|
line.startsWith("id:") -> {
|
||||||
|
val id = line.removePrefix("id:").trim().toLongOrNull()
|
||||||
|
if (id != null) {
|
||||||
|
lastEventId = id
|
||||||
|
onCursor(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
line.startsWith("event:") -> {
|
||||||
|
eventId = line.removePrefix("event:").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
line.startsWith("data:") -> {
|
||||||
|
dataLines.add(line.removePrefix("data:").removePrefix(" "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Frame.payloadAsText(): String? = payloadAs<MessagePayload>()?.text
|
||||||
Reference in new issue
Block a user