From 8c900fed9cdccaf6f07d6a45d6e2b6db4e5122ce Mon Sep 17 00:00:00 2001 From: Bruce Hamilton Date: Tue, 9 Jun 2026 16:16:31 +0200 Subject: [PATCH 1/3] Memory allocations improvements in routing --- .../ktor-server-core/api/ktor-server-core.api | 57 +++ .../api/ktor-server-core.klib.api | 19 + .../server/routing/HostsRoutingBuilder.kt | 5 +- .../server/routing/LocalPortRoutingBuilder.kt | 3 + .../io/ktor/server/routing/RegexRouting.kt | 7 +- .../io/ktor/server/routing/RouteSelector.kt | 155 +++++- .../io/ktor/server/routing/RoutingPathTrie.kt | 457 ++++++++++++++++++ .../server/routing/RoutingResolveContext.kt | 213 ++++++-- .../src/io/ktor/server/routing/RoutingRoot.kt | 25 + .../io/ktor/server/routing/SegmentedPath.kt | 151 ++++++ .../ktor/server/http/content/StaticContent.kt | 11 + .../tests/server/netty/NettySpecificTest.kt | 2 + .../server/routing/RoutingFastPathTest.kt | 274 +++++++++++ .../server/routing/RoutingFastPathJvmTest.kt | 41 ++ 14 files changed, 1364 insertions(+), 56 deletions(-) create mode 100644 ktor-server/ktor-server-core/common/src/io/ktor/server/routing/RoutingPathTrie.kt create mode 100644 ktor-server/ktor-server-core/common/src/io/ktor/server/routing/SegmentedPath.kt create mode 100644 ktor-server/ktor-server-tests/common/test/io/ktor/tests/server/routing/RoutingFastPathTest.kt create mode 100644 ktor-server/ktor-server-tests/jvm/test/io/ktor/tests/server/routing/RoutingFastPathJvmTest.kt diff --git a/ktor-server/ktor-server-core/api/ktor-server-core.api b/ktor-server/ktor-server-core/api/ktor-server-core.api index 6385a198c49..af5e90c247b 100644 --- a/ktor-server/ktor-server-core/api/ktor-server-core.api +++ b/ktor-server/ktor-server-core/api/ktor-server-core.api @@ -2048,6 +2048,63 @@ public final class io/ktor/server/routing/RoutingRootKt { public static final fun routing (Lio/ktor/server/application/Application;Lkotlin/jvm/functions/Function1;)Lio/ktor/server/routing/RoutingRoot; } +public final class io/ktor/server/routing/SegmentedPath : java/util/List, kotlin/jvm/internal/markers/KMappedMarker { + public synthetic fun add (ILjava/lang/Object;)V + public fun add (ILjava/lang/String;)V + public synthetic fun add (Ljava/lang/Object;)Z + public fun add (Ljava/lang/String;)Z + public fun addAll (ILjava/util/Collection;)Z + public fun addAll (Ljava/util/Collection;)Z + public static final synthetic fun box-impl (Ljava/lang/String;)Lio/ktor/server/routing/SegmentedPath; + public fun clear ()V + public final fun contains (Ljava/lang/Object;)Z + public fun contains (Ljava/lang/String;)Z + public static fun contains-impl (Ljava/lang/String;Ljava/lang/String;)Z + public fun containsAll (Ljava/util/Collection;)Z + public static fun containsAll-impl (Ljava/lang/String;Ljava/util/Collection;)Z + public fun equals (Ljava/lang/Object;)Z + public static fun equals-impl (Ljava/lang/String;Ljava/lang/Object;)Z + public static final fun equals-impl0 (Ljava/lang/String;Ljava/lang/String;)Z + public synthetic fun get (I)Ljava/lang/Object; + public fun get (I)Ljava/lang/String; + public static fun get-impl (Ljava/lang/String;I)Ljava/lang/String; + public fun getSize ()I + public static fun getSize-impl (Ljava/lang/String;)I + public fun hashCode ()I + public static fun hashCode-impl (Ljava/lang/String;)I + public final fun indexOf (Ljava/lang/Object;)I + public fun indexOf (Ljava/lang/String;)I + public static fun indexOf-impl (Ljava/lang/String;Ljava/lang/String;)I + public fun isEmpty ()Z + public static fun isEmpty-impl (Ljava/lang/String;)Z + public fun iterator ()Ljava/util/Iterator; + public static fun iterator-impl (Ljava/lang/String;)Ljava/util/Iterator; + public final fun lastIndexOf (Ljava/lang/Object;)I + public fun lastIndexOf (Ljava/lang/String;)I + public static fun lastIndexOf-impl (Ljava/lang/String;Ljava/lang/String;)I + public fun listIterator ()Ljava/util/ListIterator; + public fun listIterator (I)Ljava/util/ListIterator; + public static fun listIterator-impl (Ljava/lang/String;)Ljava/util/ListIterator; + public static fun listIterator-impl (Ljava/lang/String;I)Ljava/util/ListIterator; + public synthetic fun remove (I)Ljava/lang/Object; + public fun remove (I)Ljava/lang/String; + public fun remove (Ljava/lang/Object;)Z + public fun removeAll (Ljava/util/Collection;)Z + public fun replaceAll (Ljava/util/function/UnaryOperator;)V + public fun retainAll (Ljava/util/Collection;)Z + public synthetic fun set (ILjava/lang/Object;)Ljava/lang/Object; + public fun set (ILjava/lang/String;)Ljava/lang/String; + public synthetic fun size ()I + public fun sort (Ljava/util/Comparator;)V + public fun subList (II)Ljava/util/List; + public static fun subList-impl (Ljava/lang/String;II)Ljava/util/List; + public fun toArray ()[Ljava/lang/Object; + public fun toArray ([Ljava/lang/Object;)[Ljava/lang/Object; + public fun toString ()Ljava/lang/String; + public static fun toString-impl (Ljava/lang/String;)Ljava/lang/String; + public final synthetic fun unbox-impl ()Ljava/lang/String; +} + public final class io/ktor/server/routing/TrailingSlashRouteSelector : io/ktor/server/routing/RouteSelector, io/ktor/server/routing/RoutePathComponent { public static final field INSTANCE Lio/ktor/server/routing/TrailingSlashRouteSelector; public fun evaluate (Lio/ktor/server/routing/RoutingResolveContext;ILkotlin/coroutines/Continuation;)Ljava/lang/Object; diff --git a/ktor-server/ktor-server-core/api/ktor-server-core.klib.api b/ktor-server/ktor-server-core/api/ktor-server-core.klib.api index d89fea39f57..6fb30ec9f11 100644 --- a/ktor-server/ktor-server-core/api/ktor-server-core.klib.api +++ b/ktor-server/ktor-server-core/api/ktor-server-core.klib.api @@ -1305,6 +1305,25 @@ final class io.ktor.server.routing/RoutingRoot : io.ktor.server.routing/Routing, } } +final value class io.ktor.server.routing/SegmentedPath : kotlin.collections/List { // io.ktor.server.routing/SegmentedPath|null[0] + final val size // io.ktor.server.routing/SegmentedPath.size|{}size[0] + final fun (): kotlin/Int // io.ktor.server.routing/SegmentedPath.size.|(){}[0] + + final fun contains(kotlin/String): kotlin/Boolean // io.ktor.server.routing/SegmentedPath.contains|contains(kotlin.String){}[0] + final fun containsAll(kotlin.collections/Collection): kotlin/Boolean // io.ktor.server.routing/SegmentedPath.containsAll|containsAll(kotlin.collections.Collection){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // io.ktor.server.routing/SegmentedPath.equals|equals(kotlin.Any?){}[0] + final fun get(kotlin/Int): kotlin/String // io.ktor.server.routing/SegmentedPath.get|get(kotlin.Int){}[0] + final fun hashCode(): kotlin/Int // io.ktor.server.routing/SegmentedPath.hashCode|hashCode(){}[0] + final fun indexOf(kotlin/String): kotlin/Int // io.ktor.server.routing/SegmentedPath.indexOf|indexOf(kotlin.String){}[0] + final fun isEmpty(): kotlin/Boolean // io.ktor.server.routing/SegmentedPath.isEmpty|isEmpty(){}[0] + final fun iterator(): kotlin.collections/Iterator // io.ktor.server.routing/SegmentedPath.iterator|iterator(){}[0] + final fun lastIndexOf(kotlin/String): kotlin/Int // io.ktor.server.routing/SegmentedPath.lastIndexOf|lastIndexOf(kotlin.String){}[0] + final fun listIterator(): kotlin.collections/ListIterator // io.ktor.server.routing/SegmentedPath.listIterator|listIterator(){}[0] + final fun listIterator(kotlin/Int): kotlin.collections/ListIterator // io.ktor.server.routing/SegmentedPath.listIterator|listIterator(kotlin.Int){}[0] + final fun subList(kotlin/Int, kotlin/Int): kotlin.collections/List // io.ktor.server.routing/SegmentedPath.subList|subList(kotlin.Int;kotlin.Int){}[0] + final fun toString(): kotlin/String // io.ktor.server.routing/SegmentedPath.toString|toString(){}[0] +} + open class <#A: kotlin/Any> io.ktor.server.application/CallContext { // io.ktor.server.application/CallContext|null[0] final val pluginConfig // io.ktor.server.application/CallContext.pluginConfig|{}pluginConfig[0] final fun (): #A // io.ktor.server.application/CallContext.pluginConfig.|(){}[0] diff --git a/ktor-server/ktor-server-core/common/src/io/ktor/server/routing/HostsRoutingBuilder.kt b/ktor-server/ktor-server-core/common/src/io/ktor/server/routing/HostsRoutingBuilder.kt index aa2cf901cda..95065c9c046 100644 --- a/ktor-server/ktor-server-core/common/src/io/ktor/server/routing/HostsRoutingBuilder.kt +++ b/ktor-server/ktor-server-core/common/src/io/ktor/server/routing/HostsRoutingBuilder.kt @@ -129,7 +129,10 @@ public data class HostRouteSelector( require(hostList.isNotEmpty() || hostPatterns.isNotEmpty() || portsList.isNotEmpty()) } - override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation { + override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation = + evaluateSync(context, segmentIndex) + + override fun evaluateSync(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation { val requestHost = context.call.request.origin.serverHost val requestPort = context.call.request.origin.serverPort diff --git a/ktor-server/ktor-server-core/common/src/io/ktor/server/routing/LocalPortRoutingBuilder.kt b/ktor-server/ktor-server-core/common/src/io/ktor/server/routing/LocalPortRoutingBuilder.kt index 7a324bb1401..730a13caefd 100644 --- a/ktor-server/ktor-server-core/common/src/io/ktor/server/routing/LocalPortRoutingBuilder.kt +++ b/ktor-server/ktor-server-core/common/src/io/ktor/server/routing/LocalPortRoutingBuilder.kt @@ -42,6 +42,9 @@ public fun Route.localPort(port: Int, build: Route.() -> Unit): Route { public data class LocalPortRouteSelector(val port: Int) : RouteSelector() { override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation = + evaluateSync(context, segmentIndex) + + override fun evaluateSync(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation = if (context.call.request.local.localPort == port) { val parameters = parametersOf(LocalPortParameter, port.toString()) RouteSelectorEvaluation.Success(RouteSelectorEvaluation.qualityConstant, parameters) diff --git a/ktor-server/ktor-server-core/common/src/io/ktor/server/routing/RegexRouting.kt b/ktor-server/ktor-server-core/common/src/io/ktor/server/routing/RegexRouting.kt index cb88d557e43..77594cc1fa3 100644 --- a/ktor-server/ktor-server-core/common/src/io/ktor/server/routing/RegexRouting.kt +++ b/ktor-server/ktor-server-core/common/src/io/ktor/server/routing/RegexRouting.kt @@ -296,7 +296,10 @@ private fun Route.createRouteFromRegexPath(regex: Regex): Route { */ public class PathSegmentRegexRouteSelector(public val regex: Regex) : RouteSelector(), RoutePathComponent { - override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation { + override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation = + evaluateSync(context, segmentIndex) + + override fun evaluateSync(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation { val prefix = if (regex.pattern.startsWith('/') || regex.pattern.startsWith("""\/""")) "/" else "" val postfix = if (regex.pattern.endsWith('/') && context.call.ignoreTrailingSlash) "/" else "" val pathSegments = context.segments.drop(segmentIndex).joinToString("/", prefix, postfix) @@ -304,7 +307,7 @@ public class PathSegmentRegexRouteSelector(public val regex: Regex) : RouteSelec val segmentIncrement = result.value.length.let { consumedLength -> if (pathSegments.length == consumedLength) { - context.segments.size - segmentIndex + context.segmentsSize - segmentIndex } else if (pathSegments[consumedLength] == '/') { countSegments(result, consumedLength, prefix) } else if (consumedLength >= 1 && pathSegments[consumedLength - 1] == '/') { diff --git a/ktor-server/ktor-server-core/common/src/io/ktor/server/routing/RouteSelector.kt b/ktor-server/ktor-server-core/common/src/io/ktor/server/routing/RouteSelector.kt index fcc9ab270a5..3b6a557805a 100644 --- a/ktor-server/ktor-server-core/common/src/io/ktor/server/routing/RouteSelector.kt +++ b/ktor-server/ktor-server-core/common/src/io/ktor/server/routing/RouteSelector.kt @@ -251,6 +251,38 @@ public abstract class RouteSelector { * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.routing.RouteSelector.evaluate) */ public abstract suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation + + /** + * Synchronous evaluation hook used by the routing resolver to avoid allocating a coroutine + * continuation on every recursive resolution step. + * + * Built-in selectors override this method to perform their evaluation without suspending. + * When this method returns `null`, the resolver falls back to the `suspend` [evaluate] + * function, which preserves compatibility with custom selectors (and plugin-provided + * selectors such as `AuthenticationRouteSelector`, `WebSocketRouteSelector`, etc.) that + * either need suspension or live in modules without access to this internal hook. + * + * Returning a non-null result MUST be equivalent to returning the same result from + * [evaluate] (i.e., implementations must not change behaviour based on which entry point is + * used). + */ + internal open fun evaluateSync(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation? = null + + /** + * Upper bound on the quality this selector can produce from a successful [evaluate] call, + * used by the routing fast-path index ([RoutingPathTrie]) at build time to classify + * sibling selectors without invoking them. + * + * The default is [Double.NaN], meaning "unknown" — such selectors force the routing index + * to fall back to the slow DFS for the whole subtree (since they could in principle outrank + * a sibling constant path match). + * + * Selectors that statically guarantee a quality **strictly less than** + * [RouteSelectorEvaluation.qualityConstant] (e.g. wildcards, tailcards, the static-content + * tailcard wrapper) should override this to allow the routing index to keep indexing + * constant siblings, deferring to the slow DFS only when the constant lookup misses. + */ + internal open val maxQualityHint: Double get() = Double.NaN } /** @@ -307,12 +339,24 @@ public class RootRouteSelector(rootPath: String = "") : RouteSelector(), RoutePa it.value } + /** + * Constant path segments declared by this root selector, in order. Used by the routing + * fast-path index ([RoutingPathTrie]) to seed the trie below the application root prefix. + */ + internal val rootParts: List get() = parts + private val successEvaluationResult = RouteSelectorEvaluation.Success( RouteSelectorEvaluation.qualityConstant, segmentIncrement = parts.size ) - override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation { + override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation = + evaluateInternal(context, segmentIndex) + + override fun evaluateSync(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation = + evaluateInternal(context, segmentIndex) + + private fun evaluateInternal(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation { check(segmentIndex == 0) { "Root selector should be evaluated first." } if (parts.isEmpty()) { return RouteSelectorEvaluation.Constant @@ -320,7 +364,7 @@ public class RootRouteSelector(rootPath: String = "") : RouteSelector(), RoutePa val parts = parts val segments = context.segments - if (segments.size < parts.size) { + if (context.segmentsSize < parts.size) { return RouteSelectorEvaluation.FailedPath } @@ -350,7 +394,10 @@ public data class ConstantParameterRouteSelector( val value: String ) : RouteSelector() { - override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation { + override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation = + evaluateSync(context, segmentIndex) + + override fun evaluateSync(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation { if (context.call.parameters.contains(name, value)) { return RouteSelectorEvaluation.Constant } @@ -371,7 +418,10 @@ public data class ParameterRouteSelector( override val name: String ) : RouteSelector(), RouteParameterComponent { - override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation { + override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation = + evaluateSync(context, segmentIndex) + + override fun evaluateSync(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation { val param = context.call.parameters.getAll(name) if (param != null) { return RouteSelectorEvaluation.Success( @@ -396,7 +446,10 @@ public data class OptionalParameterRouteSelector( override val name: String ) : RouteSelector(), RouteParameterComponent { - override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation { + override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation = + evaluateSync(context, segmentIndex) + + override fun evaluateSync(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation { val param = context.call.parameters.getAll(name) if (param != null) { return RouteSelectorEvaluation.Success( @@ -421,8 +474,11 @@ public data class PathSegmentConstantRouteSelector( val value: String ) : RouteSelector(), RoutePathComponent { - override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation = when { - segmentIndex < context.segments.size && context.segments[segmentIndex] == value -> + override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation = + evaluateSync(context, segmentIndex) + + override fun evaluateSync(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation = when { + segmentIndex < context.segmentsSize && context.segments[segmentIndex] == value -> RouteSelectorEvaluation.ConstantPath else -> RouteSelectorEvaluation.FailedPath @@ -438,7 +494,10 @@ public data class PathSegmentConstantRouteSelector( */ public object TrailingSlashRouteSelector : RouteSelector(), RoutePathComponent { - override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation = when { + override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation = + evaluateSync(context, segmentIndex) + + override fun evaluateSync(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation = when { context.call.ignoreTrailingSlash -> RouteSelectorEvaluation.Transparent context.segments.isEmpty() -> RouteSelectorEvaluation.Constant segmentIndex < context.segments.lastIndex -> RouteSelectorEvaluation.Transparent @@ -466,7 +525,10 @@ public data class PathSegmentParameterRouteSelector( val suffix: String? = null ) : RouteSelector(), RoutePathComponent, RouteParameterComponent { - override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation { + override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation = + evaluateSync(context, segmentIndex) + + override fun evaluateSync(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation { return evaluatePathSegmentParameter( segments = context.segments, segmentIndex = segmentIndex, @@ -495,7 +557,10 @@ public data class PathSegmentOptionalParameterRouteSelector( val suffix: String? = null ) : RouteSelector(), RoutePathComponent, RouteParameterComponent { - override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation { + override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation = + evaluateSync(context, segmentIndex) + + override fun evaluateSync(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation { return evaluatePathSegmentParameter( segments = context.segments, segmentIndex = segmentIndex, @@ -515,8 +580,11 @@ public data class PathSegmentOptionalParameterRouteSelector( * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.routing.PathSegmentWildcardRouteSelector) */ public object PathSegmentWildcardRouteSelector : RouteSelector(), RoutePathComponent { - override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation { - if (segmentIndex < context.segments.size && context.segments[segmentIndex].isNotEmpty()) { + override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation = + evaluateSync(context, segmentIndex) + + override fun evaluateSync(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation { + if (segmentIndex < context.segmentsSize && context.segments[segmentIndex].isNotEmpty()) { return RouteSelectorEvaluation.WildcardPath } return RouteSelectorEvaluation.FailedPath @@ -542,7 +610,10 @@ public data class PathSegmentTailcardRouteSelector( require(prefix.none { it == '/' }) { "Multisegment prefix is not supported" } } - override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation { + override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation = + evaluateSync(context, segmentIndex) + + override fun evaluateSync(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation { val segments = context.segments if (prefix.isNotEmpty()) { val segmentText = segments.getOrNull(segmentIndex) @@ -564,14 +635,15 @@ public data class PathSegmentTailcardRouteSelector( } ) } + val segmentsSize = context.segmentsSize val quality = when { - segmentIndex < segments.size -> RouteSelectorEvaluation.qualityTailcard + segmentIndex < segmentsSize -> RouteSelectorEvaluation.qualityTailcard else -> RouteSelectorEvaluation.qualityMissing } return RouteSelectorEvaluation.Success( quality, values, - segmentIncrement = segments.size - segmentIndex + segmentIncrement = segmentsSize - segmentIndex ) } @@ -601,6 +673,15 @@ public data class OrRouteSelector( } } + /** + * Synchronous evaluation. Returns `null` if either sub-selector itself doesn't expose a + * synchronous form, in which case the resolver falls back to the `suspend` [evaluate]. + */ + override fun evaluateSync(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation? { + val result = first.evaluateSync(context, segmentIndex) ?: return null + return if (result.succeeded) result else second.evaluateSync(context, segmentIndex) + } + override fun subSelectors(): List = listOf(first, second) @@ -638,6 +719,27 @@ public data class AndRouteSelector( ) } + /** + * Synchronous evaluation. Returns `null` if either sub-selector itself doesn't expose a + * synchronous form, in which case the resolver falls back to the `suspend` [evaluate]. + */ + override fun evaluateSync(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation? { + val result1 = first.evaluateSync(context, segmentIndex) ?: return null + if (result1 !is RouteSelectorEvaluation.Success) { + return result1 + } + val result2 = second.evaluateSync(context, segmentIndex + result1.segmentIncrement) ?: return null + if (result2 !is RouteSelectorEvaluation.Success) { + return result2 + } + val resultValues = result1.parameters + result2.parameters + return RouteSelectorEvaluation.Success( + result1.quality * result2.quality, + resultValues, + result1.segmentIncrement + result2.segmentIncrement + ) + } + override fun subSelectors(): List = listOf(first, second) @@ -655,7 +757,10 @@ public data class HttpMethodRouteSelector( val method: HttpMethod ) : RouteSelector() { - override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation { + override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation = + evaluateSync(context, segmentIndex) + + override fun evaluateSync(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation { if (context.call.request.httpMethod == method) { return RouteSelectorEvaluation.Constant } @@ -678,7 +783,10 @@ public data class HttpHeaderRouteSelector( val value: String ) : RouteSelector(), RouteParameterComponent { - override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation { + override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation = + evaluateSync(context, segmentIndex) + + override fun evaluateSync(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation { val headers = context.call.request.headers[name] val parsedHeaders = parseAndSortHeader(headers) val header = parsedHeaders.firstOrNull { it.value.equals(value, ignoreCase = true) } @@ -703,7 +811,10 @@ internal data class ContentTypeHeaderRouteSelector( HttpStatusCode.UnsupportedMediaType ) - override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation { + override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation = + evaluateSync(context, segmentIndex) + + override fun evaluateSync(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation { val headers = context.call.request.header(HttpHeaders.ContentType) val parsedHeaders = parseAndSortContentTypeHeader(headers) @@ -734,6 +845,9 @@ public data class HttpAcceptRouteSelector( return delegate.evaluate(context, segmentIndex) } + override fun evaluateSync(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation? = + delegate.evaluateSync(context, segmentIndex) + override fun toString(): String = "(contentType:$contentType)" } @@ -748,7 +862,10 @@ public data class HttpMultiAcceptRouteSelector( val contentTypes: List ) : RouteSelector() { - override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation { + override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation = + evaluateSync(context, segmentIndex) + + override fun evaluateSync(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation { val acceptHeaderContent = context.call.request.headers[HttpHeaders.Accept] try { val parsedHeaders = parseAndSortContentTypeHeader(acceptHeaderContent) diff --git a/ktor-server/ktor-server-core/common/src/io/ktor/server/routing/RoutingPathTrie.kt b/ktor-server/ktor-server-core/common/src/io/ktor/server/routing/RoutingPathTrie.kt new file mode 100644 index 00000000000..b08afa35a38 --- /dev/null +++ b/ktor-server/ktor-server-core/common/src/io/ktor/server/routing/RoutingPathTrie.kt @@ -0,0 +1,457 @@ +/* + * Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.server.routing + +import io.ktor.http.* + +/** + * A fast-path index over the routing tree, keyed by constant path segments. + * + * The trie short-circuits routing resolution for the common case of "static" endpoints + * (e.g. `get("/hello") { ... }`), where the entire route consists of constant path + * segments terminating in an [HttpMethodRouteSelector] (or a leaf with handlers). + * + * For any incoming request, [lookup] either: + * - returns a [RoutingNode] when the request can be resolved unambiguously via the trie, or + * - returns `null` to signal that the caller must fall back to the regular DFS resolver + * (because either no path matches, or the matched path has ambiguity with non-constant + * siblings such as parameter/wildcard/tailcard/header/host selectors). + */ +internal class RoutingPathTrie private constructor(private val root: Node) { + + /** + * Attempts to resolve [segments] + [method] via the trie. Returns a cached + * [RoutingResolveResult.Success] when the resolution is unambiguous, or `null` if the + * caller must use DFS. + * + * The returned `Success` instance is shared across all requests that resolve to the same + * route via the fast path (parameters are always [Parameters.Empty], quality is always + * [RouteSelectorEvaluation.qualityConstant]), so the hot path allocates zero result objects. + * + * Note: this does NOT take HTTP method matching into account when ambiguity exists; it only + * returns a fast-path match for routes whose full ancestry consists of constant path / + * trailing-slash selectors with no sibling selectors capable of changing the outcome. + */ + fun lookup( + segments: List, + segmentsSize: Int, + method: HttpMethod, + hasTrailingSlash: Boolean, + ): RoutingResolveResult.Success? { + if (root.ambiguous) return null + + // Captured path parameters, lazily allocated only when a parameter-child match + // happens during the walk. + var capturedNames: ArrayList? = null + var capturedValues: ArrayList? = null + + var current: Node = root + var index = 0 + while (index < segmentsSize) { + if (current.ambiguous) return null + val segment = segments[index] + val constantNext = current.children[segment] + if (constantNext != null) { + current = constantNext + } else { + // No constant child for this segment. The trie may still resolve via a plain + // parameter child, BUT only if there are no fallback siblings at this node + // that could outrank a plain parameter match (e.g. a `PathSegmentParameter` + // with a prefix/suffix, which has quality 0.9 > plain param's 0.8). In that + // case we must defer to the slow DFS to preserve scoring semantics. + if (current.hasFallbackSibling) return null + val paramChild = current.parameterChild ?: return null + // Non-optional path parameters reject empty segments (e.g. the trailing empty + // segment produced for `/test/`), so we must mirror that here. + if (segment.isEmpty()) return null + if (capturedNames == null) { + capturedNames = ArrayList(2) + capturedValues = ArrayList(2) + } + capturedNames.add(paramChild.parameterName) + capturedValues!!.add(segment) + current = paramChild.node + } + index++ + } + + // All segments consumed. Resolve a final terminal node (handlers + optional method). + if (current.ambiguous) return null + + // If a trailing slash is in the URL, only TrailingSlash-extended nodes can match. + // For simplicity, the v1 trie does not specially handle TrailingSlashRouteSelector + // siblings (they're considered "complex" siblings and disqualify the entry). + if (hasTrailingSlash && current.requiresExactSlashHandling) return null + + val terminal = current.terminalResultFor(method) ?: return null + return withCapturedParameters(terminal, capturedNames, capturedValues) + } + + /** + * Ultra-fast lookup that operates directly on the raw, undecoded request `path` string and + * the request `method` — without allocating a [SegmentedPath], a [RoutingResolveContext], + * its scratch ArrayLists, or its `resolve` suspend continuation. + * + * Returns a cached [RoutingResolveResult.Success] when: + * - the trie is non-empty at the root, + * - the path contains no percent-encoded characters (callers must fall back to the slow + * path for those so decoded equality is honoured), + * - every segment matches a constant path child unambiguously, and + * - a terminal exists for [method]. + * + * Returns `null` in every other case to signal that the caller must build a full + * [RoutingResolveContext] and run the regular resolver. + */ + fun tryFastResolve(path: String, method: HttpMethod): RoutingResolveResult.Success? { + if (root.ambiguous) return null + val length = path.length + if (length == 0) return null + + // Defer all trailing-slash requests to the regular resolver. Trailing-slash semantics + // in Ktor depend on the [TrailingSlashRouteSelector], [IgnoreTrailingSlash] plugin, + // and the surrounding route shape; encoding all of this in the fast path is brittle. + // Bailing out here is cheap and only forfeits the fast path for `/foo/`-style URLs + // which are not the dominant hot path. + if (length > 1 && path[length - 1] == '/') return null + + var capturedNames: ArrayList? = null + var capturedValues: ArrayList? = null + + var current: Node = root + var i = 0 + // Skip a single leading '/'. + if (path[0] == '/') i = 1 + + while (i < length) { + if (current.ambiguous) return null + // Find the next '/' boundary. Bail out if we see a percent-encoded byte; the + // caller must fall back to the decoded slow path in that case. + var j = i + while (j < length) { + val c = path[j] + if (c == '/') break + if (c == '%') return null + j++ + } + if (j == i) { + // Adjacent '/' (e.g. "//"): skip the empty segment, matching `SegmentedPath`. + i = j + 1 + continue + } + // The trie keys are `String`s, so we have to materialise the segment to probe + // the children map. This is still cheaper than the full slow-path machinery. + val segment = if (i == 0 && j == length) path else path.substring(i, j) + val constantNext = current.children[segment] + if (constantNext != null) { + current = constantNext + } else { + // No constant child; only follow the plain parameter child when no fallback + // sibling could outrank it (see `lookup` for the rationale). + if (current.hasFallbackSibling) return null + val paramChild = current.parameterChild ?: return null + if (capturedNames == null) { + capturedNames = ArrayList(2) + capturedValues = ArrayList(2) + } + capturedNames.add(paramChild.parameterName) + capturedValues!!.add(segment) + current = paramChild.node + } + i = j + 1 + } + + if (current.ambiguous) return null + if (current.requiresExactSlashHandling) return null + val terminal = current.terminalResultFor(method) ?: return null + return withCapturedParameters(terminal, capturedNames, capturedValues) + } + + /** + * Returns [terminal] when no path parameters were captured along the trie walk (in which + * case the cached [terminal] can be reused as-is), or a fresh [RoutingResolveResult.Success] + * carrying the captured parameters when at least one parameter child was visited. Building + * the [Parameters] eagerly only on the parameter-route slow tail keeps the constant-path + * hot path completely allocation-free. + */ + private fun withCapturedParameters( + terminal: RoutingResolveResult.Success, + capturedNames: List?, + capturedValues: List?, + ): RoutingResolveResult.Success { + if (capturedNames == null) return terminal + val parameters = ParametersBuilder(capturedNames.size).apply { + for (idx in capturedNames.indices) { + append(capturedNames[idx], capturedValues!![idx]) + } + }.build() + // When at least one path parameter is captured, the effective resolve quality matches + // what the regular [findBestRoute] would compute: the minimum across the walked + // selectors. Constant segments contribute [qualityConstant] (1.0) and parameter + // segments contribute [qualityPathParameter] (0.8), so the minimum is always the + // parameter quality whenever any parameter child was visited. + return RoutingResolveResult.Success( + route = terminal.route, + parameters = parameters, + quality = RouteSelectorEvaluation.qualityPathParameter, + ) + } + + private class Node { + val children: MutableMap = HashMap() + + /** + * Parameter-capturing child for unmatched constants under this node. Set when the + * routing tree contains a [PathSegmentParameterRouteSelector] as a sibling alongside + * (or instead of) constant path children. The capturing child is tried *after* an + * exact constant match fails, mirroring how the regular DFS scoring prefers constants + * (quality 1.0) over parameters (quality 0.8). + */ + var parameterChild: ParameterChild? = null + + /** + * The [RoutingNode] that "owns" this trie node (constant path leaf in the routing tree), + * if any. May or may not have handlers itself. + */ + var routingNode: RoutingNode? = null + + /** + * Pre-built `Success` for [routingNode], reused across requests to avoid per-call + * allocation. Computed lazily on first lookup and then cached. + */ + var routingNodeResult: RoutingResolveResult.Success? = null + + /** + * Pre-built `Success` per HTTP method leaf. The map is populated when the children of + * [routingNode] are exclusively [HttpMethodRouteSelector] leaves with handlers. The + * cached `Success` is reused across requests so the fast path allocates nothing. + */ + val methodLeafResults: MutableMap = HashMap() + + /** + * If `true`, the trie cannot safely return a *constant* terminal match at or below + * this node and must defer the entire request to the slow DFS. Set when a sibling + * may produce a match with quality ≥ [RouteSelectorEvaluation.qualityConstant] (1.0) + * — e.g. a query/header/host/accept parameter selector, an `HttpMethodRouteSelector` + * that wraps additional sub-routes, a transparent wrapper that could hide anything, + * a regex selector, or multiple plain parameter siblings. + * + * Note: siblings with strictly lower quality than `qualityConstant` (e.g. a wildcard + * `static {}` block, a tailcard `{...}` catch-all) do **not** set this flag, because + * Ktor's routing scoring guarantees that a successful constant lookup outranks any + * such sibling. + */ + var ambiguous: Boolean = false + + /** + * If `true`, at least one sibling under this node may produce a *non-constant* match + * with quality strictly greater than [RouteSelectorEvaluation.qualityPathParameter] + * but less than [RouteSelectorEvaluation.qualityConstant] — e.g. a path parameter + * selector with a `prefix`/`suffix` (quality 0.9). When this flag is set the trie may + * still return a *constant* terminal hit (which has quality 1.0 and outranks the + * fallback sibling), but must **not** fall through to its plain `parameterChild` + * (whose 0.8 quality is below the sibling's 0.9). In that case the caller must defer + * to the slow DFS so the higher-quality prefix/suffix parameter sibling can match. + * + * Also set when there's a *low-quality* fallback sibling alongside a plain parameter + * child where the slow DFS still needs to run if the plain parameter mismatches. + */ + var hasFallbackSibling: Boolean = false + + /** + * Set to `true` when this node is reached through a route that participates in + * trailing-slash semantics in a way the trie cannot reason about. Forces fallback + * for requests whose URLs end with `/`. + */ + var requiresExactSlashHandling: Boolean = false + + fun terminalResultFor(method: HttpMethod): RoutingResolveResult.Success? { + // Method-specific leaf (e.g. `get("/hello")` creates a HttpMethod child). + methodLeafResults[method]?.let { return it } + // Or the constant-path node itself has handlers (e.g. `handle { }` on the path node). + val node = routingNode + if (node != null && node.handlers.isNotEmpty() && methodLeafResults.isEmpty()) { + return routingNodeResult + } + return null + } + } + + /** + * Trie node holding a parameter-capturing child and the parameter name to bind the matched + * segment to. Each captured segment must be materialised as a fresh [Parameters] entry per + * request (no cross-call caching is possible). + */ + private class ParameterChild( + val parameterName: String, + val node: Node, + ) + + companion object { + /** + * Builds a [RoutingPathTrie] from a routing [root]. The resulting trie covers only the + * subset of the routing tree that is safe for fast-path resolution; everything else is + * left for the DFS resolver to handle. + */ + fun build(root: RoutingNode): RoutingPathTrie { + val trieRoot = Node() + // Walk the root selector — it may itself be a [RootRouteSelector] with a constant + // prefix (e.g. `application.rootPath = "/api"`), which we model as a chain of + // constant segments at the very top of the trie. + val rootSelector = root.selector + if (rootSelector !is RootRouteSelector) { + // Root has a non-constant selector — disable the entire fast path. + trieRoot.ambiguous = true + return RoutingPathTrie(trieRoot) + } + // Descend through the constant root prefix (e.g. "/api/v1"). + var entryNode = trieRoot + for (part in rootSelector.rootParts) { + entryNode = entryNode.children.getOrPut(part) { Node() } + } + entryNode.routingNode = root + if (root.handlers.isNotEmpty()) { + entryNode.routingNodeResult = makeFastPathSuccess(root) + } + registerChildren(entryNode, root) + return RoutingPathTrie(trieRoot) + } + + /** + * Constructs a reusable, shared [RoutingResolveResult.Success] for [node]. The fast + * path never captures path parameters, so the parameter set is always empty. + */ + private fun makeFastPathSuccess(node: RoutingNode): RoutingResolveResult.Success = + RoutingResolveResult.Success( + route = node, + parameters = Parameters.Empty, + quality = RouteSelectorEvaluation.qualityConstant, + ) + + /** + * Registers all children of [routingNode] into [trieNode]. + * + * Children are classified into three buckets: + * - **Indexable children** (constant path, plain parameter, method leaf, trailing slash): + * inserted into [trieNode]'s structures so the trie can resolve them directly. + * - **Fallback children** (anything we cannot statically prove ranks above a constant + * match — wildcards, tailcards, parameter selectors with prefix/suffix, transparent + * wrappers, header/host/regex selectors, plugin selectors such as auth, etc.): the + * [trieNode] is marked with [Node.hasFallbackSibling] so the lookup defers to the + * slow path **only** when no constant child matched. The constant siblings are + * still indexed and remain usable for the common case (e.g. `staticResources("") + + * get("/hello")`, where `/hello` is a constant hit that outranks the tailcard). + * - **Truly ambiguous children**: multiple parameter siblings under the same node + * (the routing scoring rules between them are non-trivial), in which case the + * [trieNode] is marked [Node.ambiguous] and the entire subtree is left to DFS. + */ + private fun registerChildren(trieNode: Node, routingNode: RoutingNode) { + val children = routingNode.children + // First pass: count plain parameter siblings. The trie only supports a single + // parameter sibling under a node; with two or more, the DFS scoring rules become + // non-trivial (which one wins?), so we bail out and mark the node ambiguous. + var parameterCount = 0 + for (child in children) { + if (isPlainParameterChild(child)) { + parameterCount++ + } + } + if (parameterCount > 1) { + trieNode.ambiguous = true + return + } + for (child in children) { + when (val s = child.selector) { + is PathSegmentConstantRouteSelector -> { + val sub = trieNode.children.getOrPut(s.value) { Node() } + sub.routingNode = child + if (child.handlers.isNotEmpty()) { + sub.routingNodeResult = makeFastPathSuccess(child) + } + registerChildren(sub, child) + } + is PathSegmentParameterRouteSelector -> { + if (!isPlainParameterChild(child)) { + // Parameter selectors with prefix/suffix can match the same segment + // a plain constant would, with quality 0.9 (above plain param's 0.8 + // but below constant's 1.0). Treat them as low-quality fallback + // siblings: a constant trie hit still wins, but the trie must not + // fall through to its plain parameter child here. + trieNode.hasFallbackSibling = true + } else { + // Only plain `{name}` selectors (no prefix/suffix) participate in + // the fast path proper. + val sub = Node() + sub.routingNode = child + if (child.handlers.isNotEmpty()) { + sub.routingNodeResult = makeFastPathSuccess(child) + } + trieNode.parameterChild = ParameterChild(s.name, sub) + registerChildren(sub, child) + } + } + is HttpMethodRouteSelector -> { + if (child.handlers.isNotEmpty() && child.children.isEmpty()) { + trieNode.methodLeafResults[s.method] = makeFastPathSuccess(child) + } else { + // The method node has further sub-routes which the trie cannot + // reason about (their qualities could be anything, e.g. wrapping + // a query parameter selector). Mark the node ambiguous so the + // slow DFS handles the whole subtree. + trieNode.ambiguous = true + } + } + is TrailingSlashRouteSelector -> { + // V1 doesn't model trailing slash routes explicitly; mark the parent + // node as requiring exact slash handling so that we fall back when the + // request URL ends with '/'. + trieNode.requiresExactSlashHandling = true + } + is PathSegmentWildcardRouteSelector, + is PathSegmentTailcardRouteSelector, + is PathSegmentOptionalParameterRouteSelector -> { + // Low-quality path selectors (wildcards, tailcards, optional path + // parameters): these can never outrank a constant trie hit (their max + // quality is qualityPathParameter = 0.8, below constant's 1.0) but the + // trie must defer to DFS if the constant lookup misses so they get a + // chance to match. + trieNode.hasFallbackSibling = true + } + else -> { + // Anything else (query/header/host/accept parameter selectors, regex + // selectors, authentication selectors from plugins, `Or`/`And` + // composite selectors, transparent wrappers, custom selectors from + // user code or other plugins, etc.) is generally unsafe — it may + // produce a match with quality ≥ qualityConstant (1.0), in which case + // the trie's tie-breaking would not match the DFS's "first matching + // route wins" rule. *Unless* the selector statically advertises a + // [RouteSelector.maxQualityHint] strictly below `qualityConstant` + // (e.g. the static-content `TailcardSelector` with quality + // `qualityTailcard`), in which case we know a constant trie hit will + // always outrank it and we can treat it as a low-quality fallback + // sibling. On constant-miss the lookup defers to the slow DFS so the + // sibling still gets a chance to match. + val hint = child.selector.maxQualityHint + if (!hint.isNaN() && hint < RouteSelectorEvaluation.qualityConstant) { + trieNode.hasFallbackSibling = true + } else { + trieNode.ambiguous = true + } + } + } + } + } + + /** + * Returns `true` for plain `{name}` path-segment parameter selectors (no prefix/suffix). + * Selectors with a prefix/suffix are not safe to handle in the fast path because they + * partially match the segment string and would need the full DFS evaluation to + * preserve semantics with sibling constants. + */ + private fun isPlainParameterChild(node: RoutingNode): Boolean { + val s = node.selector + return s is PathSegmentParameterRouteSelector && s.prefix == null && s.suffix == null + } + } +} diff --git a/ktor-server/ktor-server-core/common/src/io/ktor/server/routing/RoutingResolveContext.kt b/ktor-server/ktor-server-core/common/src/io/ktor/server/routing/RoutingResolveContext.kt index 6f6884e066a..783fcbc09ec 100644 --- a/ktor-server/ktor-server-core/common/src/io/ktor/server/routing/RoutingResolveContext.kt +++ b/ktor-server/ktor-server-core/common/src/io/ktor/server/routing/RoutingResolveContext.kt @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + * Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. */ package io.ktor.server.routing @@ -33,24 +33,46 @@ public class RoutingResolveContext( */ public val segments: List + /** + * Cached segment count to avoid repeated O(n) `size` calls on the [SegmentedPath] view. + * Exposed `internal` so that hot selectors (in [RouteSelector] et al.) can compare + * `segmentIndex` against this value without re-traversing the path on every step. + */ + internal val segmentsSize: Int + /** * Flag showing if path ends with slash * * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.routing.RoutingResolveContext.hasTrailingSlash) */ - public val hasTrailingSlash: Boolean = call.request.path().endsWith('/') + public val hasTrailingSlash: Boolean private val trace: RoutingResolveTrace? - private val resolveResult: ArrayList = ArrayList(ROUTING_DEFAULT_CAPACITY) + // Lazily allocated by the DFS slow path. Skipped entirely when the constant-path fast + // path resolves the request (the overwhelmingly common case for static endpoints), which + // saves an ArrayList + backing Object[16] allocation per call. All slow-path helpers must + // go through [resolveResult] (a non-null getter that asserts the list is initialized). + private var resolveResultOrNull: ArrayList? = null + private val resolveResult: ArrayList + get() = resolveResultOrNull ?: error( + "Slow-path scratch list accessed before slow-path entry; " + + "this indicates a bug in the routing resolver." + ) private var failedEvaluation: RouteSelectorEvaluation.Failure? = RouteSelectorEvaluation.FailedPath private var failedEvaluationDepth = 0 init { try { - segments = parse(call.request.path()) - trace = if (tracers.isEmpty()) null else RoutingResolveTrace(call, segments) + // [ApplicationRequest.path()] allocates a fresh substring on every call. Compute it + // once here and feed it to both [parse] and the trailing-slash flag below. + val path = call.request.path() + hasTrailingSlash = path.endsWith('/') + val parsed = parse(path) + segments = parsed + segmentsSize = parsed.size + trace = if (tracers.isEmpty()) null else RoutingResolveTrace(call, parsed) } catch (cause: URLDecodeException) { throw BadRequestException("Url decode failed for ${call.request.uri}", cause) } @@ -58,29 +80,21 @@ public class RoutingResolveContext( private fun parse(path: String): List { if (path.isEmpty() || path == "/") return emptyList() - val length = path.length - var beginSegment = 0 - var nextSegment = 0 - val segmentCount = path.count { it == '/' } - val segments = ArrayList(segmentCount) - while (nextSegment < length) { - nextSegment = path.indexOf('/', beginSegment) - if (nextSegment == -1) { - nextSegment = length - } - if (nextSegment == beginSegment) { - // empty path segment, skip it - beginSegment = nextSegment + 1 - continue - } - val segment = path.decodeURLPart(beginSegment, nextSegment) - segments.add(segment) - beginSegment = nextSegment + 1 - } - if (!call.ignoreTrailingSlash && path.endsWith("/")) { - segments.add("") + // Eagerly validate URL encoding so that malformed inputs (e.g. truncated `%XX` + // sequences) surface as a single `BadRequestException` instead of being lazily + // detected per-segment by the routing fast path or selectors. `decodeURLPart` + // returns the same `String` instance when the input has no percent escapes, so + // this is allocation-free in the common case. + path.decodeURLPart() + // [SegmentedPath] tolerates a leading '/' (empty leading segments are skipped during + // iteration), so we can hand it the path string as-is and avoid an extra substring + // allocation in the common case. We only allocate a substring when [ignoreTrailingSlash] + // is enabled AND the path actually ends with '/' — otherwise SegmentedPath would emit a + // bogus trailing empty segment. + if (call.ignoreTrailingSlash && path.length > 1 && path[path.length - 1] == '/') { + return SegmentedPath(path.substring(0, path.length - 1)) } - return segments + return SegmentedPath(path) } /** @@ -89,13 +103,144 @@ public class RoutingResolveContext( * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.routing.RoutingResolveContext.resolve) */ public suspend fun resolve(): RoutingResolveResult { - handleRoute(routing, 0, ArrayList(), MIN_QUALITY) + // Try the constant-path fast path first. It returns a non-null result only when the + // request can be resolved unambiguously without considering parameter/wildcard/header + // or other complex selectors. We disable it when tracing is enabled so trace listeners + // still observe the full evaluation timeline they expect. + if (trace == null) { + val fastPathResult = tryResolveFastPath() + if (fastPathResult != null) { + return fastPathResult + } + } - val resolveResult = findBestRoute() + // Only allocate the DFS scratch lists once the slow path is actually entered. + resolveResultOrNull = ArrayList(ROUTING_DEFAULT_CAPACITY) + // Try the synchronous DFS first when tracing is disabled. It returns the resolved + // quality if it could complete without needing any suspending selector evaluation, + // and `null` if it had to bail out because a selector did not provide a synchronous + // `evaluateSync`. In the latter case we re-run the resolution using the (suspending) + // [handleRoute] so behaviour is identical to the original implementation. The vast + // majority of route trees consist entirely of built-in selectors and therefore stay + // on the synchronous path, which avoids allocating a `$handleRoute$1` coroutine + // continuation per recursive call. + // + // When tracing is enabled we go straight to the suspending DFS — running the sync DFS + // first would leave partial `RoutingResolveTrace` events behind if it bailed out + // mid-walk, and tracing is a debugging mode where the allocation savings don't matter. + val syncResolved = trace == null && + handleRouteSync(routing, 0, ArrayList(), MIN_QUALITY) != null + + if (!syncResolved) { + // Reset slow-path state and rerun via the suspending DFS to capture results from + // any custom user selector that genuinely requires suspension. If the sync DFS + // didn't run at all (tracing enabled) this is just a regular slow-path resolve. + resolveResultOrNull = ArrayList(ROUTING_DEFAULT_CAPACITY) + failedEvaluation = RouteSelectorEvaluation.FailedPath + failedEvaluationDepth = 0 + handleRoute(routing, 0, ArrayList(), MIN_QUALITY) + } + + val finalResult = findBestRoute() - trace?.registerFinalResult(resolveResult) + trace?.registerFinalResult(finalResult) trace?.apply { tracers.forEach { it(this) } } - return resolveResult + return finalResult + } + + private fun tryResolveFastPath(): RoutingResolveResult.Success? { + val root = routing as? RoutingRoot ?: return null + // Pure-constant fast path: the trie returns a *cached* Success instance for the matched + // route. No `Success`, parameters, or quality double is allocated per request. The + // standard pipeline still has access to query parameters via `call.request.queryParameters`. + return root.pathTrie.lookup( + segments = segments, + segmentsSize = segmentsSize, + method = call.request.httpMethod, + hasTrailingSlash = hasTrailingSlash, + ) + } + + /** + * Non-suspending counterpart of [handleRoute]. + * + * Returns the resolved quality (mirroring [handleRoute]) when the entire DFS sub-walk + * could be performed using [RouteSelector.evaluateSync], or `null` when any selector in + * the visited subtree does not provide a synchronous evaluation and the caller must + * therefore re-run resolution using the suspending [handleRoute]. The function bails out + * eagerly — once it sees a selector without a sync form, it short-circuits and unwinds + * without touching [resolveResult] or [failedEvaluation], so the caller can safely reset + * state before re-running. + * + * Keeping this function non-suspending is the whole point: the Kotlin compiler does not + * generate a `$handleRouteSync$1` continuation, so the recursive DFS allocates **zero** + * coroutine continuation objects when every selector is synchronous (the common case for + * built-in selectors and the request path that the user is profiling). + * + * Only invoked when [trace] is `null` (see [resolve]). All `trace.skip/begin/finish` calls + * are deliberately omitted here because the sync DFS is never used while tracing is + * active — running it speculatively would risk recording partial events that get + * duplicated when the suspending DFS reruns. + */ + private fun handleRouteSync( + entry: RoutingNode, + segmentIndex: Int, + trait: ArrayList, + matchedQuality: Double + ): Double? { + val evaluation = entry.selector.evaluateSync(this, segmentIndex) ?: return null + + if (evaluation is RouteSelectorEvaluation.Failure) { + if (segmentIndex == segmentsSize) { + updateFailedEvaluation(evaluation, trait) + } + return MIN_QUALITY + } + + check(evaluation is RouteSelectorEvaluation.Success) + + if (evaluation.quality != RouteSelectorEvaluation.qualityTransparent && + evaluation.quality < matchedQuality + ) { + return MIN_QUALITY + } + + val newIndex = segmentIndex + evaluation.segmentIncrement + + if (entry.children.isEmpty() && newIndex != segmentsSize) { + return MIN_QUALITY + } + + // Allocating the `Success` result is deferred until after the early-exit checks + // above: previously these branches built a `Success` and immediately discarded it. + val result = RoutingResolveResult.Success(entry, evaluation.parameters, evaluation.quality) + + trait.add(result) + + val hasHandlers = entry.handlers.isNotEmpty() + var bestSucceedChildQuality: Double = MIN_QUALITY + + if (hasHandlers && newIndex == segmentsSize) { + if (resolveResult.isEmpty() || isBetterResolve(trait)) { + bestSucceedChildQuality = evaluation.quality + resolveResult.clear() + resolveResult.addAll(trait) + failedEvaluation = null + } + } + + for (childIndex in 0..entry.children.lastIndex) { + val child = entry.children[childIndex] + val childQuality = handleRouteSync(child, newIndex, trait, bestSucceedChildQuality) + ?: return null + if (childQuality > 0) { + bestSucceedChildQuality = max(bestSucceedChildQuality, childQuality) + } + } + + trait.removeLast() + + return if (bestSucceedChildQuality > 0) evaluation.quality else MIN_QUALITY } private suspend fun handleRoute( @@ -112,7 +257,7 @@ public class RoutingResolveContext( segmentIndex, RoutingResolveResult.Failure(entry, "Selector didn't match", evaluation.failureStatusCode) ) - if (segmentIndex == segments.size) { + if (segmentIndex == segmentsSize) { updateFailedEvaluation(evaluation, trait) } return MIN_QUALITY @@ -134,7 +279,7 @@ public class RoutingResolveContext( val result = RoutingResolveResult.Success(entry, evaluation.parameters, evaluation.quality) val newIndex = segmentIndex + evaluation.segmentIncrement - if (entry.children.isEmpty() && newIndex != segments.size) { + if (entry.children.isEmpty() && newIndex != segmentsSize) { trace?.skip( entry, newIndex, @@ -150,7 +295,7 @@ public class RoutingResolveContext( val hasHandlers = entry.handlers.isNotEmpty() var bestSucceedChildQuality: Double = MIN_QUALITY - if (hasHandlers && newIndex == segments.size) { + if (hasHandlers && newIndex == segmentsSize) { if (resolveResult.isEmpty() || isBetterResolve(trait)) { bestSucceedChildQuality = evaluation.quality resolveResult.clear() diff --git a/ktor-server/ktor-server-core/common/src/io/ktor/server/routing/RoutingRoot.kt b/ktor-server/ktor-server-core/common/src/io/ktor/server/routing/RoutingRoot.kt index 64072d491c2..a8e7f6d59c7 100644 --- a/ktor-server/ktor-server-core/common/src/io/ktor/server/routing/RoutingRoot.kt +++ b/ktor-server/ktor-server-core/common/src/io/ktor/server/routing/RoutingRoot.kt @@ -40,6 +40,19 @@ public class RoutingRoot( Routing { private val tracers = mutableListOf<(RoutingResolveTrace) -> Unit>() + /** + * Lazy, cached path-only fast-path index over this routing tree. + * + * The trie is built on first access using a snapshot of the current tree and reused for + * the lifetime of this [RoutingRoot]. It is intentionally not invalidated on dynamic + * route additions in this revision; routes that are added after the first request will + * still resolve correctly via the DFS fallback, but will not benefit from fast-path + * resolution until the cache is rebuilt. + * + * TODO: support invalidation when the routing tree is mutated at runtime. + */ + internal val pathTrie: RoutingPathTrie by lazy { RoutingPathTrie.build(this) } + init { addDefaultTracing() } @@ -67,6 +80,18 @@ public class RoutingRoot( @OptIn(InternalAPI::class) public suspend fun interceptor(context: PipelineContext) { + // Fast path: when there are no tracers and the routing tree is amenable to constant + // path resolution, bypass the [RoutingResolveContext] allocation (and its `resolve` + // continuation, scratch ArrayLists, and per-segment parsing) entirely. + if (tracers.isEmpty()) { + val call = context.call + val fast = pathTrie.tryFastResolve(call.request.path(), call.request.httpMethod) + if (fast != null) { + executeResult(context, fast.route, fast.parameters) + return + } + } + val resolveContext = RoutingResolveContext(this, context.call, tracers) when (val resolveResult = resolveContext.resolve()) { is RoutingResolveResult.Success -> diff --git a/ktor-server/ktor-server-core/common/src/io/ktor/server/routing/SegmentedPath.kt b/ktor-server/ktor-server-core/common/src/io/ktor/server/routing/SegmentedPath.kt new file mode 100644 index 00000000000..666db05af1c --- /dev/null +++ b/ktor-server/ktor-server-core/common/src/io/ktor/server/routing/SegmentedPath.kt @@ -0,0 +1,151 @@ +/* + * Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.server.routing + +import io.ktor.http.* +import kotlin.jvm.JvmInline + +private const val DELIMITER = '/' + +/** + * A zero-allocation view of a request path as an ordered list of URL-decoded segments. + * + * `SegmentedPath` is an inline value class wrapping a single `String`: the raw, still-encoded + * request path *without a leading slash*. Iteration and indexed access produce decoded + * segments lazily (via [String.decodeURLPart]), so the routing fast-path can match constant + * segments using cheap region comparisons against the wrapped string and only pay the + * per-segment substring/decode cost when a slow-path selector actually inspects the value. + * + * Segment splitting: + * - Empty segments produced by consecutive `'/'` characters are skipped, matching the + * pre-existing parser behavior. + * - A trailing empty segment is emitted when the wrapped string ends with `'/'`. Callers + * that want trailing-slash-insensitive routing must strip the trailing slash before + * constructing a `SegmentedPath`. + * + * Decoding: + * - URL decoding is applied per-segment, not over the whole path. This preserves the + * semantics of an encoded `'/'` inside a segment (e.g. `%2F`), which must not be treated + * as a path separator. + * + * This class deliberately implements [List]<[String]> to be a drop-in replacement for the + * previous `List` parse result while remaining allocation-free at construction. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.routing.SegmentedPath) + */ +@JvmInline +public value class SegmentedPath internal constructor( + private val string: String +) : List { + + /** + * Iterates each segment's raw `[start, end)` range in [string], invoking [action] + * with the segment index and range. Empty segments are skipped, with the sole exception + * of a trailing empty segment when [string] ends with `'/'`. + */ + private inline fun forEachSegmentRange(action: (index: Int, start: Int, end: Int) -> Unit) { + val len = string.length + if (len == 0) return + var i = 0 + var count = 0 + while (i < len) { + val next = string.indexOf(DELIMITER, i) + val end = if (next == -1) len else next + if (end != i) { + action(count++, i, end) + } + if (next == -1) return + i = next + 1 + } + // String ended with '/': emit a trailing empty segment. + action(count, len, len) + } + + private inline fun forEachSegment(action: (Int, String) -> Unit) { + forEachSegmentRange { index, start, end -> + action(index, decodeSegment(start, end)) + } + } + + private fun decodeSegment(start: Int, end: Int): String = + if (start == end) "" else string.decodeURLPart(start, end) + + override val size: Int + get() { + var count = 0 + forEachSegmentRange { _, _, _ -> count++ } + return count + } + + override fun isEmpty(): Boolean = string.isEmpty() + + override fun get(index: Int): String { + if (index < 0) throw IndexOutOfBoundsException("Index $index is out of bounds") + var result: String? = null + forEachSegmentRange { i, start, end -> + if (i == index) { + result = decodeSegment(start, end) + return@forEachSegmentRange + } + } + return result ?: throw IndexOutOfBoundsException("Index $index is out of bounds") + } + + override fun contains(element: String): Boolean = indexOf(element) >= 0 + + override fun containsAll(elements: Collection): Boolean = elements.all { contains(it) } + + override fun indexOf(element: String): Int { + var result = -1 + forEachSegment { i, segment -> + if (result == -1 && segment == element) result = i + } + return result + } + + override fun lastIndexOf(element: String): Int { + var result = -1 + forEachSegment { i, segment -> + if (segment == element) result = i + } + return result + } + + override fun iterator(): Iterator = listIterator(0) + + override fun listIterator(): ListIterator = listIterator(0) + + override fun listIterator(index: Int): ListIterator { + // We need a materialized list once a caller asks for a ListIterator (random access + // backwards isn't possible from the raw string alone). This path is intentionally + // kept for slow-path compatibility and is not hit by the fast-path resolver. + return toMaterializedList().listIterator(index) + } + + override fun subList(fromIndex: Int, toIndex: Int): List { + require(fromIndex in 0..toIndex) { "fromIndex=$fromIndex, toIndex=$toIndex" } + return toMaterializedList().subList(fromIndex, toIndex) + } + + private fun toMaterializedList(): List { + val list = ArrayList() + forEachSegment { _, segment -> list.add(segment) } + return list + } + + /** + * Produces a [List]<[String]>-style representation so that diagnostics and trace output + * (which historically saw a `List` here) continue to render identically. + */ + override fun toString(): String { + val sb = StringBuilder().append('[') + forEachSegment { i, segment -> + if (i > 0) sb.append(", ") + sb.append(segment) + } + sb.append(']') + return sb.toString() + } +} diff --git a/ktor-server/ktor-server-core/jvm/src/io/ktor/server/http/content/StaticContent.kt b/ktor-server/ktor-server-core/jvm/src/io/ktor/server/http/content/StaticContent.kt index 6fe538cf4a4..b6d304e026c 100644 --- a/ktor-server/ktor-server-core/jvm/src/io/ktor/server/http/content/StaticContent.kt +++ b/ktor-server/ktor-server-core/jvm/src/io/ktor/server/http/content/StaticContent.kt @@ -963,7 +963,18 @@ public interface FileSystemPaths { // Adds lower priority to the route so that it can be used as a fallback private object TailcardSelector : RouteSelector() { override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation = + evaluateInternal() + + override fun evaluateSync(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation = + evaluateInternal() + + private fun evaluateInternal(): RouteSelectorEvaluation = RouteSelectorEvaluation.Success(quality = RouteSelectorEvaluation.qualityTailcard) + // Lets the routing fast-path index treat this wrapper as a low-quality sibling so that + // constant siblings (e.g. `get("/hello")` next to `staticResources("")`) can still be + // resolved via the trie. See `RouteSelector.maxQualityHint`. + override val maxQualityHint: Double get() = RouteSelectorEvaluation.qualityTailcard + override fun toString(): String = "(static-content)" } diff --git a/ktor-server/ktor-server-netty/jvm/test/io/ktor/tests/server/netty/NettySpecificTest.kt b/ktor-server/ktor-server-netty/jvm/test/io/ktor/tests/server/netty/NettySpecificTest.kt index f46fe840ac9..ca6c9593300 100644 --- a/ktor-server/ktor-server-netty/jvm/test/io/ktor/tests/server/netty/NettySpecificTest.kt +++ b/ktor-server/ktor-server-netty/jvm/test/io/ktor/tests/server/netty/NettySpecificTest.kt @@ -39,10 +39,12 @@ import java.io.IOException import java.net.BindException import java.net.ServerSocket import java.util.concurrent.ExecutorService +import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference import kotlin.coroutines.EmptyCoroutineContext import kotlin.test.* import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds class NettySpecificTest { diff --git a/ktor-server/ktor-server-tests/common/test/io/ktor/tests/server/routing/RoutingFastPathTest.kt b/ktor-server/ktor-server-tests/common/test/io/ktor/tests/server/routing/RoutingFastPathTest.kt new file mode 100644 index 00000000000..c7540c37023 --- /dev/null +++ b/ktor-server/ktor-server-tests/common/test/io/ktor/tests/server/routing/RoutingFastPathTest.kt @@ -0,0 +1,274 @@ +/* + * Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.tests.server.routing + +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.server.testing.* +import kotlin.test.* + +/** + * Verifies that the constant-path routing fast path produces the same observable behavior as + * the regular DFS resolver across a range of common routing shapes. + */ +class RoutingFastPathTest { + + @Test + fun simpleConstantPathResolves() = testApplication { + routing { + get("/hello") { call.respondText("hello") } + } + val response = client.get("/hello") + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("hello", response.bodyAsText()) + } + + @Test + fun nestedConstantPathResolves() = testApplication { + routing { + route("/api") { + route("/v1") { + get("/ping") { call.respondText("pong") } + } + } + } + val response = client.get("/api/v1/ping") + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("pong", response.bodyAsText()) + } + + @Test + fun unknownConstantPathReturnsNotFound() = testApplication { + routing { + get("/hello") { call.respondText("hi") } + } + assertEquals(HttpStatusCode.NotFound, client.get("/world").status) + } + + @Test + fun unsupportedMethodReturnsMethodNotAllowed() = testApplication { + routing { + get("/hello") { call.respondText("hi") } + } + assertEquals(HttpStatusCode.MethodNotAllowed, client.post("/hello").status) + } + + @Test + fun multipleMethodsOnSamePathBothResolve() = testApplication { + routing { + get("/r") { call.respondText("g") } + post("/r") { call.respondText("p") } + } + assertEquals("g", client.get("/r").bodyAsText()) + assertEquals("p", client.post("/r").bodyAsText()) + } + + @Test + fun constantSiblingTakesPrecedenceOverPathParameter() = testApplication { + routing { + route("/users") { + get("/me") { call.respondText("me") } + get("/{id}") { + val id = call.parameters["id"] + call.respondText("id=$id") + } + } + } + assertEquals("me", client.get("/users/me").bodyAsText()) + assertEquals("id=42", client.get("/users/42").bodyAsText()) + } + + @Test + fun constantSiblingCoexistsWithTailcard() = testApplication { + routing { + get("/files/list") { call.respondText("list") } + get("/files/{path...}") { + val parts = call.parameters.getAll("path") + call.respondText("path=$parts") + } + } + assertEquals("list", client.get("/files/list").bodyAsText()) + assertEquals("path=[a, b, c]", client.get("/files/a/b/c").bodyAsText()) + } + + @Test + fun transparentWrapperKeepsCorrectResolution() = testApplication { + routing { + route("/secured") { + transparent { + get { call.respondText("ok") } + } + } + } + val response = client.get("/secured") + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("ok", response.bodyAsText()) + } + + @Test + fun parentPipelineStillExecutesForFastPath() = testApplication { + var interceptorRan = false + routing { + route("/v1") { + intercept(ApplicationCallPipeline.Plugins) { interceptorRan = true } + get("/ping") { call.respondText("pong") } + } + } + assertEquals("pong", client.get("/v1/ping").bodyAsText()) + assertTrue(interceptorRan, "Ancestor interceptor must run on fast-path-resolved calls") + } + + @Test + fun repeatedRequestsHitCachedTrie() = testApplication { + routing { + get("/a") { call.respondText("A") } + get("/b") { call.respondText("B") } + } + repeat(10) { + assertEquals("A", client.get("/a").bodyAsText()) + assertEquals("B", client.get("/b").bodyAsText()) + } + } + + @Test + fun percentEncodedPathFallsBackToSlowPath() = testApplication { + // The fast path must defer to the slow resolver when the request path contains a + // percent-encoded byte, so that decoded equality is honoured: `/hi%20there` + // (i.e. "hi there") must match a route registered as `/hi there`. + routing { + get("/hi there") { call.respondText("hello") } + } + val response = client.get("/hi%20there") + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("hello", response.bodyAsText()) + } + + @Test + fun parameterRouteResolvesViaFastPath() = testApplication { + routing { + get("/users/{id}") { + call.respondText("user=${call.parameters["id"]}") + } + } + assertEquals("user=42", client.get("/users/42").bodyAsText()) + assertEquals("user=abc", client.get("/users/abc").bodyAsText()) + } + + @Test + fun parameterRouteMissingRequiredSegmentFallsBack() = testApplication { + // `GET /users/` (trailing slash) must NOT match `get("/users/{id}")` because the + // non-optional parameter rejects empty segments. The fast path needs to bail out so + // that the slow path produces a 404. + routing { + get("/users/{id}") { call.respondText("user=${call.parameters["id"]}") } + } + assertEquals(HttpStatusCode.NotFound, client.get("/users/").status) + assertEquals(HttpStatusCode.NotFound, client.get("/users").status) + } + + @Test + fun multipleParametersInSamePathResolve() = testApplication { + routing { + get("/users/{userId}/posts/{postId}") { + val userId = call.parameters["userId"] + val postId = call.parameters["postId"] + call.respondText("u=$userId,p=$postId") + } + } + assertEquals("u=10,p=20", client.get("/users/10/posts/20").bodyAsText()) + } + + @Test + fun parameterRouteDoesNotShadowSibling() = testApplication { + // Re-checks the constant-vs-parameter sibling ordering for the parameter fast path. + routing { + route("/x") { + get("/static") { call.respondText("static") } + get("/{any}") { call.respondText("param=${call.parameters["any"]}") } + } + } + assertEquals("static", client.get("/x/static").bodyAsText()) + assertEquals("param=other", client.get("/x/other").bodyAsText()) + } + + @Test + fun parameterRouteWithPrefixSuffixDefersToSlowPath() = testApplication { + // `{id}.html`-style parameter selectors carry a prefix/suffix and are deliberately not + // handled by the trie fast path. They must still resolve correctly via the slow path. + routing { + get("/items/{id}.html") { call.respondText("html=${call.parameters["id"]}") } + } + assertEquals("html=42", client.get("/items/42.html").bodyAsText()) + } + + @Test + fun trailingSlashDefersToSlowPath() = testApplication { + // The fast path must defer to the slow resolver for trailing-slash URLs so that + // strict trailing-slash semantics (separate `/foo` and `/foo/` routes) keep working. + routing { + get("/foo") { call.respondText("foo") } + get("/foo/") { call.respondText("foo-slash") } + } + assertEquals("foo", client.get("/foo").bodyAsText()) + assertEquals("foo-slash", client.get("/foo/").bodyAsText()) + } + + @Test + fun constantSiblingResolvesAlongsideRootLevelTailcard() = testApplication { + // Regression test for the benchmark scenario: a wildcard / tailcard catch-all at the + // routing root (as installed by `staticResources("")` in the user's hello-world + // benchmark) must not stop the constant `/hello` and `/clear` endpoints from + // resolving via the fast path. A constant trie hit (quality 1.0) always outranks a + // root-level tailcard (quality ≤ 0.5), so the fast path is safe. + routing { + // Stand-in for `staticResources("")` — same selector shape: a `{path...}` + // tailcard at the routing root that responds 404 by default. + get("/{path...}") { call.respondText("static:${call.parameters.getAll("path")}") } + + get("/hello") { call.respondText("Hello, World!") } + get("/clear") { call.respondText("clear") } + } + // Hot path: must resolve through the trie regardless of the tailcard sibling. + assertEquals("Hello, World!", client.get("/hello").bodyAsText()) + assertEquals("clear", client.get("/clear").bodyAsText()) + // Slow-path fallback: paths not covered by the constant trie still resolve correctly + // against the tailcard. + assertEquals("static:[some, other]", client.get("/some/other").bodyAsText()) + } + + @Test + fun queryParameterSiblingDefersToSlowPath() = testApplication { + // Query/header parameter selectors have `qualityConstant` (1.0), the same as a + // constant path match. Tie-breaking is by route registration order, which the trie + // cannot model — so the trie must mark the parent node ambiguous and defer the + // entire subtree to the slow DFS. + routing { + route("/test") { + param("p") { handle { call.respondText("param") } } + get { call.respondText("get") } + } + } + assertEquals("param", client.get("/test?p=v").bodyAsText()) + assertEquals("get", client.get("/test").bodyAsText()) + } + + private fun Route.transparent(build: Route.() -> Unit): Route { + val route = createChild( + object : RouteSelector() { + override suspend fun evaluate( + context: RoutingResolveContext, + segmentIndex: Int, + ): RouteSelectorEvaluation = + RouteSelectorEvaluation.Success(RouteSelectorEvaluation.qualityTransparent) + } + ) + route.build() + return route + } +} diff --git a/ktor-server/ktor-server-tests/jvm/test/io/ktor/tests/server/routing/RoutingFastPathJvmTest.kt b/ktor-server/ktor-server-tests/jvm/test/io/ktor/tests/server/routing/RoutingFastPathJvmTest.kt new file mode 100644 index 00000000000..4520b6d38fb --- /dev/null +++ b/ktor-server/ktor-server-tests/jvm/test/io/ktor/tests/server/routing/RoutingFastPathJvmTest.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2014-2026 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.tests.server.routing + +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.http.content.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.server.testing.* +import kotlin.test.* + +/** + * JVM-only fast-path tests that depend on `staticResources` (a JVM-only API). These + * complement [RoutingFastPathTest] in the common source set. + */ +class RoutingFastPathJvmTest { + + @Test + fun constantRoutesResolveAlongsideStaticResources() = testApplication { + // Mirrors the user's hello-world benchmark layout exactly. The catch-all installed by + // `staticResources("")` must not disable the routing fast path for the constant + // `/hello` and `/clear` endpoints; otherwise every request would needlessly fall + // through the slow DFS resolver. + routing { + staticResources("", basePackage = "io/ktor/server/http/content") + + get("/hello") { call.respondText("Hello, World!") } + get("/clear") { call.respond(HttpStatusCode.OK) } + } + + // The constant siblings must still resolve, even though the static-content tailcard + // wrapper is registered at the same routing-root level. + assertEquals("Hello, World!", client.get("/hello").bodyAsText()) + assertEquals(HttpStatusCode.OK, client.get("/clear").status) + } +} From 2f5ec533890dd46e74431a7dbe968b754f8fed95 Mon Sep 17 00:00:00 2001 From: Bruce Hamilton Date: Tue, 9 Jun 2026 21:36:32 +0200 Subject: [PATCH 2/3] Re-use connection coroutine elements --- .../server/netty/http1/NettyHttp1Handler.kt | 49 ++++++++++++++++--- .../server/netty/http2/NettyHttp2Handler.kt | 20 ++++++-- .../server/netty/http3/NettyHttp3Handler.kt | 18 +++++-- 3 files changed, 71 insertions(+), 16 deletions(-) diff --git a/ktor-server/ktor-server-netty/jvm/src/io/ktor/server/netty/http1/NettyHttp1Handler.kt b/ktor-server/ktor-server-netty/jvm/src/io/ktor/server/netty/http1/NettyHttp1Handler.kt index 7746bdf9c2a..a997b8d5e86 100644 --- a/ktor-server/ktor-server-netty/jvm/src/io/ktor/server/netty/http1/NettyHttp1Handler.kt +++ b/ktor-server/ktor-server-netty/jvm/src/io/ktor/server/netty/http1/NettyHttp1Handler.kt @@ -17,11 +17,13 @@ import io.netty.channel.ChannelHandlerContext import io.netty.channel.ChannelInboundHandlerAdapter import io.netty.handler.codec.http.* import io.netty.handler.timeout.ReadTimeoutException +import io.netty.util.concurrent.EventExecutor import io.netty.util.concurrent.EventExecutorGroup import kotlinx.coroutines.* import java.io.IOException import java.util.concurrent.ConcurrentLinkedQueue import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.cancellation.CancellationException internal class NettyHttp1Handler( @@ -45,6 +47,13 @@ internal class NettyHttp1Handler( private var activated = false + // Per-channel cache of the connection-stable portion of the per-call coroutine context. + // The dispatcher, user context, application context, and coroutine name are reused across + // all requests on this connection, so we build them once and combine only with the per-call + // [Job] on each request. + private var cachedApplication: Application? = null + private var cachedBaseContext: CoroutineContext = EmptyCoroutineContext + override fun channelActive(context: ChannelHandlerContext) { // channelActive may be fired more than once on this handler (for example, when the pipeline is // reconfigured during an HTTP/2 cleartext upgrade or via an explicit fireChannelActive call @@ -154,15 +163,41 @@ internal class NettyHttp1Handler( super.channelReadComplete(context) } - private fun handleRequest(context: ChannelHandlerContext, message: HttpRequest) { - val userAppContext = applicationProvider().coroutineContext + userContext - val callJob = Job(parent = userAppContext[Job]) - - val callExecutor = pinnedCallExecutor(context, callEventGroup) - val callContext = userAppContext + + /** + * Returns the connection-stable portion of the per-call coroutine context, building it lazily on + * the first request and refreshing it if the running [Application] reference changes (for example, + * after a hot reload). + * + * Combining the application/user/dispatcher/name elements is the same on every request, so caching + * them avoids a chain of `CombinedContext` allocations per call; only the per-call [Job] is added + * fresh in [handleRequest]. + */ + private fun baseCallContext( + context: ChannelHandlerContext, + callExecutor: EventExecutor + ): CoroutineContext { + val application = applicationProvider() + val cached = cachedBaseContext + if (cachedApplication === application && cached !== EmptyCoroutineContext) { + return cached + } + val fresh = application.coroutineContext + + userContext + NettyDispatcher.CurrentContext(context, callExecutor) + - callJob + CallHandlerCoroutineName + cachedApplication = application + cachedBaseContext = fresh + return fresh + } + + private fun handleRequest(context: ChannelHandlerContext, message: HttpRequest) { + val callExecutor = pinnedCallExecutor(context, callEventGroup) + val baseContext = baseCallContext(context, callExecutor) + val callJob = Job(parent = baseContext[Job]) + + // Only the per-call [Job] is combined per request; the rest of the context is cached on the + // handler instance and reused across all calls on this connection. + val callContext = baseContext + callJob val call = prepareCallFromRequest(context, message, callContext = callContext) activeCalls.add(call) diff --git a/ktor-server/ktor-server-netty/jvm/src/io/ktor/server/netty/http2/NettyHttp2Handler.kt b/ktor-server/ktor-server-netty/jvm/src/io/ktor/server/netty/http2/NettyHttp2Handler.kt index 903292ae4b6..97e8ea999f5 100644 --- a/ktor-server/ktor-server-netty/jvm/src/io/ktor/server/netty/http2/NettyHttp2Handler.kt +++ b/ktor-server/ktor-server-netty/jvm/src/io/ktor/server/netty/http2/NettyHttp2Handler.kt @@ -36,6 +36,16 @@ internal class NettyHttp2Handler( ) : ChannelInboundHandlerAdapter() { private val handlerJob = SupervisorJob(userCoroutineContext[Job]) + // Connection-stable portion of the per-call coroutine context. Cached at construction so each + // request only needs to combine it with the per-stream dispatcher and the per-call [Job]. + private val staticCallContext: CoroutineContext = userCoroutineContext + CallHandlerCoroutineName + + // Parent [Job] for per-call [Job]s. Cached to avoid re-running `userCoroutineContext[Job]` per request. + private val parentJob: Job? = userCoroutineContext[Job] + + // Engine context exposed on the [NettyHttp2ApplicationCall]. Constant per handler instance. + private val callEngineContext: CoroutineContext = handlerJob + Dispatchers.Unconfined + private val state = NettyHttpHandlerState(runningLimit) private lateinit var responseWriter: NettyHttpResponsePipeline @@ -111,18 +121,18 @@ internal class NettyHttp2Handler( } private fun startHttp2(context: ChannelHandlerContext, headers: Http2Headers) { - val callJob = Job(parent = userCoroutineContext[Job]) + val callJob = Job(parent = parentJob) val callExecutor = pinnedCallExecutor(context, callEventGroup) - val callContext = userCoroutineContext + + // Combine the cached static context with the per-stream dispatcher and per-call [Job] only. + val callContext = staticCallContext + NettyDispatcher.CurrentContext(context, callExecutor) + - callJob + - CallHandlerCoroutineName + callJob val call = NettyHttp2ApplicationCall( application = application, context = context, headers = headers, handler = this@NettyHttp2Handler, - engineContext = handlerJob + Dispatchers.Unconfined, + engineContext = callEngineContext, coroutineContext = callContext ) context.applicationCall = call diff --git a/ktor-server/ktor-server-netty/jvm/src/io/ktor/server/netty/http3/NettyHttp3Handler.kt b/ktor-server/ktor-server-netty/jvm/src/io/ktor/server/netty/http3/NettyHttp3Handler.kt index 0c62f63e6d9..96c38fc9f8e 100644 --- a/ktor-server/ktor-server-netty/jvm/src/io/ktor/server/netty/http3/NettyHttp3Handler.kt +++ b/ktor-server/ktor-server-netty/jvm/src/io/ktor/server/netty/http3/NettyHttp3Handler.kt @@ -27,6 +27,16 @@ internal class NettyHttp3Handler( ) : Http3RequestStreamInboundHandler(), CoroutineScope { private val handlerJob = SupervisorJob(userCoroutineContext[Job]) + // Connection-stable portion of the per-call coroutine context. Cached so each request only needs + // to combine it with the per-stream dispatcher and the per-call [Job]. + private val staticCallContext: CoroutineContext = userCoroutineContext + CallHandlerCoroutineName + + // Parent [Job] for per-call [Job]s. Cached to avoid re-running `userCoroutineContext[Job]` per request. + private val parentJob: Job? = userCoroutineContext[Job] + + // Engine context exposed on the [NettyHttp3ApplicationCall]. Constant per handler instance. + private val callEngineContext: CoroutineContext = handlerJob + Dispatchers.Unconfined + private val state = NettyHttpHandlerState(runningLimit) private lateinit var responseWriter: NettyHttpResponsePipeline @@ -87,14 +97,14 @@ internal class NettyHttp3Handler( } private fun startHttp3(context: ChannelHandlerContext, headers: Http3Headers) { - val callJob = Job(parent = userCoroutineContext[Job]) - val callContext = - userCoroutineContext + NettyDispatcher.CurrentContext(context) + callJob + CallHandlerCoroutineName + val callJob = Job(parent = parentJob) + // Combine the cached static context with the per-stream dispatcher and per-call [Job] only. + val callContext = staticCallContext + NettyDispatcher.CurrentContext(context) + callJob val call = NettyHttp3ApplicationCall( application, context, headers, - handlerJob + Dispatchers.Unconfined, + callEngineContext, callContext ) context.applicationCall = call From c09b56d07ede72788c4861e164bbe9a1cc308f43 Mon Sep 17 00:00:00 2001 From: Bruce Hamilton Date: Wed, 10 Jun 2026 11:07:51 +0200 Subject: [PATCH 3/3] Avoid manual join for writer job in favour of structured concurrency --- .../ktor/server/netty/NettyApplicationCall.kt | 73 ++++++++++++------- .../server/netty/NettyApplicationEngine.kt | 5 ++ .../netty/cio/NettyHttpResponsePipeline.kt | 6 ++ 3 files changed, 58 insertions(+), 26 deletions(-) diff --git a/ktor-server/ktor-server-netty/jvm/src/io/ktor/server/netty/NettyApplicationCall.kt b/ktor-server/ktor-server-netty/jvm/src/io/ktor/server/netty/NettyApplicationCall.kt index 623b1c9d88c..ad2e3255213 100644 --- a/ktor-server/ktor-server-netty/jvm/src/io/ktor/server/netty/NettyApplicationCall.kt +++ b/ktor-server/ktor-server-netty/jvm/src/io/ktor/server/netty/NettyApplicationCall.kt @@ -29,7 +29,35 @@ public abstract class NettyApplicationCall( */ internal lateinit var finishedEvent: ChannelPromise - public val responseWriteJob: Job = Job() + /** + * Tracks the lifetime of the response write on the Netty I/O thread. + * + * This Job is a child of the call's coroutine [Job] (see [coroutineContext]), so the call's + * coroutine remains "completing" — and is awaited by the parent application Job during graceful + * shutdown — until the response is fully written. This removes the need to suspend on + * [Job.join] from the application thread at the end of each call (for example, the + * [io.ktor.server.netty.NettyApplicationEngine] AFTER_CALL_PHASE interceptor). + * + * Initialized via [initResponseWriteJob] on the Netty I/O thread synchronously after call + * construction (from `processResponse`) and before the user handler coroutine is launched. + * The deferred initialization is required because subclasses bind [coroutineContext] in their + * own primary constructor, after the base class constructor has finished. + */ + public lateinit var responseWriteJob: Job + private set + + /** + * Initializes [responseWriteJob] as a child of the call's coroutine [Job]. Called synchronously + * on the Netty I/O thread right after the call is constructed and before the user handler + * coroutine is launched, so the field is safely published to all subsequent readers via the + * handler-dispatch happens-before edge. + */ + internal fun initResponseWriteJob() { + val callJob = coroutineContext[Job] + val job = Job(parent = callJob) + job.invokeOnCompletion { onResponseWriteCompleted() } + responseWriteJob = job + } private val messageReleased = atomic(false) @@ -62,39 +90,32 @@ public abstract class NettyApplicationCall( internal abstract fun isContextCloseRequired(): Boolean - internal suspend fun finish() { + /** + * Marks the call as ready to finish, without suspending the calling coroutine. + * + * The response writer runs on the Netty I/O thread and signals completion through + * [responseWriteJob]; because that job is a child of the call's coroutine [Job], the call + * naturally remains "completing" until the write finishes — there is no need to join here. + * Per-call cleanup (request close + request message release) is performed by + * [onResponseWriteCompleted] when [responseWriteJob] completes (registered as an + * `invokeOnCompletion` handler in [initResponseWriteJob]). + * + * Throws if [NettyApplicationResponse.ensureResponseSent] fails. In that case the failure is + * propagated through [finishedEvent] and the response write job is cancelled so cleanup still + * runs via the registered completion handler. + */ + internal fun finish() { try { response.ensureResponseSent() } catch (cause: Throwable) { finishedEvent.setFailure(cause) - finishComplete() + // Cancelling the job drives `onResponseWriteCompleted` via invokeOnCompletion. + responseWriteJob.cancel() throw cause } - - if (responseWriteJob.isCompleted) { - finishComplete() - return - } - - return finishSuspend() } - private suspend fun finishSuspend() { - try { - responseWriteJob.join() - } finally { - finishComplete() - } - } - - private fun finishComplete() { - // Avoid allocating JobCancellationException on the happy path (responseWriteJob already - // completed via finish() or finishSuspend()). On error paths — ensureResponseSent() failure - // or outer-coroutine cancellation during join() — the job may still be active and must be - // cancelled to release its resources. - if (!responseWriteJob.isCompleted) { - responseWriteJob.cancel() - } + private fun onResponseWriteCompleted() { request.close() releaseRequestMessage() } diff --git a/ktor-server/ktor-server-netty/jvm/src/io/ktor/server/netty/NettyApplicationEngine.kt b/ktor-server/ktor-server-netty/jvm/src/io/ktor/server/netty/NettyApplicationEngine.kt index 773cafee4ba..4b0eed04fbd 100644 --- a/ktor-server/ktor-server-netty/jvm/src/io/ktor/server/netty/NettyApplicationEngine.kt +++ b/ktor-server/ktor-server-netty/jvm/src/io/ktor/server/netty/NettyApplicationEngine.kt @@ -327,6 +327,11 @@ public class NettyApplicationEngine( init { pipeline.insertPhaseAfter(EnginePipeline.Call, AFTER_CALL_PHASE) pipeline.intercept(AFTER_CALL_PHASE) { + // [NettyApplicationCall.finish] is non-suspending: it only ensures the response is + // committed (headers + status flushed). The actual write completion is awaited via + // structured concurrency — the call's responseWriteJob is a child of the call's + // coroutine Job, so the call coroutine remains "completing" until the I/O-thread + // writer finishes and cleanup runs from responseWriteJob's invokeOnCompletion handler. (call as? NettyApplicationCall)?.finish() } } diff --git a/ktor-server/ktor-server-netty/jvm/src/io/ktor/server/netty/cio/NettyHttpResponsePipeline.kt b/ktor-server/ktor-server-netty/jvm/src/io/ktor/server/netty/cio/NettyHttpResponsePipeline.kt index 018d9eb75a0..0e899ed9284 100644 --- a/ktor-server/ktor-server-netty/jvm/src/io/ktor/server/netty/cio/NettyHttpResponsePipeline.kt +++ b/ktor-server/ktor-server-netty/jvm/src/io/ktor/server/netty/cio/NettyHttpResponsePipeline.kt @@ -69,6 +69,12 @@ internal class NettyHttpResponsePipeline( call.finishedEvent = context.newPromise() previousCallHandled = call.finishedEvent + // Initialize the call's responseWriteJob as a child of the call's coroutineContext Job. + // This call happens synchronously on the Netty I/O thread before the user handler coroutine + // is dispatched, so the value is safely published to both the call thread and any later + // I/O-thread listeners that complete the job. + call.initResponseWriteJob() + processElement(call) }