Skip to content

Exception during streaming: Flow invariant is violated in KtorKoogHttpClient.lines #2245

Open

Description

Bug

LLMClient is used that requires a KtorKoogHttpClient (for example, OllamaClient) in my KMP application. Everything is fine on android target but on IOS there's an exception that is happening during the agent run

Stacktrace

ai.koog.http.client.KoogHttpClientException: Error from client: OllamaClient
Message: Exception during streaming: Flow invariant is violated:
		Flow was collected in [com.mvlog.coroutines.exception.DefaultCoroutineExceptionHandler@a70aeb8, ProducerCoroutine{Active}@13db31d8, DarwinGlobalQueueDispatcher@4fa8208],
		but emission happened in [com.mvlog.coroutines.exception.DefaultCoroutineExceptionHandler@a70aeb8, DispatchedCoroutine{Active}@abd12c0, Dispatchers.IO].
		Please refer to 'flow' documentation or use 'flowOn' instead

    at 0   Thoon.debug.dylib                   0x10c3f1543        kfun:kotlin.Exception#<init>(kotlin.String?;kotlin.Throwable?){} + 123 
    at 1   Thoon.debug.dylib                   0x10b216dcf        kfun:ai.koog.http.client.KoogHttpClientException#<init>(kotlin.String?;kotlin.Int?;kotlin.String?;kotlin.String?;kotlin.Throwable?){} + 2175 
    at 2   Thoon.debug.dylib                   0x10b2170df        kfun:ai.koog.http.client.KoogHttpClientException#<init>(kotlin.String?;kotlin.Int?;kotlin.String?;kotlin.String?;kotlin.Throwable?;kotlin.Int;kotlin.native.internal.DefaultConstructorMarker?){} + 675 
    at 3   Thoon.debug.dylib                   0x10cb1aeef        kfun:ai.koog.http.client.ktor.KtorKoogHttpClient.KtorKoogHttpClient$lines$1.$invokeCOROUTINE$5.invokeSuspend#internal + 3775 
    at 4   Thoon.debug.dylib                   0x10c3f0ac7        kfun:kotlin.coroutines.native.internal.BaseContinuationImpl#invokeSuspend(kotlin.Result<kotlin.Any?>){}kotlin.Any? + 67 
    at 5   Thoon.debug.dylib                   0x10c3ef767        kfun:kotlin.coroutines.native.internal.BaseContinuationImpl#resumeWith(kotlin.Result<kotlin.Any?>){} + 675 
    at 6   Thoon.debug.dylib                   0x10c3f115b        kfun:kotlin.coroutines.Continuation#resumeWith(kotlin.Result<1:0>){} + 91 
    at 7   Thoon.debug.dylib                   0x10c5d104b        kfun:kotlinx.coroutines.DispatchedTask#run(){} + 2035 
    at 8   Thoon.debug.dylib                   0x10c600f3b        kfun:kotlinx.coroutines.Runnable#run(){} + 83 
    at 9   Thoon.debug.dylib                   0x10c5fc33b        kfun:kotlinx.coroutines.DarwinGlobalQueueDispatcher.DarwinGlobalQueueDispatcher$dispatch$$inlined$autoreleasepool$1.invoke#internal + 71 
    at 10  Thoon.debug.dylib                   0x10c5fc393        kfun:kotlinx.coroutines.DarwinGlobalQueueDispatcher.DarwinGlobalQueueDispatcher$dispatch$$inlined$autoreleasepool$1.$<bridge-DN>invoke(){}#internal + 71 
    at 11  Thoon.debug.dylib                   0x10c3d5c7b        kfun:kotlin.Function0#invoke(){}1:0 + 91 
    at 12  Thoon.debug.dylib                   0x10c5fc22b        _6f72672e6a6574627261696e732e6b6f746c696e783a6b6f746c696e782d636f726f7574696e65732d636f72652f6f70742f6275696c644167656e742f776f726b2f343465633665383530643563363366302f6b6f746c696e782d636f726f7574696e65732d636f72652f6e617469766544617277696e2f7372632f44697370617463686572732e6b74_4_knbridge3 + 239 
    at 13  libdispatch.dylib                   0x1801c07a7        _dispatch_call_block_and_release + 23 
    at 14  libdispatch.dylib                   0x1801db4af        _dispatch_client_callout + 11 
    at 15  libdispatch.dylib                   0x1801f6823        _dispatch_workloop_invoke.cold.4 + 27 
    at 16  libdispatch.dylib                   0x1801d3d57        _dispatch_root_queue_drain + 915 
    at 17  libdispatch.dylib                   0x1801d450f        _dispatch_worker_thread2 + 251 
    at 18  libsystem_pthread.dylib             0x1043eab4b        _pthread_wqthread + 227 
    at 19  libsystem_pthread.dylib             0x1043e998b        start_wqthread + 7 

Most likely the problem is here:
KtorKoogHttpClient invokes execute in lines function:

 override fun <T : Any> lines(
        path: String,
        requestBody: T,
        requestBodyType: KClass<T>,
        parameters: Map<String, String>,
        headers: Map<String, String>,
    ): Flow<String> = flow {
        logger.debug { "Opening lines flow for $clientName" }

        try {
            ktorClient.preparePost(path) {
                parameters.forEach { (key, value) ->
                    parameter(key, value)
                }
                applyRequestHeaders(headers)
                if (requestBodyType == String::class) {
                    @Suppress("UNCHECKED_CAST")
                    setBody(requestBody as String)
                } else {
                    setBody(requestBody, TypeInfo(requestBodyType))
                }
            }.execute { response: HttpResponse ->
                if (!response.status.isSuccess()) {
                    throw KoogHttpClientException(
                        clientName = clientName,
                        statusCode = response.status.value,
                        errorBody = response.bodyAsText(),
                    )
                }

                val channel = response.bodyAsChannel()
                while (!channel.isClosedForRead) {
                    val line = channel.readUTF8Line() ?: break
                    if (line.isBlank()) continue
                    emit(line)
                }
            }
        } catch (e: KoogHttpClientException) {
            throw e
        } catch (e: CancellationException) {
            throw e
        } catch (e: Exception) {
            throw KoogHttpClientException(
                clientName = clientName,
                message = "Exception during streaming: ${e.message}",
                cause = e,
            )
        }
    }

Under the hood in latest version of ktor there's thread switching added for non-jvm targets in execute function in HttpStatement's execute (in ktor):
https://github.com/ktorio/ktor/blob/main/ktor-client/ktor-client-core/common/src/io/ktor/client/statement/HttpStatement.kt

There was a similar issue, most likely its just channelFlow that is needed here
#1775

Environment

Test device

IOS simulator IPhone 16e
IOS 26.2

Library versions

  • Koog = 1.2.0
  • Ktor = 3.6.0

Activity

  1. raseln commented on Sep 21, 2026

    @raseln
    Contributor

    I dug into this and can reproduce it. The cause is on the Ktor side: since 3.4.0 (KTOR-9146) HttpStatement.execute/body run their block on the engine dispatcher on non-JVM targets, so the emit inside KtorKoogHttpClient.lines (and sse, which goes through HttpStatement.body) breaks the flow {} invariant. Same class of problem as #1775, one layer down. Koog still pins Ktor 3.3.3, which is why it only shows up when the app pulls in a newer Ktor — and it would hit Koog itself once #2202 lands.

    I have a fix (channelFlow in both lines and sse, plus a regression test) that I verified on JVM and the iOS simulator against Ktor 3.5.2. I'll open a PR against develop shortly, unless someone has already started on this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions