Changelog 3.6 version
3.6.0
released 18th September 2026
Client
Digest Auth: NullPointerException when the server does not expect Digest auth
This issue can be reproduced by making a request from a client configured with the digest authentication provider to a server configured with the basic authentication provider.
For example, change https://github.com/ktorio/ktor-documentation/blob/main/codeSnippets/snippets/client-auth-digest/build.gradle.kts to use:
implementation(project(":auth-basic"))
instead of:
implementation(project(":auth-digest"))
And change https://github.com/ktorio/ktor-documentation/blob/main/codeSnippets/snippets/client-auth-digest/src/main/kotlin/com/example/Application.kt to use:
import authbasic.*
instead of:
import authdigest.*
When running client-auth-digest, an exception will occur:
Exception in thread "main" java.lang.NullPointerException
at io.ktor.client.plugins.auth.providers.DigestAuthProvider.addRequestHeaders(DigestAuthProvider.kt:174)
at io.ktor.client.plugins.auth.AuthKt.Auth$lambda$0$executeWithNewToken(Auth.kt:152)
...
Server
JWTAuthenticationProvider silently swallows exceptions
When usingJwkProvider sometimes due to network or configuration the provider on start-up.
The Jwt plugin swallows these exceptions without any logging, unless TRACE level is enabled.
When this occurs false negatives start occurring, and this can be incredibly confusing if TRACE level logging is not enabled.
2026-01-15 15:25:24.485 [lettuce-nioEventLoop-4-1] TRACE io.ktor.auth.jwt - Failed to get JWK
com.auth0.jwk.NetworkException: Cannot obtain jwks from url http://localhost:8000/realms/foodies-keycloak/protocol/openid-connect/certs/.well-known/jwks.json
at com.auth0.jwk.UrlJwkProvider.getJwks(UrlJwkProvider.java:144)
at com.auth0.jwk.UrlJwkProvider.getAll(UrlJwkProvider.java:150)
at com.auth0.jwk.UrlJwkProvider.getCachedJwks(UrlJwkProvider.java:172)
at com.auth0.jwk.UrlJwkProvider.findKey(UrlJwkProvider.java:181)
at com.auth0.jwk.UrlJwkProvider.get(UrlJwkProvider.java:213)
at com.auth0.jwk.RateLimitedJwkProvider.get(RateLimitedJwkProvider.java:28)
at com.auth0.jwk.GuavaCachedJwkProvider.lambda$get$0(GuavaCachedJwkProvider.java:62)
at com.google.common.cache.LocalCache$LocalManualCache$1.load(LocalCache.java:4927)
at com.google.common.cache.LocalCache$LoadingValueReference.loadFuture(LocalCache.java:3571)
at com.google.common.cache.LocalCache$Segment.loadSync(LocalCache.java:2313)
at com.google.common.cache.LocalCache$Segment.lockedGetOrLoad(LocalCache.java:2190)
at com.google.common.cache.LocalCache$Segment.get(LocalCache.java:2080)
at com.google.common.cache.LocalCache.get(LocalCache.java:4012)
at com.google.common.cache.LocalCache$LocalManualCache.get(LocalCache.java:4922)
at com.auth0.jwk.GuavaCachedJwkProvider.get(GuavaCachedJwkProvider.java:62)
at io.ktor.server.auth.jwt.JWTUtilsKt.getVerifier(JWTUtils.kt:55)
at io.ktor.server.auth.jwt.JWTAuthenticationProvider$Config.verifier$lambda$2(JWTAuth.kt:312)
at io.ktor.server.auth.jwt.JWTAuthenticationProvider.onAuthenticate(JWTAuth.kt:197)
at io.ktor.server.auth.AuthenticationInterceptorsKt$AuthenticationInterceptors$2$2.invokeSuspend(AuthenticationInterceptors.kt:135)
at io.ktor.server.auth.AuthenticationInterceptorsKt$AuthenticationInterceptors$2$2.invoke(AuthenticationInterceptors.kt)
at io.ktor.server.auth.AuthenticationInterceptorsKt$AuthenticationInterceptors$2$2.invoke(AuthenticationInterceptors.kt)
at io.ktor.server.auth.AuthenticationHook$install$1.invokeSuspend(AuthenticationInterceptors.kt:29)
at io.ktor.server.auth.AuthenticationHook$install$1.invoke(AuthenticationInterceptors.kt)
at io.ktor.server.auth.AuthenticationHook$install$1.invoke(AuthenticationInterceptors.kt)
at io.ktor.server.application.ApplicationPluginKt$addAllInterceptors$1$1$1.invokeSuspend(ApplicationPlugin.kt:209)
at io.ktor.server.application.ApplicationPluginKt$addAllInterceptors$1$1$1.invoke(ApplicationPlugin.kt)
at io.ktor.server.application.ApplicationPluginKt$addAllInterceptors$1$1$1.invoke(ApplicationPlugin.kt)
at io.ktor.util.pipeline.DebugPipelineContext.proceedLoop(DebugPipelineContext.kt:79)
at io.ktor.util.pipeline.DebugPipelineContext.proceed(DebugPipelineContext.kt:57)
at io.ktor.util.pipeline.DebugPipelineContext.execute$ktor_utils(DebugPipelineContext.kt:63)
at io.ktor.util.pipeline.Pipeline.execute(Pipeline.kt:92)
at io.ktor.server.routing.RoutingRoot$executeResult$$inlined$execute$1.invokeSuspend(Pipeline.kt:510)
at io.ktor.server.routing.RoutingRoot$executeResult$$inlined$execute$1.invoke(Pipeline.kt)
at io.ktor.server.routing.RoutingRoot$executeResult$$inlined$execute$1.invoke(Pipeline.kt)
at io.ktor.util.debug.ContextUtilsKt.initContextInDebugMode(ContextUtils.kt:19)
at io.ktor.server.routing.RoutingRoot.executeResult(RoutingRoot.kt:212)
at io.ktor.server.routing.RoutingRoot.interceptor(RoutingRoot.kt:71)
at io.ktor.server.routing.RoutingRoot$Plugin$install$1.invokeSuspend(RoutingRoot.kt:157)
at io.ktor.server.routing.RoutingRoot$Plugin$install$1.invoke(RoutingRoot.kt)
at io.ktor.server.routing.RoutingRoot$Plugin$install$1.invoke(RoutingRoot.kt)
at io.ktor.util.pipeline.DebugPipelineContext.proceedLoop(DebugPipelineContext.kt:79)
at io.ktor.util.pipeline.DebugPipelineContext.access$proceedLoop(DebugPipelineContext.kt:16)
at io.ktor.util.pipeline.DebugPipelineContext$proceedLoop$1.invokeSuspend(DebugPipelineContext.kt)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:34)
at io.ktor.server.application.ClassLoaderAwareContinuationInterceptor$interceptContinuation$1.resumeWith(ApplicationEnvironment.kt:79)
at kotlinx.coroutines.DispatchedTaskKt.resume(DispatchedTask.kt:163)
at kotlinx.coroutines.DispatchedTaskKt.dispatch(DispatchedTask.kt:152)
at kotlinx.coroutines.CancellableContinuationImpl.dispatchResume(CancellableContinuationImpl.kt:470)
at kotlinx.coroutines.CancellableContinuationImpl.resumeImpl$kotlinx_coroutines_core(CancellableContinuationImpl.kt:504)
at kotlinx.coroutines.CancellableContinuationImpl.resumeImpl$kotlinx_coroutines_core$default(CancellableContinuationImpl.kt:493)
at kotlinx.coroutines.CancellableContinuationImpl.resumeWith(CancellableContinuationImpl.kt:359)
at kotlinx.coroutines.reactive.AwaitKt$awaitOne$2$1.onNext(Await.kt:238)
at reactor.core.publisher.StrictSubscriber.onNext(StrictSubscriber.java:89)
at reactor.core.publisher.MonoNext$NextSubscriber.onNext(MonoNext.java:82)
at reactor.core.publisher.MonoNext$NextSubscriber.onNext(MonoNext.java:82)
at io.lettuce.core.RedisPublisher$ImmediateSubscriber.onNext(RedisPublisher.java:895)
at io.lettuce.core.RedisPublisher$RedisSubscription.onNext(RedisPublisher.java:295)
at io.lettuce.core.RedisPublisher$SubscriptionCommand.doOnComplete(RedisPublisher.java:782)
at io.lettuce.core.protocol.CommandWrapper.complete(CommandWrapper.java:69)
at io.lettuce.core.protocol.CommandHandler.complete(CommandHandler.java:769)
at io.lettuce.core.protocol.CommandHandler.decode(CommandHandler.java:704)
at io.lettuce.core.protocol.CommandHandler.channelRead(CommandHandler.java:621)
at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:354)
at io.netty.channel.DefaultChannelPipeline$HeadContext.channelRead(DefaultChannelPipeline.java:1429)
at io.netty.channel.DefaultChannelPipeline.fireChannelRead(DefaultChannelPipeline.java:918)
at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:168)
at io.netty.channel.nio.AbstractNioChannel$AbstractNioUnsafe.handle(AbstractNioChannel.java:445)
at io.netty.channel.nio.NioIoHandler$DefaultNioRegistration.handle(NioIoHandler.java:388)
at io.netty.channel.nio.NioIoHandler.processSelectedKey(NioIoHandler.java:596)
at io.netty.channel.nio.NioIoHandler.processSelectedKeysOptimized(NioIoHandler.java:571)
at io.netty.channel.nio.NioIoHandler.processSelectedKeys(NioIoHandler.java:512)
at io.netty.channel.nio.NioIoHandler.run(NioIoHandler.java:484)
at io.netty.channel.SingleThreadIoEventLoop.runIo(SingleThreadIoEventLoop.java:225)
at io.netty.channel.SingleThreadIoEventLoop.run(SingleThreadIoEventLoop.java:196)
at io.netty.util.concurrent.SingleThreadEventExecutor$5.run(SingleThreadEventExecutor.java:1193)
at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
at java.base/java.lang.Thread.run(Thread.java:1583)
Caused by: java.io.FileNotFoundException: http://localhost:8000/realms/foodies-keycloak/protocol/openid-connect/certs/.well-known/jwks.json
at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream0(HttpURLConnection.java:2023)
at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream(HttpURLConnection.java:1618)
at com.auth0.jwk.UrlJwkProvider.getJwks(UrlJwkProvider.java:140)
... 77 common frames omitted
OpenID Connect (OAuth2) auto-discover & configuration
Similar to https://youtrack.jetbrains.com/issue/KTOR-8595/Auth-JWK-Support-auto-discover, OpenID Connect OAuth2 flows can automatically be configured through auto-discovery granted the following information is provided:
clientIdclientSecretissuer
Based on that, the current OAuth2 configuration could be done automatically (generated by Gemini 2.5 Pro). This snippet omits quite a lot of overloads to parameterise the actual underlying config.
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.routing.*
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.call.*
import io.ktor.client.request.*
import kotlinx.serialization.json.Json
import kotlinx.serialization.Serializable
// Define a simplified data class for the discovery document (only relevant fields)
@Serializable
data class OpenIdConfiguration(
val issuer: String,
val authorization_endpoint: String,
val token_endpoint: String,
val jwks_uri: String
)
fun Application.configureOpenIdConnect(
issuer: String,
clientId: String? = null,
clientSecret: String? = null,
scopes: List<String> = listOf("openid"),
callback: suspend RoutingContext.() -> Unit
) {
val discoveryUrl = "$issuer/.well-known/openid-configuration"
val clientId = clientId ?: environment.config.property("oauth.clientId").getString()
val clientSecret = clientSecret ?: environment.config.property("oauth.clientSecret").getString()
val httpClient = HttpClient(CIO)
// In a real application, you'd want to cache this or load it once at startup
// and handle potential network errors gracefully.
val openIdConfig: OpenIdConfiguration =
runBlocking { // Use runBlocking only for setup, not in production code generally
httpClient.get(discoveryUrl).body()
}
install(Authentication) {
oauth("auth-oauth-openid") {
urlProvider = { redirectUrl("/oauth/callback") } // Your callback URL
providerLookup = {
OAuthServerSettings.OAuth2ServerSettings(
name = "YourOpenIDProvider",
authorizeUrl = openIdConfig.authorization_endpoint,
accessTokenUrl = openIdConfig.token_endpoint,
clientId = clientId,
clientSecret = clientSecret,
defaultScopes = scopes,
// You might also need to configure the request method for token endpoint (e.g., HttpMethod.Post)
// parameterise additional configuration
)
}
client = httpClient
}
}
routing {
authenticate("auth-oauth-openid") {
get("/login") { }
get("/oauth/callback", callback)
}
}
}
// Helper function for redirect URL (replace with your actual domain and port)
fun ApplicationCall.redirectUrl(path: String): String {
val protocol = request.origin.scheme
val host = request.origin.host
val port = request.origin.port
return "$protocol://$host:$port$path"
}
CIO on Kotlin/Native collapses above ~50 connections: HttpHeadersMap pools contend on an allocating SynchronizedObject
Type: Bug · Subsystem: Server. Engine. CIO · Affected versions: 3.5.2 (and earlier 3.x)
Summary
On Kotlin/Native every parsed HTTP request takes two process-wide locks at least twice each, and the lock implementation allocates an object on every failed attempt, so the cost of an acquisition grows with the number of threads contending. Past about fifty connections the server does not slow down, it collapses: at 200 connections it serves 7 082 of the 60 000 requests offered with a p99 of 1.5 s, on 3.7 cores. The same binary with one file changed serves 60 053 with a p99 of 8.8 ms on 2.1 cores.
Environment
Kotlin 2.4.10 linuxX64 release binaries, Ktor 3.5.2 (ktor-server-cio, content negotiation, kotlinx-serialization), Ubuntu 26.04, 4 cores. Subject and load generator on separate hosts over a private link, k6, 30 s per run, arms rotated, twelve runs per arm, memory bounded by a cgroup.
What happens
HttpHeadersMap keeps its storage in two process-wide pools (ktor-http-cio/common/src/io/ktor/http/cio/HttpHeadersMap.kt):
private val IntArrayPool: DefaultPool<IntArray> = object : DefaultPool<IntArray>(1000) { ... }
private val HeadersDataPool: DefaultPool<HeadersData> = object : DefaultPool<HeadersData>(1000) { ... }
and on Kotlin/Native DefaultPool is a mutableListOf behind one SynchronizedObject, taken on every borrow and every recycle (ktor-io/posix/src/io/ktor/utils/io/pool/DefaultPool.posix.kt).
That SynchronizedObject (ktor-io/posix/src/io/ktor/utils/io/locks/Synchronized.kt) is a CAS loop over an AtomicReference<LockState> which allocates a LockState on every attempt and writes a heap reference on every attempt. A lost CAS is not a cheap retry. On the JVM the class is erased and synchronized becomes a monitor, so none of this is visible there.
The pools are not the only user of that lock. In the modules a native server links it is also ByteChannel.flushBufferMutex (ktor-io, 3 sites), every operation of ConcurrentMap (ktor-utils/posix, 17 sites — get, size and containsKey included, and Attributes is built on it), and DatagramSendChannel (ktor-network). Fixing the lock fixes all four; fixing the pool fixes one.
Evidence
perf, five runs: 32–73 % of all CPU is insideSynchronizedObject#lock, and the share is monotone with the damage. The collector's own functions are at 1–2 %.- gdb, three
thread apply all btsnapshots under load: all 211 frames inside that function have the same two callers —DefaultPool#borrowfromHttpHeadersMap#<init>, andDefaultPool#recyclefromHttpHeadersMap#releaseand.resize. - A ladder of five servers on the same stand:
ktor-networkraw sockets with noktor-server-coregoes slow in 0 of 12 runs; adding the CIO parser makes it 9 of 12. The step where the slow state appears is the step where these pools appear. - The rate follows concurrency, not load. At the same 2 000 rps offered: 12 connections 0 of 10 runs slow and 59 362 requests served; 50 connections 7 of 10; 200 connections 10 of 10, 6 361 served. p = 1.1e-5 across the ends.
Fix
ktor-io already declares org.jetbrains.kotlinx:atomicfu as an api dependency and already resolves 0.32.1. kotlinx.atomicfu.locks.SynchronizedObject was rewritten in 0.28.0-beta — changelog: "Implemented thread parking primitives (#498)", "Native mutexes: supported QoS on Apple platforms (#499) and improved implementations on other targets (#512, #517)". The current implementation keeps the owner in an AtomicLong and the waiter count in an AtomicInt, parks losers on a monitor, and allocates nothing. Ktor's file is a copy of the implementation that rewrite replaced.
Deleting the copy is enough. Twelve runs per arm, four builds from the same tree differing in one dependency:
| Build | 200 conn: slow | p99 | Requests | 50 conn: slow | p99 |
|---|---|---|---|---|---|
| 3.5.2 as released | 12/12 | 1 500 ms | 7 082 | 7/12 | 310 ms |
| pool made lock-free | 0/12 | 11.3 ms | 59 924 | 0/12 | 7.8 ms |
| lock delegated to atomicfu | 0/12 | 8.4 ms | 59 928 | 0/12 | 6.5 ms |
| both changes | 0/12 | 8.8 ms | 60 053 | 0/12 | 6.4 ms |
Zero of seventy-two patched runs against nineteen of twenty-four stock, p = 1.7e-13. Replacing the lock alone is as good as replacing the pool, and better on p99 and on peak memory.
Reentrancy is preserved. The class being replaced is reentrant (LockState carries a nesting count) and ConcurrentMap takes it at seventeen sites, so re-entry is not hypothetical. atomicfu's replacement is reentrant by the same contract — it is what their own ReentrantLock is built on, and its lock() increments a reEnterCount when the owner is already the current thread. Backed by a run: on the patched tree :ktor-io:macosArm64Test and :ktor-utils:macosArm64Test give 205 tests across 29 suites, 0 failures, 0 skipped, including AttributesTest, ConcurrentSetTest, PipelineTest and the whole ByteChannel set. Note there is no dedicated ConcurrentMap test in the repository, so reentrancy rests on those suites plus the contract; happy to add one in the PR if wanted.
One caveat on the shape of the change, which will save a day. public actual typealias SynchronizedObject = kotlinx.atomicfu.locks.SynchronizedObject compiles and links and then fails at runtime — IrLinkageError: uses unlinked class symbol 'io.ktor.utils.io.locks/SynchronizedObject' — because ktor-utils and ktor-network reference the class by symbol and partial linkage reports the break only when it is reached. Keeping the class and delegating its three methods works and preserves the ABI. That is what the pull request does.
A pull request against release/3.x follows this issue.
Reproducer
A five-rung probe, the harness and every result file are available and can be attached or linked on request; the shape is one route returning one JSON object, embeddedServer(CIO), driven by k6 from a second host at a fixed rate with the connection count as the variable.
OAuth: clients expect to get authenticated once per "session" for all protected routes
For all authentication mechanisms, users assume that once a client passes authentication by requesting any of the protected routes (under authenticate block), the server (or a client) saves this knowledge in a "session" and doesn't require it again. This works as expected with basic authentication since a browser saves user/password information and sends it each time it's necessary. But it doesn't work with OAuth because the fact about successful authentication isn't saved anywhere.
Use cases:
https://stackoverflow.com/questions/66779024/oauth2-authorised-routes-not-working-in-ktor
https://stackoverflow.com/questions/66601534/multiple-urlproviders-on-the-same-oauth-setting-in-ktor-with-keycloak
An example solution with sessions:
import io.ktor.application.*
import io.ktor.auth.*
import io.ktor.client.*
import io.ktor.client.engine.apache.*
import io.ktor.client.request.*
import io.ktor.response.*
import io.ktor.routing.*
import io.ktor.server.cio.*
import io.ktor.server.engine.*
import io.ktor.sessions.*
import java.util.*
import java.util.concurrent.ConcurrentHashMap
val githubProvider = OAuthServerSettings.OAuth2ServerSettings(
name = "github",
authorizeUrl = "https://github.com/login/oauth/authorize",
accessTokenUrl = "https://github.com/login/oauth/access_token",
clientId = "...",
clientSecret = "...",
defaultScopes = listOf("repo", "user")
)
data class LoginSession(val userId: String)
typealias UserId = String
typealias AccessToken = String
val tokens = ConcurrentHashMap<UserId, AccessToken>()
fun main() {
embeddedServer(CIO, port = 8888) {
install(Authentication) {
oauth("gitHubOAuth") {
client = HttpClient(Apache)
providerLookup = { githubProvider }
urlProvider = { "http://localhost:8888/callback" }
}
}
install(Sessions) {
cookie<LoginSession>("LOGIN_SESSION")
}
routing {
authenticate("gitHubOAuth") {
get("login") {}
route("callback") {
param("error") {
handle {
call.respondText { call.parameters["error"].orEmpty() }
}
}
handle {
val principal = call.authentication.principal<OAuthAccessTokenResponse.OAuth2>()
if (principal != null) {
val userId = UUID.randomUUID().toString()
call.sessions.set(LoginSession(userId))
tokens[userId] = principal.accessToken
}
}
}
}
get("repos") {
val repos = call.withAuth { session ->
HttpClient(Apache).get<String>("https://api.github.com/user/repos") {
val token = tokens[session.userId]
header("Authorization", "token $token")
}
}
call.respondText { repos }
}
}
}.start(wait = true)
}
suspend fun <T: Any> ApplicationCall.withAuth(block: suspend (session: LoginSession) -> T): T {
val session = sessions.get<LoginSession>()
if (session != null) {
return block(session)
}
respondRedirect("/login")
throw Exception("Not authenticated")
}
Auth JWK Support (auto-discover)
Currently, we do not offer any JWK support out-of-the-box. Which seems to be the most used use-case, to verify OpenID Connect / OAuth2 tokens.
When compared to some other frameworks, they typically auto-discover JWK and automatically configure based on the 'issuer'.
I.e.:
Given issuer: https://accounts.google.com
We can discover: https://accounts.google.com/.well-known/openid-configuration
Which contains all the information we need to properly set up and configure JWK.
// We only needs jwks_uri
@Serializable data class OpenIdConfiguration(@SerialName("jwks_uri") val jwksUri: String)
HttpClient(CIO) { install(ContentNegotiation) { json() } }
.get {
url.takeFrom("https://accounts.google.com")
url.appendPathSegments(".well-known", "openid-configuration")
}.body<OpenIdMetadata>() // Use bodyAsText and JsonElement for more flexibility?
val provider =
JwkProviderBuilder(URL(jwksUri))
.cached(10, 24, TimeUnit.HOURS)
.rateLimited(10, 1, TimeUnit.MINUTES)
.build()
authentication {
jwt("google-jwt") {
verifier(provider, config.issuer)
validate { credential -> credential }
}
}
Other
HttpClient eagerly initializes SLF4J during Android startup
Creating an HttpClient with an explicitly supplied engine on Android eagerly initializes SLF4J, even when the application does not emit Ktor internal logs.
Current behavior
The initialization path on Ktor 3.5.2 and current main is:
- Every client installs
HttpRequestLifecycle. HttpRequestLifecycle.kteagerly creates a file-levelKtorSimpleLogger.- On JVM,
KtorSimpleLoggercallsLoggerFactory.getLogger. - The first SLF4J 2.x call discovers providers with
ServiceLoader, which can synchronously read APK resources.
A production ANR was reported from a minified release build with R8 enabled and an explicit OkHttp engine. The main thread was blocked in LoggerFactory.findServiceProviders → ServiceLoader → APK ZIP reads while the client was created during application startup. The same eager path remains on main at 127a09a20.
Expected behavior
Constructing an explicit-engine client should not initialize SLF4J until an internal log message is actually emitted. Internal loggers could be initialized lazily, or Ktor could provide another supported way to avoid eager SLF4J initialization on Android.
Existing workaround
SLF4J 2.x supports setting the slf4j.provider system property before the first logger access. This bypasses provider discovery, but it is process-global, must be configured before Ktor initialization, and requires a provider implementation on the classpath.
DI: Concurrent module loading can cause a deadlock
When running a server using ktor.application.startup=concurrent, Dependency Injection can cause a deadlock that prevents the application from starting. The issue is in DependencyInitializer.Missing.provide: It completes the Missing with deferred.completeWith(other.resolve(resolver)), but other.resolve(resolver) returns another Deferred that is never actually started. This means that if a module tries to resolve a dependency before it has been provided by a second module, it will never resume unless a third module coincidentally attempts another resolve for the same dependency.
Using ktor-server-test-host, you can recreate this with the following code block:
interface BankService {
fun deposit(amount: Int)
fun balance(): Int
}
class BankServiceImpl : BankService {
override fun deposit(amount: Int) {
println("Depositing $amount")
}
override fun balance(): Int {
return 0
}
}
lateinit var resolvedBankService: BankService
testApplication {
environment {
config = MapApplicationConfig().apply {
put("ktor.application.startup", "concurrent")
}
}
application {
resolvedBankService = dependencies.resolve()
}
application {
dependencies { provide<BankService> { BankServiceImpl() } }
}
}
resolvedBankService.deposit(10)
assertEquals(10, resolvedBankService.balance())
ktor-io reader/writer ignores autoFlush
CoroutineScope.writer and CoroutineScope.reader ignore autoFlush
testApplication: call coroutine context isn't preserved when responding with ChannelWriterContent
When using the SSE server plugin the coroutine context from the pipeline is not correctly used
In a regular call
route {
get("...") {
currentCoroutineContext() // <- valid context
// [io.ktor.client.engine.KtorCallContextElement@47a45251,
// CoroutineId(202), CoroutineName(request),
// kotlinx.coroutines.UndispatchedMarker@17f78b74, io.ktor.callid.KtorCallIdContextElement@351e9b71, kotlinx.coroutines.slf4j.MDCContext@e03279f,
}
}
doing the same in a sse route
route {
sse("...") {
currentCoroutineContext() // <- "empty" context
// [CoroutineId(208), kotlinx.coroutines.UndispatchedMarker@3cae17ec,
// "coroutine#208":ScopeCoroutine{Active}@406c8039, Dispatchers.IO]
}
}
the plugins (like logging trace, MDCContext...) coroutine name, etc.. are lost
The same applies to the previous
call.respondTextWriter { ... }
In ktor 3.1 the writer lambda inherited the call context, it longer does now. This could apply to more writer extensions but I have not verified all.
UPDATE:
Only happens when running ktor tests, normal server execution works as expected.
Typo in KDoc for routing function: "for the this Application".
When requesting quick documentation for 'routing', it shows:
public fun Application.routing(
configuration: Routing.() -> Unit
): RoutingRoot
Installs a RoutingRoot plugin for the this Application and runs a configuration script on it. You can learn more about routing in Ktor from Routing .
Digest Auth client: `nc` and `qop` are not handled according to RFC 7616
DigestAuthProvider builds some Authorization header parameters incorrectly. Servers that check them strictly can reject the request.
-
ncis counted for all nonces together, not per nonce.
RFC 7616 §3.4 definesncas the number of requests sent with the nonce in this request. The provider keeps one counter for everything, so the first request answering a fresh nonce can be sent withnc=00000005, for example. Servers that send a new nonce with every challenge (Ktor's server does) and checkncto detect replays may reject such requests. -
When the server offers several
qopvalues, they are copied into the request unchanged.
RFC 7616 §3.4 says the client'sqopmust be one of the offered values. For a challenge withqop="auth,auth-int", the client sendsqop="auth,auth-int"and also uses that string to computeresponse, so authentication fails. For a challenge offering onlyauth-int, which the client doesn't support, the client still sends a response the server can't accept. -
ncandcnonceare sent even when the challenge has noqop.
Withoutqop, the client uses the RFC 2069 hash. RFC 2617 §3.2.2, the only spec that defines that hash, forbidsncandcnoncein that case.
Documentation for OpenID Connect (OAuth2) auto-discover & configuration
Description
Documentation for the new OIDC server plugin.
Describe different use-cases; what is OIDC, and what other RFCs are implemented in the module
Code example
See the KLIP document https://github.com/ktorio/ktor-klip/blob/main/proposals/0007-openid-connect-auth-3.5.md
Migration guide
Does this change require a migration guide for existing users?
- [ ] Yes
- [x] No
Netty: inverted skippedRead CAS leaves runningLimit read-resumption broken; runningLimit = 1 deadlocks keep-alive connections
Symptom
With runningLimit = 1, a keep-alive connection answers exactly one request and then never reads from the socket again. The client waits forever; no error, no close.
| Expected | Observed | |
|---|---|---|
runningLimit = 1, request #1 |
200 OK | 200 OK |
runningLimit = 1, request #2 on same connection |
200 OK | read timeout, no bytes, connection still open |
runningLimit = 2, 8, 32, sequential requests |
200 OK | 200 OK |
runningLimit = 2, 8, 32, saturate limit then reuse |
200 OK | 200 OK |
Mechanism
ktor-server/ktor-server-netty/jvm/src/io/ktor/server/netty/NettyHttpHandlerState.kt:23
if (skippedRead.compareAndSet(expect = false, update = true) && activeRequests.value < runningLimit) {
context.read()
}
NettyHttp1Handler.kt:334-341 (callReadIfNeeded) sets skippedRead = true precisely when a read was skipped because the limit was reached. So the CAS succeeds only when nothing was skipped — context.read() is re-armed in the one case it is not needed, and never in the case it is.
The channel runs with isAutoRead = false (NettyHttp1Handler.kt:83), so a read must be re-armed explicitly. At runningLimit = 1 every request drives activeRequests to the limit, so no callReadIfNeeded in the read cycle ever sees spare capacity, Netty's readPending is never re-set, and the connection is never read from again.
Limits above 1 recover incidentally: an earlier context.read() in the same read cycle (issued while activeRequests was still below the limit) leaves readPending set for the next cycle.
The pendingMessages queue makes runningLimit a hard cap, which is the other half of item 2, but it was built around this condition without correcting it. It masks the problem whenever there are queued messages to replay, because drainPending → dispatchMessage → callReadIfNeeded re-arms the read as a side effect.
Impact
runningLimit is a public configuration (NettyApplicationEngine.Configuration.runningLimit) with no validation, so 1 is a reachable value and a natural way to serialise requests per connection. It silently deadlocks every keep-alive connection.
Netty performance issues
Investigations into Ktor's benchmark statistics have revealed a few problems that appear when a Netty server is under load.
- Pipelined responses are not flushed while other requests are queued in the same connection. This leads to a memory leak on the connection which could crash the server.
- The runningLimit config item is not properly enforced due to a mistake in the logic when checking if reads on pipelined requests should continue. This can lead to poisoning of connections after the threshold is reached.
- Call jobs are all direct children of the application job. When processing connections on a server with high parallelism and many connections, this causes major contention on the coroutine internals.
- The
shareWorkGroupflag shares the same overhead as the default configuration due to the thread-pinning logic. This ought to be circumvented to allow for higher throughput. - Write jobs are cancelled instead of completed on happy path requests. This allocates an exception each time, which is very bad for the allocation rate.
- Empty HTTP/2 requests cause a leak from not being finished
- Flush considation support: this is a feature in Netty that can improve performance by considating flushes on socket writes.
Netty: engine double-responds after its built-in 400, crashing the call-handler coroutine with "Headers can no longer be set because response was already completed"
Summary
On ktor 3.5.2 the Netty engine
crashes the call-handler coroutine with:
java.lang.UnsupportedOperationException: Headers can no longer be set because response was already completed
at io.ktor.server.netty.http1.NettyHttp1ApplicationResponse$headers$1.engineAppendHeader(NettyHttp1ApplicationResponse.kt:45)
at io.ktor.server.response.ResponseHeaders.append(ResponseHeaders.kt:71)
at io.ktor.server.engine.BaseApplicationResponse.commitHeaders(BaseApplicationResponse.kt:90)
at io.ktor.server.engine.BaseApplicationResponse.respondOutgoingContent$suspendImpl(BaseApplicationResponse.kt:135)
at io.ktor.server.netty.NettyApplicationResponse.respondOutgoingContent$suspendImpl(NettyApplicationResponse.kt:41)
...
at io.ktor.server.engine.DefaultEnginePipelineKt.tryRespondError(DefaultEnginePipeline.kt)
at io.ktor.server.engine.DefaultEnginePipelineKt.handleFailure(DefaultEnginePipeline.kt:72)
at io.ktor.server.netty.http1.NettyHttp1Handler$handleRequest$1$1.invokeSuspend(NettyHttp1Handler.kt:191)
logged as Unhandled exception caught for CoroutineName(call-handler) on the application logger.
On a public endpoint this is high-volume noise. The events carry empty url/transaction
because the request never reaches routing — StatusPages cannot intercept this path.
This is the same exception as KTOR-8410, which was closed as "Can't Reproduce". Below is the exact
mechanism; the trigger race is real traffic (malformed request + connection teardown), which is why
it is hard to hit in a local repro but constant in production.
Mechanism (ktor 3.5.1 sources)
- A client sends a request Netty's HTTP decoder rejects (or one with an invalid
Transfer-Encodinglist) →NettyHttp1Handler.handleRequest:
!call.request.isValid()→call.respondError400BadRequest(). respondError400BadRequest(NettyApplicationCallHandler.kt) writes the 400 via
response.sendResponse(chunked = false, content)— this sets the engine-level
responseMessageSent = trueand completesresponseReadywith success, but never touches
BaseApplicationResponsestate:isCommittedandisSentstayfalse(nothing called
commitHeaders, andisSentis only set at the end ofrespondOutgoingContent).- The connection dies while the 400 is being finished (bot disconnects;
exceptionCaught→
handlerJob.cancel()), sofinish()/responseWriteJob.join()throws a
CancellationExceptionout ofrespondError400BadRequest. - The
catch (error: Throwable)inNettyHttp1Handler.handleRequest(line 190) calls
handleFailure(call, error)→tryRespondError. Its guard is
if (call.response.isCommitted || call.response.isSent) return— both are false (step 2),
so it callscall.respond(...)→commitHeaders→engineAppendHeader. engineAppendHeaderseesresponseMessageSent == true. Its benign branch requires
responseReady.isCancelled, but the promise completed with success (step 2), so it throws
UnsupportedOperationException("Headers can no longer be set because response was already completed"). The exception escapeshandleFailure, fails the coroutine and reaches the
uncaught-exception handler (it is not aCancellationException/IOException, so it is logged
at error level).
In short: the engine's own channel-level 400 desynchronizes the engine state
(responseMessageSent) from the call state (isCommitted/isSent), and the engine's own failure
fallback then trusts the call state and double-responds.
KTOR-8410's stack (Sessions cookie append inside a StatusPages handler) is another entry point
into the same desynchronized state.
Suggested fix (one line)
Mark the call as answered when the engine answers at the channel level, in
NettyApplicationCallHandler.respondError400BadRequest:
response.sendResponse(chunked = false, content)
response.isSent = true // channel-level response == the call is answered
finish()
tryRespondError's existing guard then short-circuits and the crash disappears. Setting isSent
inside sendResponse itself would be wrong — that function is also on the streaming path
(responseChannel()), where isSent must keep meaning "body fully written".
Deterministic regression test
No connection race needed — both functions are reachable from ktor-server-netty tests:
// call = a NettyHttp1ApplicationCall whose request failed decoding
call.respondError400BadRequest()
handleFailure(call, RuntimeException("simulated failure after the built-in 400"))
// before the fix: UnsupportedOperationException("Headers can no longer be set...")
// after the fix: no throw
Environment
- ktor 3.5.2 (Netty engine,
enableHttp2 = true,enableH2c = true), JDK 25, Linux
ServletApplicationEngine: Application events aren't triggered
Hello. I discovered that my Ktor applications are leaking resources when deployed on Tomcat, even though I was releasing those resources in the ApplicationStopped event. While debugging the issue, I noticed that none of the ApplicationXXX lifecycle events are triggered when running on Tomcat.
I reproduced the issue using the official codeSnippets. When adding the following code:
monitor.subscribe(ApplicationStarted){
println("Application started")
}
monitor.subscribe(ApplicationStopping){
println("Application stopping")
}
monitor.subscribe(ApplicationStopped){
println("Application stopped")
}
to a snippet using a different engine (e.g., the call-id snippet), all events are logged as expected. However, when adding the same code to the tomcat-war snippet, none of the events are printed.
As a temporary workaround, I moved my cleanup logic to a ServletContextListener, but I would prefer to use the official Ktor lifecycle event mechanism if possible.
Jackson/Gson converters waste time on unnecessary Dispatchers.IO dispatch
Summary
JacksonConverter and GsonConverter dispatch every deserialize() to Dispatchers.IO; the dispatch costs ~4x more than the actual parsing for typical API payloads.
The dispatch exists to keep blocking InputStream reads off the caller's thread. However, for request bodies that are already buffered in the channel and the common case for API-sized payloads ByteReadChannel.toInputStream() never actually blocks: its read path only parks when the channel is empty and still open for write.
The guard is the dominant cost of the whole receive path. JMH decomposition (JDK 21 Corretto, Apple Silicon, JMH 1.37, avgt, 2 forks):
| Micro-benchmark | Result |
|---|---|
withContext(Dispatchers.IO) {} (empty) |
8,802 ± 311 ns |
| Jackson raw parse, 10-element list (~400 B JSON) | 2,282 ± 77 ns |
toInputStream drain of buffered 64 KB channel |
2,152 ns, 72 B/op (no runBlocking on the buffered path) |
Full JacksonConverter.deserialize of the same payload |
14,608 ± 380 ns |
Framework overhead is therefore 14,608 − 2,282 ≈ 12.3 µs, dominated by the two thread handoffs of the IO dispatch. KotlinxSerializationConverter demonstrates the alternative in-tree: it reads the body with suspending, non-blocking reads (readRemaining()) and parses inline; full converter cost for the same payload is 1,686 ± 75 ns.
Type: Performance Problem
Subsystem: Serialization
Affected versions: 3.x
Suggested fix
Read the body with suspending toByteArray() (cooperative; the carrier thread is released while awaiting bytes — strictly better than pinning an IO thread in a blocking read) and parse inline, mirroring KotlinxSerializationConverter. Measured with that change applied:
Apache5: Upgrading Apache HttpClient5 to version 5.6 breaks the Ktor client with `Content-Length mismatch` error for any gzipped content
Ktor client 3.5.2 uses Apache HttpClient5 5.5.1. When forcing HttpClient5 version 5.6.x by direct dependency
implementation("org.apache.httpcomponents.client5:httpclient5:5.6.4")
or indirect by using Spring Boot BOM, reading any gzipped content results in Content-Length mismatch error.
Client curated multi-platform facade module
Since KMP is the new standard for Kotlin applications, more users are looking for a convenient way to use the Ktor client in their projects.
Oftentimes, users will choose the CIO engine for this purpose, but they are left disappointed with the supported features and performance.
To resolve this, we can simply provide a facade module that imports our recommended engine for each platform.
OkHttp: Reduce coroutine dispatching and allocations while reading response bodies
Problem
The OkHttp client copies response bodies through a producer coroutine and an intermediate ByteChannel:
ResponseBody.source()
-> BufferedSource.toChannel(callContext, requestData)
-> GlobalScope.writer(callContext)
-> ByteChannel
-> response consumer
The response-copy loop reads approximately one 4–8 KiB chunk at a time. For a 2 MiB response this produces about 513 iterations.
The loop calls:
channel.write { buffer ->
lastRead = source.read(buffer)
}
channel.flush()
The ByteWriteChannel.write(ByteBuffer) already calls flush(). The second flush is therefore redundant and creates another suspend continuation invocation on every iteration. Allocation measurements show two ByteChannel$flush$1 continuation sites, each totaling approximately 24.6 KiB per 2 MiB request.
Even after removing the duplicate flush, write() still invokes suspending flush() for every source read. Most calls do not suspend because the channel has free capacity, but they retain continuation overhead and increase opportunities for producer/consumer scheduling changes.
Coroutine migration also affects Okio's JVM segment pool because pool buckets are selected from the current thread. A migration can move later reads to another bucket, while bucket contention can produce an okio.Segment and paired 8 KiB ByteArray allocation despite pooled segments being available elsewhere.
Allocation evidence
From ktor-benchmarks against the baseline:
client/streamingResponse[OkHttp]: +3,869 bytes total
Allocations under OkHttpEngine.toChannel included paired [B and okio.Segment allocations. The deterministic duplicate-flush cost was approximately 24.6 KiB per request; the segment-pool variance is scheduler-sensitive.
Testing
Add an OkHttp engine test that keeps the source open after emitting its first byte and verifies that the consumer receives that byte before EOF. This protects immediate publication without requiring a suspending flush per source read.
Support nested jars in static resources
The reference to the Spring JarFile: https://docs.spring.io/spring-boot/docs/2.7.1/api/org/springframework/boot/loader/jar/JarFile.html
Cancel blocking bridges when coroutine is cancelled
Usage of runBlocking inside java.io blocking bridges (ByteReadChannel.toInputStream, ByteWriteChannel.toOutputStream) breaks structured concurrency.
When such a bridge is used from a coroutine, cancellation of this coroutine doesn't affect the blocking bridge as it uses runBlocking under the hood. We should pass a parent Job into runBlocking to propagate cancellation.
Support HTTP/3 for Netty
Since this codec has been supported for the Netty library since 4.2.5, we can take advantage and incorporate it into the engine.
Results
AUGUST 2026
Supported HTTP/3 for Netty. Will be released in 3.6.0.
Documentation for Support at least zstd and deflate formats of precompressed files
Description
We introduced the ZSTD and DEFLATE options to the list of precompressed file options when working with static files.
ZSTD is a modern compression algorithm which is generally stronger than GZIP and BROTLI.
Code example
staticResources("staticResources", "public") {
preCompressed(CompressedFileType.BROTLI, CompressedFileType.GZIP)
}
For more examples, see https://github.com/solonovamax/ktor/blob/60c580699c31598ae4bd1a9e9606b00ac6affb53/ktor-server/ktor-server-tests/jvm/test/io/ktor/server/plugins/StaticContentTest.kt
Support at least zstd and deflate formats of precompressed files
Currently, when serving pre-compressed files you can only serve them in 2 formats:
- brotli
- gzip
there is no option to serve them in other formats such as zstd or deflate (for older browsers).
this is configured by doing something similar to the following:
staticFiles("/", File("./public/")) {
preCompressed(
CompressedFileType.BROTLI,
CompressedFileType.GZIP,
CompressedFileType.DEFLATE,
CompressedFileType.ZSTD,
)
// ...
}
According to MDN, here is a list of all valid directives for Accept-Encoding/Content-Encoding:
gzipcompress(not supported by most browsers due to patent stuff, see MDN)deflatebrzstddcb(brotli using a custom dictionary, see MDN) (experimental, only supported by chrome ≥130)dcz(zstd using a custom dictionary, see MDN) (experimental, only supported by chrome ≥130)
I think support for, at the very least, zstd and deflate should be added.
to add support for these compression formats (as well as a few other changes), in my projects what I have been doing is copying PreCompressed.kt and replacing the CompressedFileType enum with the following:
data class CompressedFileType(val extension: String, val encoding: String = extension) {
companion object {
val BROTLI = CompressedFileType("br")
val GZIP = CompressedFileType("gz", "gzip")
val DEFLATE = CompressedFileType("deflate")
val ZSTD = CompressedFileType("zst", "zstd")
}
}
imo it would make a lot more sense if CompressedFileType was a data class rather than an enum as it would allow users to specify custom compressed file types if they so desired (for example when the user controls both the client and the server and wishes to use their own custom pre-compressed file type, or if in the future another algorithm like bzip2 or lzma are added to the list of supported compression algorithms). However, this would be a breaking change and would not be able to be integrated into the existing api without doing something like deprecating the existing api and coming up with a new name for the new api.
Documentation for Support nullable types in ApplicationCall.receive
Description
ApplicationCall.receive<T>() now supports nullable types. Use a nullable type argument when the request body may contain no value:
val value = call.receive<MyType?>()
receiveNullable<T>() is deprecated. Non-nullable calls such as receive<MyType>() continue to work unchanged.
Code example
An endpoint can accept either notification preferences or JSON null. Sending null clears the user's existing preferences.
@Serializable
data class NotificationPreferences(
val emailEnabled: Boolean,
val pushEnabled: Boolean,
)
put("/users/{userId}/notification-preferences") {
val userId = call.parameters.getOrFail("userId")
val preferences = call.receive<NotificationPreferences?>()
if (preferences == null) {
preferenceService.clear(userId)
} else {
preferenceService.update(userId, preferences)
}
call.respond(HttpStatusCode.NoContent)
}
The requested type now expresses the endpoint contract directly:
receive<NotificationPreferences>()requires a preferences object.receive<NotificationPreferences?>()accepts an object ornull.
Migration guide
Replace receiveNullable with receive and move nullability to the type argument:
post("/") {
- val payload = call.receiveNullable<Payload>()
+ val payload = call.receive<Payload?>()
// use the payload
}
For response bodies, the API remains unchanged. Continue using respondNullable as an explicit opt-in when a route may respond with null.
Related links
- https://github.com/ktorio/ktor/pull/5831
- No existing documentation snippets using
receiveNullablewere found.
Support nullable types in ApplicationCall.receive
ApplicationCall.receive<T>() currently requires a non-nullable T, forcing callers to use receiveNullable<T?>() when null is an expected result. At the same time, receiveNullable also accepts non-nullable type arguments, so the API does not ensure that the requested type carries the nullability information required by content converters.
Nullable types should be received through the regular API:
val card: Card? = call.receive<Card?>()
receive<T>() should therefore accept nullable types, while receiveNullable<T?>() should be deprecated in favor of receive<T?>(). The requested type's nullability must be preserved in TypeInfo even when complete KType information is unavailable.
Original description
When sending an empty body from a Ktor client to the server, it says:
"No suitable content converter found for request type class com.queatz.db.Card"
Note this used to work, not sure what happened.
Documentation for DefaultConversionService: Support Uuid type introduced in Kotlin 2.0
In 3.6.0, our default parsing was updated to include new Kotlin types:
- Uuid
- Byte
- java.lang.Byte
- UByte
- UShort
- UInt
- ULong
Now you can use all of these with property delegation inside you call handlers:
get {
val uuid: Uuid by call.parameters
}
Documentation for Support HTTP/3 for Netty
Please provide the following details so the technical writing team can begin work on this issue.
Description
Describe what this feature or change does and what problem it solves.
Code example
Provide a code example or link to a working sample (if applicable).
You can also link to a repo, PR, or snippet.
Migration guide
Does this change require a migration guide for existing users?
- [ ] Yes
- [ ] No
Related Links (Optional)
Add any relevant references:
- Blog posts
- Design or product docs (e.g., Quip, KEEP)
- PRs or related issues (other than the parent issue)
Once the description is complete, please re-assign this issue to a Technical Writer.
Documentation for Support HTTP/3 for Netty
In 3.6.0, we're providing support for QUIC and HTTP/3 in the Netty server engine.
You can enable this in the engine config using enableHttp3(). This function also accepts a scoped lambda for HTTP/3-specific configuration:
embeddedServer(Netty, environment, {
// SSL connector is required
sslConnector(
keyStore = keyStore,
keyAlias = "server",
keyStorePassword = { "changeit".toCharArray() },
privateKeyPassword = { "changeit".toCharArray() }
) {
port = 8443
host = "0.0.0.0"
}
enableHttp3 {
quicTokenHandler = HmacQuicTokenHandler() // optional
quicMaxIdleTimeout = 30.seconds
quicInitialMaxData = 10_000_000
quicInitialMaxStreamDataBidirectionalLocal = 1_000_000
quicInitialMaxStreamDataBidirectionalRemote = 1_000_000
quicInitialMaxStreamsBidirectional = 100
udpSocketCount = 1
udpReceiveBufferSize = 0
udpSendBufferSize = 0
configureQuicServerCodec = { /* optional low-level Netty tuning */ }
}
}) { /* application */ }.start(wait = true)
enableHttp3 {} options
| Option | Type | Default | Purpose | Constraints / Notes |
|---|---|---|---|---|
quicTokenHandler |
QuicTokenHandler? |
null |
Enables QUIC Retry/address validation tokens | null = no Retry; setting adds handshake RTT but improves anti-spoofing protection |
quicMaxIdleTimeout |
Duration |
30.seconds |
Max idle time before closing QUIC connection | Must be > 0 |
quicInitialMaxData |
Long |
10_000_000 |
Connection-level flow-control window (bytes) | Must be > 0 |
quicInitialMaxStreamDataBidirectionalLocal |
Long |
1_000_000 |
Per-stream flow-control (locally initiated bidi streams) | Must be > 0 |
quicInitialMaxStreamDataBidirectionalRemote |
Long |
1_000_000 |
Per-stream flow-control (remotely initiated bidi streams) | Must be > 0 |
quicInitialMaxStreamsBidirectional |
Long |
100 |
Max concurrent bidirectional streams peer may open | Must be > 0 |
udpSocketCount |
Int? |
null |
Number of UDP sockets for HTTP/3 listener | Must be null or > 0; values > 1 require SO_REUSEPORT support |
udpReceiveBufferSize |
Int |
0 |
UDP SO_RCVBUF size |
0 = OS default; must be >= 0 |
udpSendBufferSize |
Int |
0 |
UDP SO_SNDBUF size |
0 = OS default; must be >= 0 |
configureQuicServerCodec |
QuicServerCodecBuilder.() -> Unit |
{} |
Advanced low-level Netty QUIC customization | Use with care, especially when using multiple UDP sockets |
Documentation for respondHtmlPartial
Description
We've replaced respondHtmlFragment with respondHtmlPartial. This is because there was a breaking change needed to support returning unbounded HTML elements. Now, respondHtmlFragment will be deprecated in favour of respondHtmlPartial.
Documentation for OpenAPI: No way to set tag description
Description
Previously, there was no way to set the description of a tag in the OpenAPI document builders. Now, it may be assigned in the top-level metadata.
Code example
Both the OpenAPI and Swagger plugins can assign tag descriptions from the plugin configuration:
swaggerUI("/swagger") {
info = OpenApiInfo("Books API from routes", "1.0.0")
tag(
name = "Books",
description = "Operations on books"
)
}
Documentation for WebRTC client on JVM
Please provide the following details so the technical writing team can begin work on this issue.
Description
Describe what this feature or change does and what problem it solves.
Code example
Provide a code example or link to a working sample (if applicable).
You can also link to a repo, PR, or snippet.
Migration guide
Does this change require a migration guide for existing users?
- [ ] Yes
- [ ] No
Related Links (Optional)
Add any relevant references:
- Blog posts
- Design or product docs (e.g., Quip, KEEP)
- PRs or related issues (other than the parent issue)
Once the description is complete, please re-assign this issue to a Technical Writer.
Documentation for Commonize HttpCache FileCacheStorage
Description
Before 3.6.0, HTTP cache file storage was only available for JVM targets. Now, with the help of the kotlinx-io library, you can use it on any platform.
Code example
Before:
val client = HttpClient(CIO) {
install(HttpCache) {
val cacheFile = Files.createDirectories(Paths.get("build/cache")).toFile()
publicStorage(FileStorage(cacheFile))
}
}
After:
val client = HttpClient(CIO) {
install(HttpCache) {
publicStorage(FileStorage(Path("build/cache")))
}
}
Documentation for Client curated multi-platform facade module
Description
We're introducing a new artifact ktor-client-engine-defaults to make it easier for multi-platform projects to import a client engine for every platform in their projects. They can simply add this to their common dependencies, and it will import a curated set of Ktor client engines.
Code example
In build.gradle.kts, include:
kotlin {
sourceSets {
commonMain {
dependencies {
api("io.ktor:ktor-client-engine-defaults:3.6.0-SNAPSHOT")
}
}
}
}
Then call HttpClient() from anywhere, and the appropriate engine will be supplied.
Migration guide
Some projects may be using the CIO client engine for multiplatform projects, but it is preferred to use this dependency, so that the best support is provided for each platform.
Related Links (Optional)
Documentation for ContentNegotiation: Add a way to prevent changing Accept and Content-Type headers
Description
The default behaviour of the client ContentNegotiation plugin is to always include registered content types in addition to any assigned in the request configuration. In some cases, it is better to override the registered content type, or perform some other method for consolidating the header values.
Code example
This snippet shows how to use the SkipIfPresent merge strategy, which will ignore the registered content types in the plugin config after Accept is assigned manually.
install(ContentNegotiation) {
register(ContentType.Application.Json, noOpJsonConverter)
acceptHeaderMergeStrategy = ContentTypeMergeStrategy.SkipIfPresent
}
Documentation for Add resource attribute for client resources plugin
Description
You can now access the "resource" used in a request via the RESOURCE client attribute when using the Resources client plugin. This is only relevant for instrumentation and plugin development, so maybe not needed for docs.
Code example
@Resource("path/{id}")
class PathWithOptionalQueryParameter(val id: Int, val query: String? = null)
@Test
fun `check template is added as attribute`() = testWithEngine(MockEngine) {
config {
install(Resources)
engine {
addHandler { request ->
respondOk()
}
}
}
test { client ->
val resource = PathWithOptionalQueryParameter(10, "clyde")
val clientWithAssertions = client.config {
install(
createClientPlugin("check resource attribute is set") {
onRequest { call, _ ->
assertSame(resource, call.attributes[RESOURCE])
}
}
)
}
val response = clientWithAssertions.get(resource)
assertEquals(response.status, HttpStatusCode.OK)
}
}
Documentation for Async CIO DNS resolver with timeout
Description
The CIO client engine would previously use JVM DNS resolution, which would always block threads. Now, it may be overridden.
Code example
See https://github.com/ktorio/ktor/pull/5577
We introduced a new field to CIOEngineConfig which allows for using custom DNS resolution.
public var dnsResolver: (suspend (hostname: String) -> List<String>)? = null
To use the new async DNS resolver, you can configure it like so:
HttpClient(CIO) {
engine {
dnsResolver = CioDnsResolver(server = "1.1.1.1", timeout = 3.seconds)
}
}
Documentation for Override fetch in JS engine
Description
Some JavaScript libraries, such as AWS WAF, require using an alternative method (eg, AwsWafIntegration.fetch) instead of the global fetch. To resolve this, we introduced a fetch variable to the JS engine configuration to override the default, global fetch.
Code example
val client = HttpClient(Js) {
engine {
fetch = { url, init -> Promise.reject(IllegalStateException("Networking not available")) }
}
}
Documentation for Make ApplicationCallPipeline.ApplicationPhase.Validators public in 3.6.0
Description
This enables rate limits based on auth (KTOR-8032) and fixes principal access when nesting rateLimit under authenticate (KTOR-9688): nest rateLimit inside authenticate so requestKey can use call.principal().
Docs to update:
- Custom server plugins — document
onCallValidators - Custom plugins - Base API — add
Validatorsto phases/mapping - Rate limiting — auth +
requestKey/ nesting - Authentication — combining with rate limit
- 3.6.0 WhatsNew/changelog — brief mention
Code example
install(Authentication) {
basic("auth") { validate { UserIdPrincipal(it.name) } }
}
install(RateLimit) {
register(RateLimitName("per-user")) {
rateLimiter(limit = 10, refillPeriod = 60.seconds)
requestKey { call.principal<UserIdPrincipal>()?.name ?: "anonymous" }
}
}
routing {
authenticate("auth") {
rateLimit(RateLimitName("per-user")) {
get("/api") { call.respondText("OK") }
}
}
}
Migration guide
Does this change require a migration guide for existing users?
- No
Related Links (Optional)
Fixes:
- KTOR-9688 RateLimit: No access to principal when wrapped with authentication since 3.5.1
- KTOR-8032 RateLimit: Allow limit requests based on authentication result
Documentation for Allow H2C and HTTP/2 on same server
Description
We updated H2C to allow it when HTTP/2 is enabled on the same server.
I think we can just remove this line:
Note that h2c requires enableHttp2 = true and cannot be used if an SSL connector is configured on the server.
Documentation for Ktor Cookie.parseClientCookiesHeader returns Map, which breaks Cookie header contract
Description
We introduced a new function for parsing the cookie header that allows for multiple values for each key.
Code Example
See https://github.com/fru1tworld/ktor/blob/fc7fc2dc7179da0ad1b642c9336fc32eb639c719/ktor-http/common/test/io/ktor/tests/http/ParseClientCookiesHeaderTest.kt
There is now a function for returning List<Pair<String, String>> instead of the former Map<String, String>.
Ktor Cookie.parseClientCookiesHeader returns Map, which breaks Cookie header contract
According to https://datatracker.ietf.org/doc/html/rfc6265#section-5.4, Cookie header might contain multiple cookies with the same name. On the other hand, Cookie.parseClientCookiesHeader returns a Map, which breaks that agreement. Cookie: name=value1; name=value2 would result in a single Cookie with value2 value.
OIDC Server Plugin
Allow H2C and HTTP/2 on same server
When using multiple connectors on an embedded server, it should be possible to allow H2C on the insecure port while still providing HTTP/2 on the secured port.
I ran into this problem when implementing the H2C benchmarks in HttpArena.
Make ApplicationCallPipeline.ApplicationPhase.Validators public in 3.6.0
To fix KTOR-9688, we introduced a new "validators" phase.
KTOR-9688 is a regression, and the fix should be published in a minor version that discourages the new public API.
Override fetch in JS engine
Some JavaScript libraries, such as AWS WAF, require using an alternative method (eg, AwsWafIntegration.fetch) instead of the global fetch. While it's possible to work around this for some libraries by monkey-patching fetch to include that call, trying to do this with AwsWafIntegration causes an infinite loop because their method calls the global fetch internally.
To work around this, we must implement a configuration item in the JS engine to allow us to inject a custom fetch function. All arguments are expected to be the same, but the function itself may be replaced.
Typesafe Authentication DSL
The core idea is to encode more information in auth schemes at the type level — principal type, role model, and an anonymous fallback — so the compiler enforces correctness across the entire auth pipeline.
Implement a typed provider DSL to replace the current one.
For more details, see https://github.com/ktorio/ktor-klip/pull/6
Auth: Non-optional principal is of nullable type
When working with (non-optional) authenticatethecall.principal` is always null, which requires:
- A redundant (and unnecessary) usage of
!!. Not allowed by some linters and considered bad practice in general. - early
?: return@get call.respond(HttpStatusCode.Unauthorized)in every route, which results in a lot of boilerplate but also cognitive overhead.
fun Application.userRoutes(users: UserRepository) = routing {
authenticate("google-jwt") {
route("user") {
get("/") {
val idToken = call.principal<GoogleIdToken>() ?: return@get call.respond(HttpStatusCode.Unauthorized)
val user = users.findOrNull(idToken.subject)
if(user != null) call.respond(HttpStatusCode.OK, user)
else call.respond(HttpStatusCode.NotFound)
}
}
}
}
When working with non-optional authentication, we should be able to get a non-null principal. Since this is impossible to achieve without writing your own DSLs, which currently most people have to resort to. Some examples:
Most (company private) DSLs I've seen implement this in the same style on a per-route basis, instead of on a Routing basis.
Async CIO DNS resolver with timeout
This issue was imported from GitHub issue: https://github.com/ktorio/ktor/issues/1678
Currently, we are doing DNS resolution through Java API that is always blocking. We can't control resolution timeout, can't configure servers and so on.
So we need to implement a CIO DNS resolver
Add resource attribute for client resources plugin
Add the resource used on client requests for easier instrumentation.
This can later be accessed via the request attributes under the RESOURCE key.
ContentNegotiation: Add a way to prevent changing Accept and Content-Type headers
I've got this HttpClient
httpClient {
install(Logging) {
this.level = LogLevel.ALL
}
install(ContentNegotiation) {
json(contentType = VendoContentType.VendoMobLocation /* ContentType("application", "x.db.vendo.mob.location.v3+json") */)
}
})
When I now make a POST Request with it like this (I have a custom function which just executes it with the builder)
post("mob", "location", "search") {
headers {
contentType(VendoContentType.VendoMobLocation.withoutParameters())
accept(VendoContentType.VendoMobLocation.withoutParameters())
}
setBody(LocationSearchRequest(searchTerm, locationTypes))
}
it changes the Content-Type and Accept Headers to
Content-Type: application/x.db.vendo.mob.location.v3+json; charset=UTF-8
Accept: application/x.db.vendo.mob.location.v3+json; application/x.db.vendo.mob.location.v3+json
which the API that I'm using doesn't accept. Am I doing something wrong, or is this a bug in Ktor?
OpenAPI: No way to set tag description
There is no way to set the tag description, not with KDoc, nor with the describe call. The tag method accepts only the name:
get("/test") {
call.respond(HttpStatusCode.OK)
}.describe {
tag("tag")
}
it seems the underlying implementation does not support it either, because the list of tags is represented as a mutable list of strings.
Use TagConsumer in respondHtmlFragment lambda receiver
We're currently using FlowContent as the receiver for the respondHtmlFragment function used in HTMX, but this can be quite limiting in terms of choices.
We should instead be using the TagConsumer interface here.
See slack thread https://kotlinlang.slack.com/archives/CKWA2MV8U/p1785055758371719
Authorization header removed from refresh token request
I am using the Ktor client Auth plugin for bearer authentication. For the refresh request inside refreshTokens, I need to include an Authorization header (Basic) containing client id and secret. However, even though the refresh token request is marked with markAsRefreshTokenRequest(), my Authorization header is removed by the Auth plugin.
This is a bug as oauth authentication often requires an Authorization header in the refresh token request, which should not be removed by Ktor.
Workaround:
sendWithoutRequest {
// TODO: Replace the path with your actual token refresh path
if (it.url.encodedPath == "my token refresh path") {
return@sendWithoutRequest false
}
true
}
Header seems to be removed here: https://github.com/ktorio/ktor/blob/ef92e5537d7465961f1b3f2b21e217fc35bcfa1f/ktor-client/ktor-client-plugins/ktor-client-auth/common/src/io/ktor/client/plugins/auth/providers/BearerAuthProvider.kt#L236
Issue originally reported here: https://youtrack.jetbrains.com/issue/KTOR-8107/Dont-send-Authorization-header-for-requests-marked-with-markAsRefreshTokenRequest#focus=Comments-27-11993094.0-0
Bad percentage encoding in URL query causes uncaught exception and 500 status
The Routing plugin does not handle requests with malformed percentage encoded characters in the URL query. For example assuming a route is configured for path /, then /?foo=%nope will cause an uncaught exception and a HTTP 500 response. I would have expected KTOR to handle this more gracefully and return either status 400 or 404.
Android: can hang when cancelling a streaming response during a blocking read
Problem
The Android client engine does not always cancel a streaming response promptly when the response-processing block throws.
The flaky test is: HttpStatementTest.testStreamingResponseExceptionInBodyCancelsImmediately
TeamCity history:
https://ktor.teamcity.com/test/8122890615480427451?currentProjectId=Ktor_ProjectKtorCore
The failure rate is ~7%. Failed attempts take approximately 60 seconds.
Reproducer
The server writes an initial response chunk and then waits for 60 seconds before writing another chunk.
withTimeout(2_000) {
client.prepareGet(streamingUrl).body<ByteReadChannel, Unit> {
throw IllegalStateException("Test exception from body block")
}
}
Expected behavior: the response is cancelled immediately and the original IllegalStateException is propagated.
Actual behavior: cancellation can remain blocked until the server writes the next response chunk.
Cause
Transforming the response to ByteReadChannel starts a copy coroutine that reads from the Android engine's HttpURLConnection input stream.
There is a race between:
- The copy coroutine entering the next blocking
InputStream.read(). - The response-processing block throwing and cancelling the call.
RawSourceChannel handles cancellation by closing the input stream. On Android, closing a HttpURLConnection input stream does not reliably interrupt a concurrent blocking read. Response cleanup then waits for the response job until the server sends the next chunk.
The Android engine does not currently call HttpURLConnection.disconnect() when the call context starts cancelling.
Proposed fix
Register HttpURLConnection.disconnect() as an onCancelling completion handler on the call context before creating the response body channel.
Disconnect only on exceptional completion so normally consumed responses remain eligible for connection reuse.
OpenAPI: "No mapping for symbol: VALUE_PARAMETER" exception when respondText receives a conditional ContentType within ApplicationCall extension function
Component: OpenAPI
Affects: Ktor 3.4.1, Kotlin 2.3.10
Possibly Related: KTOR-9305
Description:
The OpenAPI compiler plugin (ktor-compiler-plugin:3.4.1) with codeInferenceEnabled = true (the default) causes an internal compiler error when a Route extension function:
1. Captures many parameters (9+ in my case)
2. Registers nested route handlers that use local variables
The error occurs in the JVM codegen phase, not in the plugin's analysis phase — the plugin's IR transformation creates symbol references that the IrFrameMap doesn't have mappings for.
Error:
e: java.lang.RuntimeException: Exception while generating code for:
FUN name:myHandler visibility:public modality:FINAL returnType:kotlin.Unit
...
Caused by: java.lang.IllegalStateException: No mapping for symbol: VALUE_PARAMETER kind:ExtensionReceiver name:$this$get index:0 type:io.ktor.server.routing.RoutingContext
at org.jetbrains.kotlin.backend.jvm.codegen.IrFrameMap.typeOf(irCodegenUtils.kt:59)
In a larger codebase the same root cause manifests as:
No mapping for symbol: VAR name:contentType type:io.ktor.http.ContentType [val]
Minimal reproducer: https://github.com/elee/kotlin-playground './gradlew compileKotlin` to reproduce
build.gradle.kts:
plugins {
kotlin("jvm") version "2.3.10"
id("io.ktor.plugin") version "3.4.1"
}
ktor { openApi { enabled = true } }
application { mainClass.set("repro.ApplicationKt") }
repositories { mavenCentral() }
dependencies {
kotlinCompilerPluginClasspath("io.ktor:ktor-compiler-plugin:3.4.1")
implementation("io.ktor:ktor-server-core-jvm:3.4.1")
implementation("io.ktor:ktor-server-netty-jvm:3.4.1")
implementation("io.ktor:ktor-server-openapi:3.4.1")
implementation("io.ktor:ktor-server-resources:3.4.1")
}
src/main/kotlin/repro/Routing.kt:
package repro
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
interface SomeService { fun getData(): String }
fun Route.myHandler(a: SomeService, b: SomeService, c: SomeService,
d: SomeService, e: SomeService, f: SomeService,
g: SomeService, h: SomeService, i: SomeService) {
route("/items") {
get { call.respond(a.getData()) }
route("/{id}") {
get { call.respond(b.getData()) }
get("/log/{logName...}") {
val parts = call.parameters.getAll("logName")!!
if (parts.lastOrNull() == "pretty") {
val html = call.request.accept()?.contains("text/html") ?: false
call.respondText(c.getData(),
if (html) ContentType.Text.Html else ContentType.Text.Plain)
return@get
}
call.respondText("raw")
}
post("/cancel") { call.respond(d.getData()) }
post("/stop") { call.respond(e.getData()) }
get("/findings") { call.respond(f.getData()) }
post("/findings") { call.respond(g.getData() + call.receiveText()) }
get("/status") { call.respond(h.getData()) }
delete { call.respond(i.getData()) }
}
}
}
fun Application.configureRouting() {
routing {
route("/api") {
val svc = object : SomeService { override fun getData() = "x" }
myHandler(svc, svc, svc, svc, svc, svc, svc, svc, svc)
}
}
}
src/main/kotlin/repro/Application.kt:
package repro
import io.ktor.server.application.*
import io.ktor.server.netty.*
fun main(args: Array<String>): Unit = EngineMain.main(args)
fun Application.module() { configureRouting() }
Steps to reproduce:
./gradlew compileKotlin
Expected: Compilation succeeds.
Actual: IllegalStateException: No mapping for symbol in JVM codegen.
Workaround: Set codeInferenceEnabled = false in ktor { openApi {} }, but this disables all OpenAPI inference, producing an empty spec. We could also describe every single parameter and return type but this is not ideal.
Notes:
- The crash does NOT occur with fewer captured parameters (e.g. 3)
- The crash does NOT occur without the nested route structure
- The crash occurs in org.jetbrains.kotlin.backend.jvm.codegen.IrFrameMap.typeOf, suggesting the compiler plugin's IR transformations introduce variable references that aren't registered
in the JVM codegen's frame map
- This appears to be a variant of KTOR-9305 which was fixed for FOR_LOOP_VARIABLE but the underlying issue remains for VALUE_PARAMETER and regular VAR symbols in the same scenario
OpenAPI: "No mapping for symbol: VAR name" exception when code inference is on
compileKotlin fails when code inference is enabled for the following snippet, even though the code itself works correctly.
import io.ktor.http.*
import io.ktor.openapi.*
import io.ktor.server.application.*
import io.ktor.server.netty.*
import io.ktor.server.plugins.openapi.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.routing.openapi.*
fun main(args: Array<String>) {
EngineMain.main(args)
}
fun Application.module(
) {
configureRouting()
}
fun Application.configureRouting() {
routing {
openAPI(path = "/openapi") {
info = OpenApiInfo("My API", "1.0")
source = OpenApiDocSource.Routing {
routingRoot.descendants()
}
}
get("/test/{user-id}") {
val userId = call.parameters["user-id"] ?: ""
call.respondWithStatus(userId)
}
}
}
suspend inline fun ApplicationCall.respondWithStatus(result: String) {
respond(result.toHttp(), result)
}
fun String.toHttp() = when {
this.isBlank() -> HttpStatusCode.NotFound
else -> HttpStatusCode.OK
}
build.gradle
plugins {
kotlin("jvm") version "2.3.0"
id("io.ktor.plugin") version "3.4.1"
}
group = "org.example"
version = "1.0-SNAPSHOT"
repositories {
mavenCentral()
}
application {
mainClass = "io.ktor.server.netty.EngineMain"
}
ktor {
openApi {
enabled = true
codeInferenceEnabled = true
onlyCommented = false
}
}
dependencies {
implementation("io.ktor:ktor-server-core")
implementation("io.ktor:ktor-server-netty")
implementation("io.ktor:ktor-server-openapi")
implementation("io.ktor:ktor-server-auth")
implementation("io.ktor:ktor-server-auth-jwt")
implementation("io.ktor:ktor-server-routing-openapi")
}
kotlin {
jvmToolchain(21)
}
OpenAPI: "IllegalStateException: No mapping for symbol" when code inference and passing received body to HttpStatusCode()
Subsystem: Server
Type: Bug
Affected versions: 3.4.0, 3.4.1, 3.4.2
Description:
KTOR-9305 fixed the FOR_LOOP_VARIABLE variant of this crash in 3.4.1, but the same root cause (No mapping for symbol in IrFrameMap.typeOf) still triggers for local variables in route handler lambdas.
The code inference compiler plugin incorrectly captures a local variable from a post-lambda body into the enclosing route lambda's invokedynamic closure. The crash requires all three of these elements together – removing any one avoids it:
1. Property delegates (e.g. Kodein by di.instance()) in the outer Route function scope
2. authenticate { route { post { ... }.describe { } } } lambda nesting
3. ?: return@post call.someExtensionFn(...) - an elvis operator with a labeled return that calls a suspend extension function on ApplicationCall with default parameters
Important: The Gradle daemon masks this crash by caching compiled outputs. You must build with:
./gradlew clean compileKotlin --no-daemon --no-build-cache
Reproduction:
// build.gradle.kts
plugins {
id("io.ktor.plugin")
kotlin("plugin.serialization")
}
ktor {
openApi {
enabled = true
codeInferenceEnabled = true
}
}
// + ktor-server-auth dependency for authenticate {}
// + kodein-di dependency for DI property delegates
@file:OptIn(io.ktor.utils.io.ExperimentalKtorApi::class)
package com.example
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.auth.authenticate
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.openapi.describe
import io.ktor.server.routing.post
import io.ktor.server.routing.route
import kotlinx.serialization.Serializable
import org.kodein.di.DI
import org.kodein.di.instance
interface MyApiError {
val status: HttpStatusCode
val message: String
}
data class NotFoundError(val id: String) : MyApiError {
override val status = HttpStatusCode.NotFound
override val message = "Not found: $id"
}
interface MyService {
fun lookup(id: String): String?
}
@Serializable
data class MyRequest(val model: String)
// Suspend extension function with a default parameter — this is the
// function whose call site inside `?: return@post` triggers the crash.
suspend fun ApplicationCall.respondError(
error: MyApiError,
message: String? = null,
) {
respond(status = error.status, message = message ?: error.message)
}
fun Route.example(di: DI) {
val svcA: MyService by di.instance()
val svcB: String by di.instance()
val svcC: String by di.instance(tag = "c")
val svcD: Long by di.instance()
val svcE: Double by di.instance()
val svcF: Boolean by di.instance()
val svcG: Int by di.instance()
authenticate("test") {
route("example") {
post("cost") {
val request = call.receive<MyRequest>()
val model = svcA.lookup(request.model)
?: return@post call.respondError(NotFoundError(request.model))
call.respond(HttpStatusCode.OK, model)
}.describe {
summary = "Cost"
}
}
}
}
Expected: Compilation succeeds.
Actual: Internal compiler error:
java.lang.IllegalStateException: No mapping for symbol:
AR name:request type:com.example.MyRequest [val]
at ...IrFrameMap.typeOf(irCodegenUtils.kt:59)
Environment: Kotlin 2.3.20, Ktor 3.4.2, Gradle 9.3.1
Workaround: codeInferenceEnabled = false
Multipart is not supported on non-JVM server targets
call.receiveMultipart() fails on non-JVM server targets (Native, JS/Wasm) with:
io.ktor.server.plugins.CannotTransformContentToTypeException:
Cannot transform this request's content to io.ktor.http.content.MultiPartData
Reported on GitHub: https://github.com/ktorio/ktor/issues/5694 (linuxX64, reproduced on 3.0.3 / 3.4.0 / 3.5.0)
Root cause
The default receive transformation delegates multipart handling to an expect function that has no working non-JVM implementation:
ktor-server-core/common/src/io/ktor/server/engine/DefaultTransform.kthas noMultiPartDatabranch; it falls through todefaultPlatformTransformations.- The JVM actual (
DefaultTransformJvm.kt) handlesMultiPartData::class -> multiPartData(channel). - The nonJvm actual (
DefaultTransform.nonJvm.kt) returnsnull, so the body stays aByteReadChannelandPipelineCallthrowsCannotTransformContentToTypeException.
This is a server-core wiring gap, not an engine issue: the request Content-Type is parsed correctly and call.receiveChannel() works.
Second symptom (same root cause)
call.receiveParameters() with a multipart/form-data body hits the common Parameters::class branch, which calls multiPartData(channel) directly. The nonJvm actual is error("Multipart is not supported on non JVM platforms"), so this fails with IllegalStateException instead of the exception above.
Why the stub is stale
0de7948fb(KTOR-746, Native CIO server, 2021) — the CIO multipart parser lived inktor-http-cio/jvm/only, so a non-JVM stub was unavoidable.1115fae6f(JS/Wasm server commonization, 2024-08) — parser still JVM-only, the stub was copied tojsAndWasmShared.4933074bb(KTOR-6632, "Support receiving multipart data with Ktor client", shipped in 3.1.0) —CIOMultipartDataBaseandparseMultipartwere moved toktor-http-cio/common/, with onlydiscardBlocking()left as expect/actual. From this point the parser works on all targets, and the client uses it fromcommonMain. The server side was not re-wired.d0d6a7e83(nonJvm source-set cleanup, 2025) — the stub was relocated tononJvmwithout re-checking whether it was still needed.
The workaround in the GitHub issue (constructing CIOMultipartDataBase by hand on Native) confirms the parser itself works off-JVM.
Suggested fix
Move the JVM multiPartData implementation into commonMain and add a MultiPartData::class branch to the common when. No new dependency is needed (ktor-server-core already has api(projects.ktorHttpCio) in commonMain) and no public API changes (everything involved is internal).
OpenAPI: generated schema for recursive types uses invalid schemaRef with ReflectionJsonSchemaInference
Description
When using ReflectionJsonSchemaInference to generate OpenAPI specification for recursive types, the inner schema reference for the property uses a fully qualified class name, while the actual schema name is shortened to the simple type name.
Steps to reproduce
import com.fasterxml.jackson.databind.ObjectMapper
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.openapi.OpenApiInfo
import io.ktor.openapi.reflect.ReflectionJsonSchemaInference
import io.ktor.serialization.jackson.jackson
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.plugins.swagger.swaggerUI
import io.ktor.server.response.respond
import io.ktor.server.routing.get
import io.ktor.server.routing.openapi.OpenApiDocSource
import io.ktor.server.testing.testApplication
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class RecursiveTypeSchemaReferenceTest {
data class Value(val name: String, val children: List<Value>)
@Test
fun `refs in openapi schema are correct for recursive types`() = testApplication {
install(ContentNegotiation) {
jackson()
}
routing {
swaggerUI("/swagger-internal") {
info = OpenApiInfo("Shapes API", "1.0")
source = OpenApiDocSource.Routing(
contentType = ContentType.Application.Json,
schemaInference = ReflectionJsonSchemaInference.Default,
)
remotePath = "documentation.json"
}
get("/myValue") {
call.respond(Value("test", emptyList()))
}
}
val specResponse = client.get("/swagger-internal/documentation.json").bodyAsText()
val spec = ObjectMapper().readTree(specResponse)
val childrenItemsRef = spec
.path("components")
.path("schemas")
.path("Value")
.path("properties")
.path("children")
.path("items")
.path("\$ref")
.asText()
assertEquals("#/components/schemas/Value", childrenItemsRef)
}
}
Expected behavior
An OpenAPI specification with the following components:
"components": {
"schemas": {
"Value": {
"type": "object",
"title": "Value",
"required": [
"children",
"name"
],
"properties": {
"children": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Value"
}
},
"name": {
"type": "string"
}
}
}
}
}
Actual behavior
"components": {
"schemas": {
"Value": {
"type": "object",
"title": "Value",
"required": [
"children",
"name"
],
"properties": {
"children": {
"type": "array",
"items": {
"$ref": "#/components/schemas/no.nav.pensjon.brev.skribenten.openapi.RecursiveTypeSchemaReferenceTest.Value"
}
},
"name": {
"type": "string"
}
}
}
}
}
OpenAPI: Support the "const" validation keyword
const is a valid Schema Object keyword in OpenAPI 3.1 because OpenAPI 3.1 incorporates JSON Schema Draft 2020-12.
Ktor’s OpenAPI schema model does not expose a const property or a corresponding @JsonSchema.Const annotation, so code-first generation cannot emit this keyword:
This isn't the end of the world, but it's a regression from our handwritten schemas.
DefaultConversionService: Support Uuid type introduced in Kotlin 2.0
Kotlin 2.0.20 has introduced Uuid support in the standard library.
In addition, kotlinx.serialization also has been updated in version 1.7.2 to handle the Uuid type.
Issue:
When using the Uuid type to retrieve a parameter from a call the next error happens
Request parameter id couldn't be parsed/converted to Uuid Caused by: Type class kotlin.uuid.Uuid is not supported in default data conversion service
Sample:
fun Route.exampleRoute() {
get("v1/route/{id}") {
val id: Uuid = call.parameters.getOrFail<Uuid>("id")
...
}
}
Incorrect warning about streaming compression buffering
When using compression with a response that uses OutgoingContent.WriteChannelContent, the following message is logged:
2026-06-25 13:25:39,125 [eventLoopGroupProxy-4-] WARN io.ktor.server.plugins.compression.Compression - Compressing a WriteChannelContent response for [api route here]. Compression will buffer the entire body before sending, which defeats the purpose of streaming. Consider suppressing compression for this route with call.suppressCompression().
there are a couple of issues with this
- this log message is present for every request that is made, rather than just a single time, which can be quite expensive.
- this warning is just incorrect.
on top of the code that logs this warning, the following comment is present:
// Most compression algorithms (e.g. gzip, deflate, brotli) cannot compress streaming responses
// incrementally without buffering the entire body, which defeats the purpose of streaming.
this is just factually incorrect.
although all of the compression algorithms do need to buffer content in order to compress it, it is still streamed.
i.e. it does not need to buffer the entire response in order to compress it. there is not a single compression algorithm that does this.
the following code snippet is from Deflater.kt. this is the code path that anything using WriteChannelContent would hit if it were using gzip or deflate compression.
private suspend fun ByteReadChannel.deflateTo(
destination: ByteWriteChannel,
gzip: Boolean = true,
pool: ObjectPool<ByteBuffer> = KtorDefaultPool
) {
val crc = CRC32()
val deflater = Deflater(Deflater.DEFAULT_COMPRESSION, true)
val input = pool.borrow()
val compressed = pool.borrow()
try {
if (gzip) {
destination.putGzipHeader()
}
while (!isClosedForRead) {
input.clear()
if (readAvailable(input) <= 0) continue
input.flip()
crc.updateKeepPosition(input)
deflater.setInputBuffer(input)
destination.deflateWhile(deflater, compressed) { !deflater.needsInput() }
}
closedCause?.let { throw it }
deflater.finish()
destination.deflateWhile(deflater, compressed) { !deflater.finished() }
if (gzip) {
destination.putGzipTrailer(crc, deflater)
}
} finally {
deflater.end()
pool.recycle(input)
pool.recycle(compressed)
}
}
as you can see, it streams the compressed response using a temporary byte buffer from a pool
HttpCache add method for clearing
See GH issue: https://github.com/ktorio/ktor/issues/4719
Description
We are experiencing an issue where the cache seems to persist even after deleting the cache storage directories. This issue occurs when using the HttpCache plugin in Ktor. Despite deleting the cache directories and confirming that they are empty, cached responses are still being returned.
We need a way to clear the private storage cache, including any in-memory cache, when the user logs out in our Android app. This should ensure that no cached responses are returned after the cache has been cleared.
Steps to Reproduce
- Set up
HttpCachein Ktor with both public and private storage. - Make a request that gets cached.
- Delete the cache directories.
- Make the same request again.
Expected Behavior
The request should not return a cached response after the cache storage directories have been deleted.
Actual Behavior
The request still returns a cached response even after the cache storage directories have been deleted.
Additional Context
- The
HttpCacheplugin might be using an in-memory cache in addition to the file storage. - Tested with Ktor 3.0.3 in our Android app.
Netty HTTP/3: the listener can only ever serve ONE QUIC connection — every subsequent handshake times out
Affected versions
Unreleased: main (3.6.0-SNAPSHOT), commit fc6595632e7412abb98b941f926d0ea13c7647d1.
HTTP/3 support introduced in PR ktorio/ktor#5527.
Problem
With enableHttp3(), the first QUIC connection to the server works; every QUIC connection
after it — concurrent or after the first one closed — never completes its handshake and
times out. In practice the HTTP/3 listener serves exactly one browser/client per process
lifetime.
Observed with a Netty h3 client against a freshly started server (5s connect timeout):
[probe] conn1 (fresh listener): connected in 821ms
[probe] conn1: GET /i -> ok
[probe] conn2 (while conn1 still open): CONNECT TIMED OUT after 5s
[probe] conn1 closed
[probe] conn3 (after conn1 closed): CONNECT TIMED OUT after 5s
Root cause
NettyHttp3ChannelInitializer.kt:49:
.handler(Http3ServerConnectionHandler(streamInitializer))
A single Http3ServerConnectionHandler instance is passed to
QuicServerCodecBuilder.handler(...), which installs it into the pipeline of every
incoming QuicChannel. But Http3ServerConnectionHandler is explicitly non-sharable: its
superclass Http3ConnectionHandler overrides isSharable() to always return false —
"Always returns false as it keeps state"
— since it owns the connection's control-stream and QPACK state. Netty therefore rejects
adding the same instance to a second pipeline (ChannelPipelineException: ... is not a @Sharable handler, so can't be added or removed multiple times), the second connection's
initialization fails, and its handshake never completes. Netty's own HTTP/3 server example
wraps the connection handler in a ChannelInitializer<QuicChannel> that creates a new
instance per connection.
(Interestingly, the per-STREAM level got this right — NettyHttp3RequestStreamInitializer
creates a new NettyHttp3Handler per stream, its KDoc even noting request stream handlers
are not sharable. The connection level was missed.)
Fix
A PR with this change and a regression test follows.
.handler(object : ChannelInitializer<QuicChannel>() {
override fun initChannel(ch: QuicChannel) {
ch.pipeline().addLast(Http3ServerConnectionHandler(streamInitializer))
}
})
streamInitializer (a ChannelInitializer) is sharable by design and can stay shared.
After the fix, the same probe:
[probe] conn1 (fresh listener): connected in 16ms
[probe] conn1: GET /i -> ok
[probe] conn2 (while conn1 still open): connected in 15ms
[probe] conn2: GET /i -> ok
[probe] conn1 closed
[probe] conn3 (after conn1 closed): connected in 26ms
[probe] conn3: GET /i -> ok
Regression test attached: NettyHttp3MultipleConnectionsTest (drop into
ktor-server/ktor-server-netty/jvm/test/io/ktor/tests/server/netty/) — it opens a second
connection while the first is still open, and another one after the first has closed, and
asserts that both connect and serve requests. Fails on current main, passes with the fix.
Duplicate code in ByteReadChannel
We have:
public suspend fun ByteReadChannel.readRemaining(): Source
and
public suspend fun ByteReadChannel.readBuffer(): Buffer
Which are pretty much the exact same.
We should pick one and deprecate the other.
OpenAPI Contextual JSON schema inference support
As a developer, I should be able to use contextual serializers and different serializer modules in my routes, so that when calling jsonSchema<T>(), the correct model is generated.
Misleading log output for Unix Domain Socket servers: "Responding at unix://0.0.0.0:80"
When Ktor is configured to listen on a Unix Domain Socket, the log output shows:
INFO io.ktor.server.Application - Responding at unix://0.0.0.0:80
This is misleading and potentially alarming — a security engineer seeing 0.0.0.0:80 in logs would think the application is listening on all network interfaces on port 80, which is a serious concern. The actual socket path (e.g., /run/user/1000/jetbrainsd-...) is not shown.
Expected behavior: The log should display the actual Unix socket path, e.g.:
Responding at unix:///run/user/1000/jetbrainsd-a8ee6ee1c4c661f130fda083388d2b5d
Additionally, a minor improvement: the startup time message shows Application started in 0.0 seconds which loses precision for fast-starting applications (e.g., native images). It would be better to show milliseconds:
Application started in 2 ms.
Full log excerpt from JetBrains Daemon (Ktor on native image, UDS):
2026-03-02 10:22:50.816 INFO io.ktor.server.Application - Autoreload is disabled because the development mode is off.
2026-03-02 10:22:50.816 INFO io.ktor.server.Application - Application started in 0.0 seconds.
2026-03-02 10:22:50.821 INFO io.ktor.server.Application - Responding at unix://0.0.0.0:80
CIO: The engine doesn't encode Unicode symbols like '–' (U+2013) in request URL
What's goin' on babe?
When using Ktor's HTTP client with the CIO engine on both Android and JVM, accessing URLs containing the –, (Unicode U+2013, not the regular hyphen -) results in an empty ByteArray (size= 0).
Example URLs:
https://cosplaytele.com/wp-content/uploads/2024/09/Jiu-Yan-cosplay-2B-–-NierAutomata-43_result.web
https://cosplaytele.com/wp-content/uploads/2023/06/taopaipu-cosplay-Sora-Kasugano-–-Yosuga-no-Sora-47_result-scaled.webp
Example code:
val bytes = httpClient.get(url).bodyAsBytes()
println(bytes.size) // -> 0 when using CIO
Notes:
- This is not related to internal URL parsing:
url.takeFrom(urlString)handles the–character correctly. - Switching the engine to OkHttp works fine — the same URLs return the expected file data.
- The URLs are also accessible directly via browsers and tools like Reqable without issues.
Expected behavior:
CIO engine should correctly retrieve non-empty ByteArray for URLs containing – in the path, just like OkHttp and browsers do.
Environment:
- Ktor version: 3.1.3
- Engine: CIO (Android & JVM)
- Kotlin version: 2.1.21
- Platform: Android & JVM
- Java: Android 15, 16 / JDK17, 21
Log which ConfigLoader has been used for loading the server configuration
I generated a FatJar using the ./gradlew buildFatJar command. However when I'm trying to run the .jar file under the "build/libs/" folder I get the following error:
Exception in thread "main" java.lang.IllegalArgumentException: Neither port nor sslPort specified. Use command line options -port/-sslPort or configure connectors in application.conf
Even if I link the application.conf file (as described here: ktor-dokumentation ) with the command: java -jar app.jar -config=application.conf the issue remains.
(From the indellij idea I can run the program without issue.)
CharsetDecoder.decode() ignores the max argument on JVM
Description of the issue:
The following function takes a max argument that is supposed to be used to limit the size of the returned string, but it completely ignores that parameter (in the JVM implementation at least).
public fun CharsetDecoder.decode(input: Source, max: Int = Int.MAX_VALUE)
Code:
In CharsetJVM.kt the implementation is as follows. You can see that max is never used.
// Please attach the problematic code below
public actual fun CharsetDecoder.decode(input: Source, dst: Appendable, max: Int): Int {
if (charset == Charsets.UTF_8) {
return input.readString().also { dst.append(it) }.length
}
val result = input.remaining
dst.append(input.readByteString().decodeToString(charset))
return result.toInt()
}
Commonize HttpCache FileCacheStorage
Our current implementation for FileCacheStorage is only available for JVM, but with kotlinx-io, there's no reason we can't implement it for all platforms.
WebRTC client on JVM
It's great to see Ktor get support for WebRTC. However, I'd really like it to extend to the JVM, too.
Escape `$` in application.yaml file
I couldn’t find a way to escape the dollar sign within the application.yaml file. The hard-coded assumption that the part directly after the $ refers to an environment variable makes it impossible to store, for example, JsonPath strings there.
The documentation doesn’t mention how to escape the $ and my attempts with \$, $$, etc. where fruitless.
Unescape quoted-pair at the end of a header parameter value
From the https://github.com/ktorio/ktor/pull/5817 PR description:
parseHeaderValue unescaped a backslash in a quoted parameter value only when it sat more than two characters from the end of the header, so an escaped character in the tail kept its backslash. A header Ktor renders itself then does not survive a round trip:
val rendered = ContentDisposition.File.withParameter("filename", "a\"").toString()
// file; filename="a\""
parseHeaderValue(rendered) // filename=a\" (expected a")
CIO: HttpRequestLifecycle cancels `Connection: close` call too soon, sending an incomplete response
Ktor CIO with HttpRequestLifecycle { cancelCallOnClose = true } cancels an in-flight handler when the request carries a Connection: close header, so the server closes the socket with zero response bytes while the still-connected client is blocked reading the response. Reproduced with Ktor 3.4.1 and 3.5.1, JVM.
Per RFC 9112 §9.6 a server that receives Connection: close MUST send the final response before initiating closure — the close connection option is a persistence signal about what happens after the exchange completes, not licence to abort the in-flight request. A one-shot client that sends Connection: close, keeps the socket open, and reads to EOF must observe a complete HTTP response.
package ktor.repro
import io.ktor.server.application.install
import io.ktor.server.cio.CIO
import io.ktor.server.engine.embeddedServer
import io.ktor.server.http.HttpRequestLifecycle
import io.ktor.server.response.respondText
import io.ktor.server.routing.post
import io.ktor.server.routing.routing
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import java.io.OutputStream
import java.net.Socket
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds
/**
* Mechanism (CIO):
* - The per-connection parse loop in `ServerPipeline.kt` `break`s on a `Connection: close` request as soon as the request body is
* read, and its `finally` block then invokes `handlerScope.onClose` — before the response is written, while the handler coroutine
* may still be suspended.
* - `CIOApplicationEngine.setCloseHandler` wires `onClose` to the call attribute installed by `HttpRequestLifecycle`'s `CallSetup`
* hook, which with `cancelCallOnClose = true` does `call.coroutineContext.cancel(...)`.
* - The cancelled handler closes its response `ByteChannel` with the cancellation cause, the writer loop copies 0 bytes, and the
* connection output closes — the client observes a clean EOF with an empty (or truncated) HTTP response.
*
* The callback registration itself races the parse loop: `setCloseHandler` and the `CallSetup` hook run on the user dispatcher,
* while the parse loop reaches its `finally` on the engine side almost immediately for a request that arrives in one piece — then
* `onClose` is still unset and the call completes normally. That makes the bug appear only under load in real deployments. The
* first test pins the race deterministically by withholding the request body until the handler has started: the parse loop is
* parked in `parseHttpBody` until after registration, so on body arrival it breaks and cancels the suspended handler every time.
*
* The second test shows the plugin's intended behavior works: a real client disconnect cancels the suspended handler. For
* contrast, the Netty engine passes both tests on 3.4.1 and 3.5.1 — it invokes the close callback only from `channelInactive`
* (a real disconnect), never for a `Connection: close` request.
*/
class ConnectionCloseCancellationTest {
@Test
fun `Connection close request receives a complete response from a suspending handler`() = runBlocking<Unit> {
val handlerStarted = CompletableDeferred<Unit>()
val handlerOutcome = CompletableDeferred<String>()
val server = embeddedServer(CIO, port = 0, host = "127.0.0.1") {
install(HttpRequestLifecycle) {
cancelCallOnClose = true
}
routing {
post("/slow") {
handlerStarted.complete(Unit)
try {
delay(3.seconds) // a handler that suspends before responding, e.g. awaiting some external state
call.respondText("pong")
handlerOutcome.complete("completed")
} catch (cause: CancellationException) {
handlerOutcome.complete("cancelled")
throw cause
}
}
}
}
server.startSuspend(wait = false)
try {
val port = server.engine.resolvedConnectors().single().port
val response = Socket("127.0.0.1", port).use { socket ->
socket.soTimeout = 30_000
socket.getOutputStream().sendRequestWithheldBody(connectionClose = true) {
handlerStarted.await()
}
// one-shot HTTP/1.1 client: read until EOF; the socket stays fully open meanwhile (no half-close)
socket.getInputStream().readBytes().decodeToString()
}
val outcome = withTimeoutOrNull(5.seconds) { handlerOutcome.await() } ?: "still suspended"
assertTrue(
response.contains("\r\n\r\n"),
"Malformed HTTP response: missing header terminator; " +
"server closed after ${response.length} bytes (handler outcome: $outcome): \"$response\"",
)
assertTrue(response.startsWith("HTTP/1.1 200"), "Expected a 200 response (handler outcome: $outcome), got: \"$response\"")
assertTrue(response.endsWith("pong"), "Expected the full body (handler outcome: $outcome), got: \"$response\"")
assertEquals("completed", outcome, "Full response received, so the handler must have run to completion")
} finally {
server.stopSuspend(100, 1000)
}
}
@Test
fun `client disconnect cancels a suspended handler`() = runBlocking<Unit> {
val handlerStarted = CompletableDeferred<Unit>()
val handlerOutcome = CompletableDeferred<String>()
val server = embeddedServer(CIO, port = 0, host = "127.0.0.1") {
install(HttpRequestLifecycle) {
cancelCallOnClose = true
}
routing {
post("/slow") {
handlerStarted.complete(Unit)
try {
delay(30.seconds) // long enough that only cancellation can complete the outcome within the await window below
handlerOutcome.complete("completed")
call.respondText("pong")
} catch (cause: CancellationException) {
handlerOutcome.complete("cancelled")
throw cause
}
}
}
}
server.startSuspend(wait = false)
try {
val port = server.engine.resolvedConnectors().single().port
Socket("127.0.0.1", port).use { socket ->
// keep-alive request: the disconnect below must be the only close signal the engine sees
socket.getOutputStream().sendRequestWithheldBody(connectionClose = false) {
handlerStarted.await()
}
} // socket close = real client disconnect while the handler is suspended
val outcome = withTimeoutOrNull(10.seconds) { handlerOutcome.await() } ?: "still suspended after 10s"
assertEquals(
"cancelled",
outcome,
"cancelCallOnClose = true must cancel the suspended handler on a real client disconnect",
)
} finally {
server.stopSuspend(100, 1000)
}
}
/** Sends a POST whose body is withheld until [beforeBody] returns, pinning the engine in its request-body read. */
private suspend fun OutputStream.sendRequestWithheldBody(connectionClose: Boolean, beforeBody: suspend () -> Unit) {
val body = """{"key":"value"}""".toByteArray()
write(
(
"POST /slow HTTP/1.1\r\n" +
"Host: localhost\r\n" +
"Content-Type: application/json\r\n" +
"Content-Length: ${body.size}\r\n" +
(if (connectionClose) "Connection: close\r\n" else "") +
"\r\n"
).toByteArray()
)
flush()
beforeBody()
write(body)
flush()
}
}
dependencies {
implementation("io.ktor:ktor-server-core:3.4.1") // or 3.5.1
implementation("io.ktor:ktor-server-cio:3.4.1") // or 3.5.1
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.14.1")
}
CIO: mapToKtor unwraps non-timeout exceptions to their cause
Problem
On JVM, CIO's Throwable.mapToKtor() unintentionally replaces every non-timeout exception that has a cause with its immediate cause:
internal actual fun Throwable.mapToKtor(request: HttpRequestData): Throwable = when (cause?.rootCause) {
is java.net.SocketTimeoutException -> SocketTimeoutException(request, cause)
else -> cause
} ?: this
For example, mapping ClosedReadChannelException(EOFException(...)) returns the bare EOFException. This discards the contextual exception added by CIO and can expose a sole EOF to users.
This is a possible cause of the renewed reports in KTOR-8205, where java.io.EOFException: Failed to parse HTTP response: the server prematurely closed the connection is still observed after the contextual wrapper was introduced.
Cause
The behavior appears to have been introduced accidentally in commit 9785a7a82 while extracting JVM exception mapping for native-mt support.
Before extraction, the mapping was inside catch (cause: Throwable):
when (cause.rootCause) {
is java.net.SocketTimeoutException -> SocketTimeoutException(request, cause)
else -> cause
}
After moving it to a Throwable extension, cause changed meaning from the caught local variable to Throwable.cause, introducing one-level unwrapping. The native implementation preserves the original throwable.
Expected behavior
mapToKtor() should only normalize JVM socket timeouts. Other exceptions should be preserved:
internal actual fun Throwable.mapToKtor(request: HttpRequestData): Throwable =
when (rootCause) {
is java.net.SocketTimeoutException -> SocketTimeoutException(request, this)
else -> this
}
Regression coverage should verify both that:
- a non-timeout exception with a cause is returned unchanged;
- a root
java.net.SocketTimeoutExceptionis still mapped to Ktor's request-awareSocketTimeoutException.
Make quality parameter case-insensitive
According to RFC 9110, section 12.4.2, the q parameter is case-insensitive.
Pull request: https://github.com/ktorio/ktor/pull/5794
OkHttp: Cancelling a request can close the response body on the Android main thread
Problem
Cancelling an HTTP request handled by the OkHttp engine can close the response body on the thread that initiated cancellation.
The response body is currently released from a job completion handler:
callContext.job.invokeOnCompletion { body.close() }
Coroutine completion handlers execute on the thread completing the job. When an Android application cancels the request from the main thread, ResponseBody.close() can perform blocking TLS/socket I/O there and throw NetworkOnMainThreadException.
Relevant stack trace:
Caused by: android.os.NetworkOnMainThreadException
at android.os.StrictMode$AndroidBlockGuardPolicy.onNetwork(StrictMode.java:1769)
at com.android.org.conscrypt.ConscryptEngineSocket.close(ConscryptEngineSocket.java:566)
at okhttp3.internal.connection.RealCall.callDone(RealCall.kt:418)
at okhttp3.internal.connection.Exchange$ResponseBodySource.close(Exchange.kt:379)
at okio.RealBufferedSource.close(RealBufferedSource.kt:493)
at okhttp3.ResponseBody.close(ResponseBody.kt:195)
at io.ktor.client.engine.okhttp.OkHttpEngine.executeHttpRequest$lambda$0(OkHttpEngine.java:110)
at kotlinx.coroutines.JobSupport.notifyCompletion(JobSupport.kt:1624)
at kotlinx.coroutines.JobSupport.cancel(JobSupport.kt:648)
Expected behavior
Cancelling a request must not perform blocking response-body cleanup on the cancelling thread. The cleanup should run on the engine dispatcher and remain owned by the engine lifecycle.
References
- Original GitHub report: https://github.com/ktorio/ktor/issues/5792
- Proposed fix: https://github.com/ktorio/ktor/pull/5793
CIO: "Failed to parse HTTP response: unexpected EOF" when pipelining is on
We found out there is unexpected EOF error with jvm ByteChannel in ktor 2.3.*, when we enable pipelining of engine
I can reproduce the error by below code by calling GET http://btse.localhost:8080/test/1, but in 2.2.4 is working fine. please check.
What we expect we should not have any error when calling the API with jackson.
// my service level
class Test2Service : KoinComponent {
val testClient = HttpClient(CIO) {
install(ContentNegotiation) {
jackson()
}
engine {
pipelining = true
}
}
fun test2(input: String): TestData {
return TestData(input)
}
@OptIn(InternalAPI::class)
suspend fun test3(input: Int): String {
return testClient.get("http://localhost:8080/hi/$input")
.content.readUTF8Line(8192).toString()
}
}
// my routing level
fun Application.configureRouting() {
routing {
get("/hi/{random}") {
call.response.header("Content-Type", "application/json")
val random = call.parameters["random"] ?: "test"
call.respond(Test2Service().test2(random))
}
get("/test/{number}") {
call.response.header("Connection", "keep-alive")
val number = call.parameters["number"]?.toIntOrNull() ?: 1
call.respond(Test2Service().test3(number))
}
}
}
// application server
fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
startKoin {
install(ContentNegotiation) {
jackson()
}
}
configureRouting()
}.start(wait = true)
}
Error response (2.3.*)
java.io.EOFException: Failed to parse HTTP response: unexpected EOF
at io.ktor.client.engine.cio.ConnectionPipeline$responseHandler$1.invokeSuspend(ConnectionPipeline.kt:77)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
at kotlinx.coroutines.internal.LimitedDispatcher$Worker.run(LimitedDispatcher.kt:115)
at kotlinx.coroutines.scheduling.TaskImpl.run(Tasks.kt:100)
at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:584)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:793)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:697)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:684)
Successful in 2.2.4
Curl: Requests leak curl_slist and response StableRef
The Curl client engine does not release all per-request resources.
Actual behavior
- The
StableRef<CurlResponseBuilder>passed throughCURLOPT_HEADERDATAandCURLOPT_PRIVATEis never disposed. It keepsCurlResponseBuilder,CurlRequestData, and the associated request graph alive after the request finishes. - Request headers allocated as a
curl_slistare released while processing a completed transfer, but cancellation and handler shutdown bypass that cleanup path.
Repeated requests, especially cancelled requests, therefore retain Kotlin/Native objects and native allocations.
Expected behavior
The handler should own and release the response-data stable reference and request header list on every terminal path: successful completion, failure, cancellation, handler shutdown, and request setup failure.
Regression test
Schedule and cancel a request, force Kotlin/Native garbage collection, and verify through a WeakReference that its CurlRequestData is no longer retained.
Pull request
CIO server: request handler can leak when idle timeout cancels a claimed response
Problem
The CIO pipeline writer receives responses with a timeout:
withTimeoutOrNull(timeout) {
channel.receiveCatching().getOrNull()
}
Channel receives have prompt-cancellation semantics. The receive can remove a response from actorChannel, schedule the writer continuation, and then lose to the idle timeout before that continuation resumes. The response is no longer in the actor buffer and is never consumed by the writer.
If the request handler writes a response larger than the ByteChannel capacity, it can suspend permanently without a reader and keep the request-handler coroutine and connection pipeline alive.
Difference from KTOR-9741
KTOR-9741 covers responses left in or submitted to the actor channel after the writer exits. This issue covers a response already claimed by receiveCatching and then discarded by prompt cancellation, so draining the actor buffer cannot recover it.
Deterministic reproduction
Use a dispatcher that controls dispatched continuations and timeout callbacks independently:
- Suspend the writer in
receiveCatching. - Submit a response and leave the writer continuation queued.
- Trigger the idle timeout before running the queued continuation.
- Run the queued tasks.
- Observe that the large-response handler remains active and the pipeline does not complete.
Proposed fix
Create actorChannel with onUndeliveredElement and cancel an undelivered response channel:
Channel(
capacity = 3,
onUndeliveredElement = { response ->
response.cancel(IOException("Response was not delivered"))
}
)
Discovered while fixing KTOR-9741 in https://github.com/ktorio/ktor/pull/5783.
Darwin CertificatePinner over-releases borrowed Core Foundation references
CertificatePinner passes Core Foundation references it does not own to CFBridgingRelease during TLS handshakes:
publicKeyTypeandpublicKeySizecome fromCFDictionaryGetValueand follow the Core Foundation Get Rule. The attributes dictionary owns them.kSecAttrKeyTypeRSAandkSecAttrKeyTypeECSECPrimeRandomare Security framework constants.
The resulting over-release corrupts values owned by the dictionary returned from SecKeyCopyAttributes. The handshake can succeed, but the process may crash later when Kotlin/Native GC finalizes the dictionary and its already-released values are deallocated again.
This reproduces reliably on watchOS arm64_32 after a pinned request with Ktor 3.5.1. It can be masked on arm64, where these values may be represented by tagged pointers.
The borrowed references should be retained before ownership is transferred through CFBridgingRelease, or consumed through ownership-neutral Core Foundation APIs.
Pull request: https://github.com/ktorio/ktor/pull/5802
CIO: pipelining corrupts requests with non-empty bodies
When CIO HTTP pipelining is enabled, a non-empty request body can still be writing when the pipeline starts writing the next request to the same connection. This violates ByteWriteChannel's single-writer contract and can corrupt HTTP/1.1 request framing.
Reproduction
- Configure a CIO client with
pipelining = trueand one connection per route. - Start a GET request with a delayed non-empty
WriteChannelContentbody. - After the first body writer starts, issue a second GET request with a non-empty body through the same pipeline.
- Let the server read and echo both bodies.
The server can consume the beginning of the second request line as the first request body. In a local reproducer, the expected responses were:
[first, second]
but the actual responses were:
[GET /, text is empty (possibly HTTP/0.9)]
Depending on scheduling and channel implementation, this can also result in concurrent-write failures.
Root cause
ConnectionPipeline expects writeRequest() to finish writing the current request before advancing to the next task. However, writeBody() launches cio-client-body-writer in the call context and returns immediately. The pipeline can consequently write the next request's headers to networkOutput while the previous body coroutine is still writing to the same channel.
The scope is currently pipelined GET/HEAD requests with bodies; other HTTP methods use dedicated CIO connections.
Suggested direction
Serialize complete request writes in the pipeline by waiting for the current body writer before starting the next request. Keep the response handler concurrent so CIO can still receive response headers while sending a body, preserving the behavior introduced for KTOR-3491. Add a regression test with two non-empty pipelined request bodies.
Discovered while working on KTOR-7009 and https://github.com/ktorio/ktor/pull/5798.
OpenAPI: "has no continuation" AssertionError when a suspend call inside a sealed-type branch reads the smart-cast value
When OpenAPI generation is enabled (ktor { openApi { enabled = true } }, with codeInferenceEnabled left at its default true), compileKotlin fails with an internal compiler error.
Assertion
e: org.jetbrains.kotlin.backend.common.BackendException: Backend Internal error: Exception during IR lowering
File being compiled: .../src/main/kotlin/Repro.kt
The root cause java.lang.AssertionError was thrown at: org.jetbrains.kotlin.backend.jvm.lower.AddContinuationLoweringKt.retargetToSuspendView(AddContinuationLowering.kt:497)
Caused by: java.lang.AssertionError: FUN LOCAL_FUNCTION_FOR_LAMBDA name:reproRoutes$lambda$0$0 visibility:private modality:FINAL <> ($this_get:io.ktor.server.routing.RoutingContext, <this>:io.ktor.openapi.Responses.Builder) returnType:kotlin.Unit? in file Repro.kt has no continuation; can't call FUN name:authorizeAdmin visibility:public modality:FINAL <> (call:io.ktor.server.application.ApplicationCall) returnType:<root>.AuthorizationResult [suspend]
The receiver of the failing lambda is io.ktor.openapi.Responses.Builder, which does not exist in the source — it is generated by the OpenAPI compiler plugin. A user-level suspend call (authorizeAdmin) ends up inside that generated, non-suspend lambda, which therefore has no continuation.
Reproducer
build.gradle.kts:
plugins {
kotlin("jvm") version "2.4.10"
id("io.ktor.plugin") version "3.5.1"
}
repositories { mavenCentral() }
kotlin { jvmToolchain(17) }
dependencies {
implementation("io.ktor:ktor-server-core:3.5.1")
implementation("io.ktor:ktor-server-netty:3.5.1")
}
ktor { openApi { enabled = true } }
src/main/kotlin/Repro.kt:
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
sealed class AuthorizationResult {
data object Success : AuthorizationResult()
data class Failure(val statusCode: HttpStatusCode, val message: String) : AuthorizationResult()
}
suspend fun withAuthorization(
call: ApplicationCall,
block: suspend () -> AuthorizationResult,
): Boolean {
val result = block()
if (result is AuthorizationResult.Failure) {
// suspend call AND a read of the smart-cast value, in the same branch
call.respond(result.statusCode, result.message)
return false
}
return true
}
suspend fun authorizeAdmin(call: ApplicationCall): AuthorizationResult {
call.respond(HttpStatusCode.OK, "checked")
return AuthorizationResult.Success
}
fun Route.reproRoutes() {
get("/repro") {
if (!withAuthorization(call) { authorizeAdmin(call) }) {
return@get
}
call.respond(HttpStatusCode.OK, "ok")
}
}
Then:
./gradlew compileKotlin --rerun-tasks
Trigger, narrowed one variable at a time
The crash needs a suspend function that takes a suspend () -> T parameter where T is a sealed class, and whose body contains both a read of the smart-cast value and a suspend call inside the same type-check branch. Whether the branch is written as if or when makes no difference.
body of withAuthorization |
result |
|---|---|
| suspend call outside the branch | compiles |
| suspend call inside the branch, literal arguments only | compiles |
| read of the smart-cast value, suspend call outside the branch | compiles |
call.respond(result.statusCode, result.message) inside the branch |
crash |
the same, written as when (result) { is Failure -> ... } |
crash |
| the same, but the members hoisted into locals before the call | crash |
Versions tried
| Ktor | Kotlin | result |
|---|---|---|
| 3.5.1 | 2.4.10 | crash |
| 3.5.1 | 2.4.0 | crash |
3.5.1 with ktor-compiler-plugin substituted to 3.5.2 |
2.4.10 / 2.4.0 | crash |
| 3.4.3 | 2.3.21 | crash |
| 3.4.3 | 2.3.0 (the version its compiler plugin is built against) | crash |
| 3.4.1 | 2.3.0 | crash |
| 3.4.0 | 2.3.21 | different error, getLOCAL_FUNCTION_FOR_LAMBDA (KTOR-9370) |
| 3.3.3 | 2.3.21 | openApi { enabled } does not exist yet |
Gradle 8.14.5, JVM toolchain 17, macOS 15 (arm64).
Impact
This is the shape of an ordinary authorization guard, so in our server (~213 endpoints) every route goes through it and OpenAPI generation cannot be enabled at all. We could not find a source-level workaround that keeps the sealed-result design.
Possibly related
- KTOR-9389, KTOR-9392, KTOR-9443 — the same family (code inference producing invalid IR), but different symptoms.
- Setting
codeInferenceEnabled = falseavoids this crash, but the plugin then fails on the test source sets withIrGenerationExtensionException: Parent of element (CLASS ... ) is not initialized, becauseCompilerPlugin.isApplicableonly checksopenApi.enabledand so the plugin is applied to every compilation. Happy to file that separately if it is not the same root cause.
OkHttp: duplex streaming does not release the connection after the request completes
Description
When duplexStreamingEnabled = true in the OkHttp client engine, completing a duplex streaming response while the request body channel is still open can leave the OkHttp exchange active. The HTTP/2 connection is not returned to the idle connection pool, and a subsequent request that tries to reuse it may hang.
Reproduction
- Configure the OkHttp engine with HTTP/2 and
duplexStreamingEnabled = true. - Send a streaming request body using an open
ByteChannel. - Exchange several request and response chunks.
- Exit the response scope without explicitly closing the request channel.
- Observe that the injected OkHttp
ConnectionPoolhas one connection but zero idle connections.
Expected behavior
The request body sink is completed when the body writer finishes or is cancelled, and the HTTP/2 connection is returned to the idle pool.
Pull request
`DigestAuthProvider` cannot be initialized with a congested `Dispatchers.Default` pool
Ktor's DigestAuthProvider constructor calls generateNonceBlocking(), which launches a coroutine on Dispatchers.Default and then blocks the calling thread until that coroutine produces a result. Dispatchers.Default is a fixed-size pool (sized to CPU cores). If every thread in that pool is already busy with other work, there's no thread free to run the nonce-generation coroutine, so the thread blocked waiting for it stalls until a pool thread frees up.
In practice, this means simply constructing a DigestAuthProvider can hang for an unbounded amount of time if the app's Dispatchers.Default pool happens to be saturated at that moment by unrelated CPU-bound work — even before any digest auth is actually performed.
Minimal reproducible example
// Saturate every Dispatchers.Default thread so no thread is free to run the nonce-generation coroutine.
val processors = Runtime.getRuntime().availableProcessors()
val latch = CountDownLatch(processors)
repeat(processors) {
GlobalScope.launch(Dispatchers.Default) {
latch.countDown()
Thread.sleep(10_000)
}
}
latch.await()
// This blocks/hangs while the pool above is congested:
DigestAuthProvider(credentials = { DigestAuthCredentials("user", "pass") })
Outdated KDoc for AuthProvider.refreshToken method
Api symbol: io.ktor.client.plugins.auth.AuthProvider.refreshToken:
kDoc is outdated with call param being non-existent
Application lifecycle events affect other reload generations
During hot reload, old and new Application instances temporarily share the engine event bus.
Unscoped lifecycle subscriptions may handle events from another application, leading to premature cleanup, duplicate work, or leaked subscriptions.
The behaviour was originally identified in the DI plugin.
Other likely affected plugins:
StaticContent: stopping the old application can cancel the new application’s file-watching job.OpenAPI: old subscriptions can regenerate files during later application startups and accumulate across reloads.CallLogging: an old application’s stopped event can uninstall ANSI support belonging to the replacement application.
The new DI scope is cancelled during hot reload
When the old application emits ApplicationStopping, the newly created DI plugin also receives it and cancels its coroutine scope.
Subsequent dependency resolution fails with Application stopped, producing a 500 response.
Unnecessary blocking in OutputStream wrapper
We have mostly mitigated the request blocking of the OutputStream wrapper by introducing a separate IO dispatcher, but we can further avoid blocking by writing directly to our in-memory buffer and only blocking as flushes are needed.
"DefaultDispatcher-worker-2" #152 [155] daemon prio=5 os_prio=0 cpu=59.06ms elapsed=4297.51s tid=0x00007f9e297756e0 nid=155 waiting on condition [0x00007f9e2952a000]
java.lang.Thread.State: TIMED_WAITING (parking)
at jdk.internal.misc.Unsafe.park(java.base@23.0.2/Native Method)
- parking to wait for <0x00000000ed25a1c0> (a kotlinx.coroutines.BlockingCoroutine)
at java.util.concurrent.locks.LockSupport.parkNanos(java.base@23.0.2/LockSupport.java:269)
at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:98)
at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking(Builders.kt:69)
at kotlinx.coroutines.BuildersKt.runBlocking(Unknown Source)
at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking$default(Builders.kt:47)
at kotlinx.coroutines.BuildersKt.runBlocking$default(Unknown Source)
at io.ktor.utils.io.jvm.javaio.BlockingKt$toOutputStream$1.close(Blocking.kt:67)
at kotlin.io.CloseableKt.closeFinally(Closeable.kt:56)
at io.ktor.http.content.OutputStreamContent$writeTo$2.invokeSuspend(OutputStreamContent.kt:27)
at io.ktor.http.content.OutputStreamContent$writeTo$2.invoke(OutputStreamContent.kt)
at io.ktor.http.content.OutputStreamContent$writeTo$2.invoke(OutputStreamContent.kt)
at io.ktor.http.content.BlockingBridgeKt$withBlockingAndRedispatch$2.invokeSuspend(BlockingBridge.kt:45)
at io.ktor.http.content.BlockingBridgeKt$withBlockingAndRedispatch$2.invoke(BlockingBridge.kt)
at io.ktor.http.content.BlockingBridgeKt$withBlockingAndRedispatch$2.invoke(BlockingBridge.kt)
at kotlinx.coroutines.intrinsics.UndispatchedKt.startUndispatchedOrReturn(Undispatched.kt:43)
at kotlinx.coroutines.BuildersKt__Builders_commonKt.withContext(Builders.common.kt:166)
at kotlinx.coroutines.BuildersKt.withContext(Unknown Source)
at io.ktor.http.content.BlockingBridgeKt.withBlockingAndRedispatch(BlockingBridge.kt:44)
at io.ktor.http.content.BlockingBridgeKt.withBlocking(BlockingBridge.kt:32)
at io.ktor.http.content.OutputStreamContent.writeTo(OutputStreamContent.kt:24)
at io.ktor.http.content.CompressedWriteChannelResponse$writeTo$2.invokeSuspend(CompressedContent.kt:82)
at io.ktor.http.content.CompressedWriteChannelResponse$writeTo$2.invoke(CompressedContent.kt)
at io.ktor.http.content.CompressedWriteChannelResponse$writeTo$2.invoke(CompressedContent.kt)
at kotlinx.coroutines.intrinsics.UndispatchedKt.startUndispatchedOrReturn(Undispatched.kt:43)
at kotlinx.coroutines.BuildersKt__Builders_commonKt.withContext(Builders.common.kt:157)
at kotlinx.coroutines.BuildersKt.withContext(Unknown Source)
at io.ktor.http.content.CompressedWriteChannelResponse.writeTo(CompressedContent.kt:80)
at io.ktor.server.engine.BaseApplicationResponse$respondWriteChannelContent$2$1.invokeSuspend(BaseApplicationResponse.kt:176)
at io.ktor.server.engine.BaseApplicationResponse$respondWriteChannelContent$2$1.invoke(BaseApplicationResponse.kt)
at io.ktor.server.engine.BaseApplicationResponse$respondWriteChannelContent$2$1.invoke(BaseApplicationResponse.kt)
at kotlinx.coroutines.intrinsics.UndispatchedKt.startUndispatchedOrReturn(Undispatched.kt:43)
at kotlinx.coroutines.BuildersKt__Builders_commonKt.withContext(Builders.common.kt:166)
at kotlinx.coroutines.BuildersKt.withContext(Unknown Source)
at io.ktor.server.engine.BaseApplicationResponse.respondWriteChannelContent$suspendImpl(BaseApplicationResponse.kt:175)
at io.ktor.server.engine.BaseApplicationResponse.respondWriteChannelContent(BaseApplicationResponse.kt)
at io.ktor.server.engine.BaseApplicationResponse.respondOutgoingContent$suspendImpl(BaseApplicationResponse.kt:132)
at io.ktor.server.engine.BaseApplicationResponse.respondOutgoingContent(BaseApplicationResponse.kt)
at io.ktor.server.netty.NettyApplicationResponse.respondOutgoingContent$suspendImpl(NettyApplicationResponse.kt:37)
at io.ktor.server.netty.NettyApplicationResponse.respondOutgoingContent(NettyApplicationResponse.kt)
at io.ktor.server.engine.BaseApplicationResponse$Companion$setupSendPipeline$1.invokeSuspend(BaseApplicationResponse.kt:319)
at io.ktor.server.engine.BaseApplicationResponse$Companion$setupSendPipeline$1.invoke(BaseApplicationResponse.kt)
at io.ktor.server.engine.BaseApplicationResponse$Companion$setupSendPipeline$1.invoke(BaseApplicationResponse.kt)
at io.ktor.util.pipeline.PipelineJvmKt.pipelineStartCoroutineUninterceptedOrReturn(PipelineJvm.kt:15)
at io.ktor.util.pipeline.SuspendFunctionGun.loop(SuspendFunctionGun.kt:131)
at io.ktor.util.pipeline.SuspendFunctionGun.proceed(SuspendFunctionGun.kt:89)
at io.ktor.util.pipeline.SuspendFunctionGun.proceedWith(SuspendFunctionGun.kt:99)
at io.ktor.server.engine.DefaultTransformKt$installDefaultTransformations$1.invokeSuspend(DefaultTransform.kt:29)
at io.ktor.server.engine.DefaultTransformKt$installDefaultTransformations$1.invoke(DefaultTransform.kt)
at io.ktor.server.engine.DefaultTransformKt$installDefaultTransformations$1.invoke(DefaultTransform.kt)
at io.ktor.util.pipeline.PipelineJvmKt.pipelineStartCoroutineUninterceptedOrReturn(PipelineJvm.kt:15)
at io.ktor.util.pipeline.SuspendFunctionGun.loop(SuspendFunctionGun.kt:131)
at io.ktor.util.pipeline.SuspendFunctionGun.proceed(SuspendFunctionGun.kt:89)
at io.ktor.util.pipeline.SuspendFunctionGun.execute$ktor_utils(SuspendFunctionGun.kt:109)
at io.ktor.util.pipeline.Pipeline.execute(Pipeline.kt:79)
at io.ktor.server.application.PipelineCall$DefaultImpls.respond(PipelineCall.kt:106)
at io.ktor.server.routing.RoutingPipelineCall.respond(RoutingPipelineCall.kt:20)
at io.ktor.server.routing.RoutingCall.respond(RoutingNode.kt:229)
at io.ktor.server.response.ApplicationResponseFunctionsJvmKt.respondOutputStream(ApplicationResponseFunctionsJvm.kt:135)
at org.jetbrains.kotlin.diogen.server.ProgramKt.handleDomainCall(Program.kt:345)
at org.jetbrains.kotlin.diogen.server.ProgramKt.access$handleDomainCall(Program.kt:1)
at org.jetbrains.kotlin.diogen.server.ProgramKt$main$1$12$7$1$1.invokeSuspend(Program.kt:277)
at org.jetbrains.kotlin.diogen.server.ProgramKt$main$1$12$7$1$1.invoke(Program.kt)
at org.jetbrains.kotlin.diogen.server.ProgramKt$main$1$12$7$1$1.invoke(Program.kt)
at org.jetbrains.kotlin.diogen.server.Site.withZip(Program.kt:390)
at org.jetbrains.kotlin.diogen.server.ProgramKt$main$1$12$7$1.invokeSuspend(Program.kt:276)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:100)
at kotlinx.coroutines.internal.LimitedDispatcher$Worker.run(LimitedDispatcher.kt:113)
at kotlinx.coroutines.scheduling.TaskImpl.run(Tasks.kt:89)
at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:586)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:820)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:717)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:704)
OpenAPI: reflection schema inference produces incorrect discriminator mapping for Jackson-annotated and nested sealed hierarchies
Description
When using Jackson annotations to declare serialization and deserialization of sealed class hierarchies, there is a mismatch between the actual JSON (de/)serialization and the generated OpenAPI specification. You can specify both the property name of the discriminator field (which I believe is fixed in KTOR-9591) and the discriminator value each subtype is mapped to. It would be nice if the SchemaReflectionAdapter had a way to override this behavior. In addition, there is an issue with how nested hierarchies are represented where an intermediary sealed interface/class gets its own discriminator mapping.
Steps to reproduce
import Shape.Circle
import Shape.Triangle
import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "kind")
@JsonSubTypes(
JsonSubTypes.Type(value = Circle::class, name = "CIRCLE"),
JsonSubTypes.Type(value = Shape.Square::class, name = "SQUARE"),
JsonSubTypes.Type(value = Triangle.Equilateral::class, name = "EQUILATERAL_TRIANGLE"),
JsonSubTypes.Type(value = Triangle.Isosceles::class, name = "ISOSCELES_TRIANGLE"),
JsonSubTypes.Type(value = Triangle.Scalene::class, name = "SCALENE_TRIANGLE"),
)
sealed interface Shape {
data class Circle(val radius: Int) : Shape
data class Square(val length: Int) : Shape
sealed interface Triangle : Shape {
data class Equilateral(val length: Int): Triangle
data class Isosceles(val legsLength: Int, val oddLength: Int) : Triangle
data class Scalene(val length1: Int, val length2: Int, val length3: Int) : Triangle
}
}
Expose Shape and Triangle as response bodies in separate endpoints.
Expected behavior
"Shape": {
"type": "object",
"title": "Shape",
"oneOf": [
{
"$ref": "#/components/schemas/Circle"
},
{
"$ref": "#/components/schemas/Square"
},
{
"$ref": "#/components/schemas/Triangle"
}
],
"discriminator": {
"propertyName": "type",
"mapping": {
"CIRCLE": "#/components/schemas/Circle",
"SQUARE": "#/components/schemas/Square",
"EQUILATERAL_TRIANGLE": "#/components/schemas/Equilateral",
"ISOSCELES_TRIANGLE": "#/components/schemas/Isosceles",
"SCALENE_TRIANGLE": "#/components/schemas/Scalene"
}
}
}
I'm unsure if the oneOf array would have to be expanded with each concrete type, or if it is valid with a $ref to a schema (Triangle) that is also a oneOf type.
Actual behavior
"Shape": {
"type": "object",
"title": "Shape",
"oneOf": [
{
"$ref": "#/components/schemas/Circle"
},
{
"$ref": "#/components/schemas/Square"
},
{
"$ref": "#/components/schemas/Triangle"
}
],
"discriminator": {
"propertyName": "type",
"mapping": {
"no.nav.pensjon.brev.skribenten.openapi.OpenApiSpecTest.Shape.Circle": "#/components/schemas/Circle",
"no.nav.pensjon.brev.skribenten.openapi.OpenApiSpecTest.Shape.Square": "#/components/schemas/Square",
"no.nav.pensjon.brev.skribenten.openapi.OpenApiSpecTest.Shape.Triangle": "#/components/schemas/Triangle"
}
}
}
Suggested fix
The discriminator mapping name is easy enough to change by adding a new method to the SchemaReflectionAdapter interface, e.g. getDiscriminatorMappingName. The nested hierarchy problem would involve climbing down the sealedSubclasses-tree of the kClass.isSealed case in ReflectionJsonSchemaInference::buildSchemaInternal.
Digest Auth: URI and HA2 are empty for a URL without a path
Digest client auth: uri and HA2 are empty for a URL without a path, so authentication always fails
Reported by the dav4jvm project (WebDAV/CalDAV/CardDAV library, used by the DAVx5 Android app). We hit this with ktor-client-auth 3.5.1 and currently work around it in our own AuthProvider; we would like to drop that workaround.
Affected component: ktor-client-auth (client-side DigestAuthProvider)
Affected versions: 3.5.1 (current code, unchanged for a long time)
Overview
When a request URL has no path — e.g. https://example.com, without the trailing slash — DigestAuthProvider computes both the uri auth parameter and HA2 from Url.fullPath, which is the empty string in that case. It therefore sends uri="" and hashes over "GET:", while the request target actually put on the wire is /.
The server, which computes HA2 over the request target it received (/), calculates a different response and rejects the request. Digest auth can never succeed for such a URL.
References:
- RFC 9112, section 3.2.1 — "If the target URI's path component is empty, the client MUST send
/as the path within the origin-form of request-target." - RFC 7616, section 3.4 — the
uridirective is "The Effective Request URI […] of the HTTP request; duplicated here because proxies are allowed to change the request target." - RFC 7616, section 3.4.3 —
A2 = Method ":" request-uri.
Steps to reproduce
Kotlin/JVM, ktor-client-auth + ktor-client-mock 3.5.1, JUnit 4:
@Test
fun reproduce() = runTest {
val engine = MockEngine { request ->
if (request.headers[HttpHeaders.Authorization] == null)
respond(
content = "",
status = HttpStatusCode.Unauthorized,
headers = headersOf(
HttpHeaders.WWWAuthenticate,
"""Digest realm="test", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", qop="auth", algorithm=MD5"""
)
)
else
respond(content = "OK", status = HttpStatusCode.OK)
}
val client = HttpClient(engine) {
install(Auth) {
digest {
credentials { DigestAuthCredentials(username = "user", password = "password") }
}
}
}
// note the missing trailing slash
client.get("https://example.com")
for (request in engine.requestHistory)
println("--> ${request.method.value} [${request.url}] Authorization: ${request.headers[HttpHeaders.Authorization]}")
}
Result
Output of the reproducer:
--> GET [https://example.com] Authorization: null
--> GET [https://example.com] Authorization: Digest realm="test", username="user",
nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", cnonce="f4939f273be23cec7523ed784d5cd1a2",
response="4fce4e2c78fd64a3c88c590e662304c2", uri="", qop=auth, nc=00000001, algorithm=MD5
Actual result: uri="", and the response is computed with HA2 = MD5("GET:").
Expected result: uri="/", and HA2 = MD5("GET:/").
The response value confirms which HA2 was used (HA1 = MD5("user:test:password"), qop=auth, nc=00000001 and the cnonce from the header above):
| HA2 over | resulting response |
|---|---|
GET: |
4fce4e2c78fd64a3c88c590e662304c2 ← what ktor sends |
GET:/ |
e44ed87bf661a5c8ff6af6eacb448bae ← what a server computes |
Why the expected result is / and not "":
- Per RFC 9112 §3.2.1 the client MUST send
/as the request target when the target URI's path is empty, and ktor clients do:Url.toString()produceshttps://example.com, and the engine turns that intoGET / HTTP/1.1. For the OkHttp engine this happens inOkHttpEngine.kt, which passesurl.toString()to OkHttp, and OkHttp normalizes the empty path to/. - Per RFC 7616 §3.4/§3.4.3 both the
uridirective and HA2 must use that same request target. So they must be/, not""— an emptydigest-uri-valuealso isn't a valid request-target to begin with.
Suspected cause and possible fix
DigestAuthProvider.addRequestHeaders() uses url.fullPath in two places:
val end = makeDigest("$methodName:${url.fullPath}").toHexString() // line 187
…
this["uri"] = url.fullPath.quote() // line 205
and Url.fullPath returns "" for an empty path, because appendUrlFullPath() only prepends the / when encodedPath.isNotBlank().
Two options:
- Local fix in
DigestAuthProvider: use the request target that is actually sent, e.g.val requestUri = url.fullPath.ifEmpty { "/" }, for both line 187 and line 205. - Fix
Url.fullPath: have it return/for an empty path, since it models "the path that goes on the wire" and RFC 9112 §3.2.1 requires/there. This would also cover any other place that builds a request target fromfullPath(we did not audit the engines for this).
We would prefer option 2 if fullPath is indeed meant to be the request target, but option 1 already fixes the authentication failure.
Possibly related issues
Not duplicates as far as we can tell, but in the same area: KTOR-7578, KTOR-4318, KTOR-7869 (RFC 7616 conformance of the client digest computation) and KTOR-9620.
Our workaround (for reference)
We normalize the path before delegating to DigestAuthProvider, so that its fullPath is non-empty:
private fun HttpRequestBuilder.workaroundKtorEmptyDigestUri() {
if (url.encodedPath.isEmpty())
url.encodedPath = "/"
}
Character classification in ktor-http hot paths uses boxed Set<Char> lookups and per-symbol encoder allocations
Problem
Several per-request hot paths in ktor-http classify characters via Set<Char> / Set<Byte>
membership, which boxes every probed character and performs a hash lookup for each one:
Codecs.kt:encodeURLPath,encodeURLQueryComponent,encodeURLParameter, andpercentEncode
probeURL_ALPHABET,VALID_PATH_PART,HEX_ALPHABET, etc. per char/byte. Additionally,
encodeURLPathallocates a newCharsetEncoderper encoded symbol (acknowledged by a comment),
and each percent-encoded byte allocates a 3-charString(Byte.percentEncode()).HttpHeaders.checkHeaderNamevalidates each character by linearly scanning a 15-char string
literal (ch in "\"(),/:;<=>?@[\\]{}"), and runs for every header appended to every response.HeaderValueWithParameters:needQuotes()probes a 20-element boxedSet<Char>per character
every time aContent-Type-style header is rendered.
These run on every URL built/encoded/decoded, every header validated, and every parameterized
header rendered, on both client and server, across all engines.
Proposed solution
Use a bitmask. Replace the boxed sets with an internal AsciiBitSet: a 128-bit membership table stored as two Longs, where lookup is two shifts plus an AND. No boxing, no hashing, and it is KMP-portable. The probes become basically free.
Additional information:
.NET uses the same technique: bit-vector character classes (BitVector256.cs) behind the SearchValues API, and Kestrel validates HTTP header names/values with precompiled character sets (HttpCharacters.cs) instead of per-character set lookups.
OpenAPI UI (ktor-server-openapi) does not support OpenAPI spec 3.1.*
swagger generates openapi v3.1.1 by default
Code example:
main.kt
package com.example
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.plugins.cors.routing.CORS
import io.ktor.server.plugins.openapi.openAPI
import io.ktor.server.plugins.swagger.swaggerUI
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.routing
import io.ktor.server.util.getValue
import io.swagger.codegen.v3.generators.html.StaticHtml2Codegen
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class Customer(val id: Int, val firstName: String, val lastName: String)
fun Application.main() {
val customerStorage = mutableListOf<Customer>()
customerStorage.addAll(
arrayOf(
Customer(1, "Jane", "Smith"),
Customer(2, "John", "Smith")
)
)
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
})
}
install(CORS) {
anyHost()
allowHeader(HttpHeaders.ContentType)
}
routing {
get("/customer/{id}") {
val id: Int by call.parameters
val customer: Customer = customerStorage.find { it.id == id }!!
call.respond(customer)
}
post("/customer") {
val customer = call.receive<Customer>()
customerStorage.add(customer)
call.respondText("Customer stored correctly", status = HttpStatusCode.Created)
}
swaggerUI(path = "swagger", swaggerFile = "openapi/documentation.yaml") {
version = "4.15.5"
}
openAPI(path="openapi", swaggerFile = "openapi/documentation.yaml") {
codegen = StaticHtml2Codegen()
}
}
}
build.gradle.kts
plugins {
alias(libs.plugins.kotlin.jvm)
alias(ktorLibs.plugins.ktor)
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.0"
}
group = "com.example"
version = "1.0.0-SNAPSHOT"
application {
mainClass = "io.ktor.server.netty.EngineMain"
}
kotlin {
jvmToolchain(25)
}
ktor {
openApi {
enabled = true
codeInferenceEnabled = true
onlyCommented = false
}
}
dependencies {
implementation(ktorLibs.server.config.yaml)
implementation(ktorLibs.server.core)
implementation(ktorLibs.server.netty)
implementation(ktorLibs.server.openapi)
implementation(ktorLibs.server.routingOpenapi)
implementation(libs.logback.classic)
implementation(ktorLibs.server.contentNegotiation)
implementation(ktorLibs.serialization.kotlinx.json)
implementation(ktorLibs.server.cors)
implementation(ktorLibs.server.swagger)
implementation("io.swagger.codegen.v3:swagger-codegen-generators:1.0.62")
testImplementation(kotlin("test"))
testImplementation(ktorLibs.server.testHost)
}
As I understood, it doesn't work on openapi 3.1.1 (work on 3.0.3)
documentation.yaml (working):
openapi: "3.0.3"
info:
title: "JSON API sample"
description: "A JSON API that allows you to view and add customers"
version: "1.0.0"
servers:
- url: "http://0.0.0.0:8080"
paths:
/customer:
post:
description: "Creates a new customer"
requestBody:
description: "A JSON object containing customer information"
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/Customer"
responses:
"201":
description: "Created"
content:
text/plain:
schema:
type: "string"
examples:
Example#1:
value: "Customer stored correctly"
/customer/{id}:
get:
description: "Returns a customer by its ID"
parameters:
- name: "id"
in: "path"
required: true
schema:
type: "string"
responses:
"200":
description: "OK"
content:
'*/*':
schema:
$ref: "#/components/schemas/Customer"
components:
schemas:
Customer:
type: "object"
properties:
id:
type: "integer"
format: "int32"
firstName:
type: "string"
lastName:
type: "string"
documentation.yaml (not working):
openapi: 3.1.1
info:
title: Untitled API
version: 1.0.0
paths:
/customer/{id}:
get:
summary: ""
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
"200":
description: ""
content:
application/json:
schema:
$ref: "#/components/schemas/Customer"
/customer:
post:
summary: ""
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/Customer"
responses:
"201":
description: ""
content:
text/plain: {}
webhooks: {}
components:
schemas:
Customer:
type: object
title: Customer
required:
- id
- firstName
- lastName
properties:
id:
type: integer
firstName:
type: string
lastName:
type: string
Netty HTTP/3: user handler code is dispatched on the QUIC event loop — one blocking handler freezes the entire HTTP/3 listener (callEventGroup / pinnedCallExecutor not used)
Affected versions
Unreleased: main (3.6.0-SNAPSHOT), verified at commit baec59c0b0159c685ef46cb638da3711bb58e67b
(2026-07-16, after the KTOR-9712 merge). HTTP/3 support for the Netty engine was introduced in
PR ktorio/ktor#5527.
Problem
Since KTOR-9542, HTTP/1 and HTTP/2 dispatch application handler code onto an executor pinned
from callEventGroup, keeping user code off the Netty I/O threads:
- HTTP/1: NettyHttp1Handler.kt:177 —
CurrentContext(context, callExecutor) - HTTP/2: NettyHttp2Handler.kt:128-L131 —
pinnedCallExecutor(context, callEventGroup) - The intent is documented in PinnedCallExecutor.kt.
The new HTTP/3 handler does neither. NettyHttp3Handler does not receive callEventGroup at
all, and startHttp3 builds the call context with the single-argument CurrentContext:
- NettyHttp3Handler.kt:102:
staticCallContext + NettyDispatcher.CurrentContext(context) + callJob - CIO.kt:102-L105:
the single-argument overload defaults toexecutor = context.executor()— the KDoc itself
notes this executor "runs I/O work". - NettyHttp3Handler.kt:114:
the call coroutine is also launched viacontext.executor().execute { ... }.
So every resumption of the application coroutine is dispatched onto the QUIC stream's event
loop. This is aggravated by the HTTP/3 topology: each SSL connector binds a single
DatagramChannel registered on workerEventGroup
(NettyApplicationEngine.kt, createHttp3Bootstrap),
and netty-codec-quic drives all QUIC connections and streams of that connector on that one
channel's event loop. Any blocking user code (JDBC, file IO, CPU-heavy serialization)
therefore stalls not just its own stream but the whole HTTP/3 listener — including QUIC
handshakes of brand-new, unrelated connections.
The same application code behaves fine over HTTP/1/2 on the same server, which makes this a
very confusing performance trap once HTTP/3 is enabled.
Reproducer
Attached NettyHttp3CallExecutorTest.kt (drop into
ktor-server/ktor-server-netty/jvm/test/io/ktor/tests/server/netty/). The test
blocking user code on one HTTP3 connection must not stall other connections:
- handler
/blocksignals a latch, then doesThread.sleep(1500); - connection A fires
GET /blockwithout awaiting the response; the test waits on the latch,
so the server is now inside the blocking section; - a brand-new QUIC connection B (fresh handshake) issues
GET /instant.
Expected (and asserted): B completes in roughly baseline time. Actual on main: B takes
~the full blocking duration, because its handshake + request can't be processed until the
event loop thread returns from the handler's Thread.sleep. The test also prints the handler
thread name, which is the event-loop thread.
Observed on main @ baec59c0:
[repro] HTTP/3 handler thread: eventLoopGroupProxy-3-1 @call-handler#10 // netty worker (I/O) event loop
[repro] fresh connection + GET /instant with idle server: 56ms
[repro] fresh connection + GET /instant while another connection's handler blocks: 1517ms // ≈ the full 1500 ms Thread.sleep
With the fix (pinned executor from callEventGroup, mirroring HTTP/1/2):
[repro] HTTP/3 handler thread: eventLoopGroupProxy-4-1 @call-handler#10 // call event group executor
[repro] fresh connection + GET /instant with idle server: 49ms
[repro] fresh connection + GET /instant while another connection's handler blocks: 49ms // no stall
Suggested fix
Mirror the HTTP/1/2 model: pass callEventGroup into NettyHttp3Handler (via
NettyHttp3ChannelInitializer / NettyHttp3RequestStreamInitializer) and build the call
context with NettyDispatcher.CurrentContext(context, pinnedCallExecutor(context, callEventGroup)),
launching the call coroutine on that executor as startHttp2 does.
Related
- KTOR-9542 (introduced the pinned call executor for HTTP/1/2)
- KTOR-9712 (HTTP/3: per-connection Http3ServerConnectionHandler — merged; this reproducer
relies on it, since it needs a second concurrent QUIC connection)
CORS Plugin: Access-Control-Allow-Methods header does not include default methods (GET, POST, HEAD)
In Ktor 3.3.0, the CORS plugin does not correctly include the default HTTP methods (GET, POST, HEAD) in the Access-Control-Allow-Methods response header.
Current implementation in CORSConfig builds the methods header like this:
val methodsListHeaderValue = methods.filterNot { it in CorsDefaultMethods } .map { it.value } .sorted() .joinToString(", ")
Since CorsDefaultMethods = { GET, POST, HEAD }, these methods are filtered out and not included in the final header.
As a result, browsers see an incomplete Access-Control-Allow-Methods header (often containing only OPTIONS), which breaks CORS preflight validation.
To Reproduce
Steps to reproduce the behavior:
Enable the CORS plugin in Ktor 3.3.0 and allow POST.
Send an OPTIONS preflight request from the browser.
Inspect the response headers.
Example output:
Access-Control-Allow-Methods: OPTIONS
Expected output:
Access-Control-Allow-Methods: GET, POST, HEAD, OPTIONS
Expected behavior
The Access-Control-Allow-Methods header should always include all configured methods, including the defaults (GET, POST, HEAD).
Proposed Fix
Replace the filtering logic with a distinct collection of methods:
val methodsListHeaderValue = methods.distinct() .map { it.value } .sorted() .joinToString(", ")
This ensures that GET, POST, HEAD are included in the header along with any additional methods.
Environment
Ktor version: 3.3.0
Module: ktor-server-cors
JVM: 17
OS: (e.g. Ubuntu 22.04 / Windows 11)
Additional context
This bug prevents correct CORS preflight handling in browsers, leading to blocked requests even when methods are allowed.
Static Content: excluded extension prevents fallback, and index file bypasses exclusion rules
Not the best title, but I couldn't think of something better.
Here's a reproduction case:
@Test
fun testStaticPathExclude() = testApplication {
routing {
staticFileSystem("static", "jvm/test-resources/public", "index.txt") {
exclude { it.pathString.contains("ignore") }
exclude { it.pathString.contains("secret") }
extensions("secret.txt", "txt")
}
}
// fails due to first trying static/has-fallback.secret.txt. does not continue to try static/has-fallback.txt
val responseFileFallbackExtension = client.get("static/has-fallback")
assertEquals(HttpStatusCode.OK, responseFileFallbackExtension.status)
assertEquals("has-fallback.txt", responseFileFallbackExtension.bodyAsText().trim())
assertEquals(ContentType.Text.Plain, responseFileFallbackExtension.contentType()!!.withoutParameters())
assertNull(responseFileFallbackExtension.headers[HttpHeaders.CacheControl])
// when returning the index, does not check if it is excluded
val responseIgnoreFileIndex = client.get("static/ignored/")
assertEquals(HttpStatusCode.Forbidden, responseIgnoreFileIndex.status)
}
The first one is unintuitive, as you might add an exclusion for index.md, and have the extensions configured to md and html. you still want it to return the .md files for other paths, just not when it ends in index.md. if md is specified before html in the extensions, then it will have different behaviour.
The second one is unintuitive because you could configure a directory, secret/ to be excluded and an index of index.html. if someone makes a request for /secret/, then it will recognize this is a directory and serve secret/index.html.
I'm going to open a PR that fixes this.
OAuth2: fallback handler not invoked when token endpoint returns invalid_grant
Currently, an invalid_grant error bypasses the fallback mechanism and triggers a retry loop, causing unnecessary redirects. We should update the logic to ensure the fallback is always triggered for all exceptions. This allows the client to stop the loop, notify the user, and suggest contacting support.
Websockets: Closing WebSocketSession with a long message leads to ProtocolViolationException
Passing a long message to WebSocketSession.close or WebSocketSession.closeExceptionally leads to
io.ktor.websocket.ProtocolViolationException: Received illegal frame: control frames can't be larger than 125 bytes
Expected behavior:
The message is trimmed by ktor to comply with the specs
UDP support for Node.js targets
We currently have UDP for JVM and native targets.
To fully support the feature on all platforms, we will need to introduce UDP for JS and WASM targets.
ByteReadChannel.copyTo does not propagate source closedCause on normal exit
Problem
ByteReadChannel.copyTo exits its while loop normally when isClosedForRead becomes true (i.e. the source was cancelled with a cause). In this case the exception from closedCause is silently dropped and the function returns normally, without propagating the cause to the caller.
rethrowCloseCauseIfNeeded() is already called at the top of awaitContent() (catching the case where the channel is already closed before suspension), but there is no equivalent check on exit — if the source is cancelled while copyTo is between suspension points, the function exits the while loop via !isClosedForRead and returns normally.
Current workaround
Callers who need correct error propagation must call source.rethrowCloseCauseIfNeeded() manually after copyTo returns, as done in DefaultTransform:
body.copyTo(channel, limit = Long.MAX_VALUE)
body.rethrowCloseCauseIfNeeded()
Proposed fix
rethrowCloseCauseIfNeeded() should be made private to ByteChannel and called at the end of awaitContent() (after sleepWhile exits), so that any cancellation cause is always propagated regardless of whether the caller was suspended or executing between iterations. This would make copyTo (and all other readers built on awaitContent()) correct by default.
This is a more central refactor than the immediate workaround and should be addressed in a dedicated change.
Add `text/markdown` to the ContentType constants
The markdown content type is missing as a constant and therefore needs to be worked around with `ContentType.parse("text/markdown")`.
The spec can be found here: https://www.rfc-editor.org/info/rfc7763/
The changes need to be done to this object: https://github.com/ktorio/ktor/blob/eac67e82d3d60bedbbf7aa0ed72acc1426cb18e7/ktor-http/common/src/io/ktor/http/ContentTypes.kt#L368