Repository: tipsy/javalin
Pull request #1397 (2021-09-21 15:11:07)

GH-1385 Reimplement JavalinServlet to protect async request handling against race-conditions
**Context**

* Discussion on Javalin's Discord server about a new possibilities of raising race condition vulnerabilities (continuation of spotted and reproduced #1385)
  <details>
    <img src="https://user-images.githubusercontent.com/4235722/135995572-aa289f27-a972-431b-a062-b77b2852d7c8.png" />
  </details>

**Resolves**

* Fix https://github.com/tipsy/javalin/issues/1385 - the root of the problem, fixes internals to properly handle presented scenarios, but still exposes user to potential race condition side-effects and maybe even more that we still didn't find out
* Resolves https://github.com/tipsy/javalin/issues/1278 - overall better async support as it's currently dangerous to any concurrent env, espeically with tools like Loom or Kotlin Coroutines
* Resolves https://github.com/tipsy/javalin/issues/1402 - enhanced async handling, improved api (consistent - removed some runtime flags that forbidden future result handling in before/async context)

**About**

Reimplemented JavalinServlet reuses previous logic and imitates behaviors described by tests. Flow implementation may look complicated for the first time, but in the end it's just a recursive iteration over 2 queues to provide lazy creation of a new phase of request lifecycle. PR adds ~150 new lines, but like half of them is just a cost of spacing & moving request handling to standalone class.

Pros:
- Uses persistent future container for each scope and guarantees future completion
- Separates request lifecycle from request handling
- Supports future result handling by each handler (by design)
- Provides more detailed description of request lifecycle

Cons:
- ~I don't quite like the idea of 2 queues, might be improved in the future.~ resolved
- Ported error handling looks kinda dirty and I think it could be simplified in the future, but I don't really want to play about this topic right, especially that it could affect compatibility and it should be a topic of another issue
- Obviously might be slightly slower, but it shouldn't be a problem (future stage per handler + one queue). It's natural cost of concurrent layer

**Extra**

1. ~New implementation **does not enable** async handling in before & after routes (other scopes) yet, but makes it possible. Anyway, such a change could be a topic of another issue.~ Well, I've already enabled it as I'm using Javalin based on this branch for 2 months already, because imo it's just more stable and safe impl than the official release.
2. Not sure if async pipeline should use async timeout per each layer or whole queue (currently the second one). Should be discussed during (*1)

dzikoysk: Previous patch is pretty stable and I didn't receive even a single corrupted request in both test & production environments. That's why I could apply optimizations to request handling in these 3 commits above. Flow uses now only one `ArrayDeque` queue that is created per request and indexed loop over immutable list with configured scopes that submits tasks (handlers as layers) to handle request. I'm going to switch to the latest build to test servlet with a burst tool as the previous one and then apply it into the production server. Artifact:

```kotlin
implementation("com.github.dzikoysk.javalin:javalin:90381e8e35")
```

If version with optimizations won't get any regression, then I think the draft may be converted into PR and reviewed. 
dzikoysk: I think think I'm done with it now, I guess it's ready for review :)
dzikoysk: I've implemented #1402 here to simplify development process. It can be enabled/disabled by this condition:

* https://github.com/tipsy/javalin/blob/d2e02a25b044c1fd04190e2cd4493f8cd1c8cb7a/javalin/src/main/java/io/javalin/http/Context.kt#L360

Thanks to that change I was not only able to support all async handlers, but I also found two issues:

1. Calling ctx.result(InputStream) multiple times may lead to unclosed connections. As `context.result(InputStream)` states that it closes connections, it might be a good idea to take care of overridden streams
2. `ctx.future` cannot override `InputStream` result as it's now an independent variable (and also could lead to memory leaks in the past)

Both cases have been resolved, the latest artifact is available on JitPack: 

```
dependencies {
    implementation 'com.github.dzikoysk.javalin:javalin:d2e02a25b0'
}
```
dzikoysk: I had to move lately to 4.0.0, because of the #1409 and my lack of time to investigate this topic. I'll merge latest changes from master (#1413) and run tests with new build:

```groovy
dependencies {
    implementation("com.github.dzikoysk:javalin:760159a7c4")
}
```
dzikoysk: Latest build with merged changes from `master`

```groovy
dependencies {
    implementation("com.github.dzikoysk:javalin:2a5c2bfd94")
}
```
tipsy: Are you using this through jitpack now @dzikoysk ?
dzikoysk: Using JitPack linked to my fork:

* https://github.com/dzikoysk/javalin -> https://jitpack.io/#dzikoysk/javalin

I'm using these builds for like 2 months now in all of my projects such as:

https://github.com/dzikoysk/reposilite/blob/755eded7a2bd8030681eabf684334c06473ee42f/reposilite-backend/build.gradle.kts#L88

I have to use JitPack linked to my fork, because JitPack does not see this branch in official repository:

* https://jitpack.io/#tipsy/javalin

tipsy: Have you made a comparison between this and normal 4.x?
dzikoysk: I've made several stress tests locally in highly concurrent context with high CPU usage and the reimplemented servlet was able to handle it properly, both sync & async context. The only problem I've found was the memory leak spotted here (#1409 + #1413) and also that's the branch I used to detect this.

I didn't make any performance comparison (rps) between them, it may probably affect some non real-world benchmarks, but it shouldn't take effect on real-world apps as it's just a cost of composing CompletableFuture wrappers and iterating over a queue with tasks to do in request lifecycle.

Speaking of the implementation, I guess it could be even simpler, but the idea was to maintain 100% compatibility and behave the same way as the old implementation. That's why the error handling looks kinda strange.


tipsy: Thanks for the summary. The PR is intriguing, but the implementation is more complex than what I'm comfortable with. I think it will just have to live in this branch for now 😞 
dzikoysk: Which part exactly?

It's a little longer as it's just moved to another class with clearer staging mechanism, but in the end it should be easier to extend and maintain. Imo the previous implementation was way more complex to understand as you had to analyze every line to see what's going on in the overall handling process - especially because of these nested methods called multiple time within the same context. I've spent literally more time by trying to reproduce and analyze the previous behavior than recreating this with proposed scopes and I guess that's why no one ever really checked and analyzed this method before me 😛 

Now, instead of goto-like methodology with mixed responsibility, it's all wrapped around named scopes like `before`, `http`, `exception`, `after` and just looped over them - that's pretty much the whole idea of that queue 🤔 In general `JavalinServlet` describes now how the request is handled (it's pretty much like registering handlers to request handling), and the `JavalinServletFlow` deals with executing them.

In general I guess it could be resolved in several ways, it's all about feedback to work out the solution. I didn't want to invest much time on creating some fancy stuff, because I was quite sure you won't like it anyway. Exposing user to any kind of RC is unacceptable imo and might lead to serious issues, so I guess it's important to cover this case. I know that I'm the only one who's raising this issue, but it doesn't mean it's not affecting people - I'm quite they just don't know about it and they're not able to spot it out as it's a source for "heisenbugs".
dzikoysk: Thanks. My first idea was to make servlet implementation customizable (sth like JsonMapper) so we can provide custom impl through e.g. configuration or sth, but I didn't want to introduce another layer to this PR to simplify review and discussion around it. With customizable servlet it would be easier to incubate this kind of change and it could also extend plugin api possibilities, because plugins could register some interactions directly at servlet level. 
tipsy: > As an extra note related to this: my first idea was to make servlet implementation customizable (sth like JsonMapper) so we can provide custom impl through e.g. configuration or sth, but I didn't want to introduce another layer to this PR to simplify review and discussion around it. With customizable servlet it would be easier to incubate this kind of change and it could also extend plugin api possibilities, because plugins could register some interactions directly at servlet level (so it's pretty much enabling all kind of e.g. reactive plugins/metrics/any type of request handlers/custom handlers/maybe even whole routing/etc.). Just throwing this off here, maybe you'll find something you like in this kind of approach as it's kinda related.

This is a really cool idea 🤔 
tipsy: Really cool 🤔

I guess we could extract the different functions, so you could easily build your own `JavalinServlet`
dzikoysk: Right, but we have to remember that the current implementation is still affected, so we need a patch for the default servlet anyway. Also, if we'd like to provide a possibility of declaring custom servlets, we need to also enable futures in each layer (and that's also what this PR does as a side-effect as it's just natural way of processing tasks) and remove any handling related checks from other methods (like from Context) to fully control handling just in the servlet codebase.

tipsy: @dzikoysk it actually looks a lot simpler than I thought. Something came up, so I was only able to read through the code twice, but it's much better than what I thought from reading the diff on GitHub (it looks like you might have simplified it a bit too).

I will need to read through it again next weekend, but it's not unlikely that this will get merged once I fully understand it.

I am a bit worried about the performance impact for the 99% of people who don't use this, and I'm wondering if it makes sense to make the servlet pluggable first, and then offer this as an alternative servlet.
dzikoysk: The cost of this servlet is:
* ArrayDeque (so pretty much Object[]), default of size 8.
* Creating iterator for lifecycles (because the whole lifecycle setup is initialized only once when servlet is created, so that's pretty much a cost of `for` loop)
* CompletableFuture per handler
  * For sync context it is always CompletableFuture.completedStage(null) as starting point that under the hood creates `MinimalStage<T>` that is just straight wrapper for T value, so it's like a cost of creating `Optional`. Then it's regular CompletableFuture that just chains next handlers. Like I said, it'll probably affect unrealistic benchmarks (that can be affected by literally even creating `new Object()`), but it shouldn't be a case for standard application. We could probably remove it for sync stages, but it'll more likely decrease the readability. 
  * For async is doesn't matter, you have to use futures anyway and that's just a natural cost of handling

Summing up it's something like:

```
class Completable {
  Value v;
  Completable nextAction;
}

fun serve() {
  Object[] tasks = new Object[8]; // it's literally ArrayDeque
  Completable start = Completable(null) // stub wrapper for value using MinimalStage

  for (Cycle cycle : lifecycle) { // 4 cycles ("before", "http", "exception", "after") by default, list is predefined in servlet
    tasks[1...n] = cycle.taskN  // mostly just one per handler, assigning values to array

    for (Task task : task) { // polling tasks, accessing array
       task.execute() // sync execute
       start = Completable(null).also { start.nextAction = it } // just another empty stub, another option
    }
  }
}
```
tipsy: I ran some performance tests, and the impact of this seems to be insignificant. 

I have some problems with how the code is structured into named Cycles. 
* If I remove the names, all tests still pass. 
* If I combine http/before and error/after, all tests still pass.

Is this `Cycle`-layer really necessary, or could we get away with just a `Task` layer? Could `ignoresExceptions` be renamed and given to a `Task` instead?
dzikoysk: Names are just optional, but it's kinda useful during debugging and maybe in the future for error handling to indicate which layer is currently invoked.

The purpose of cycle is that it incubates each group of tasks, so its easier to support things like `ignoresExceptions` per layer and group them, might be especially useful in the future, if we'd like to extend this with API methods that allows user to register a custom cycle. 

We could merge it to 2 cycles, but separating it to 4 is just more aesthetic imo as we directly see the associated scope. I think we could try do it through submitTask, but it'll increase the size of codebase and we still need to separate (before + http) and (after + exception), so I'm not sure if there is at least one benefit of trying to remove these Cycles 🤔 
tipsy: Maybe having Cycles is the best abstraction for the future, but as they are now they are confusing IMO. If you can combine them and remove their names without any effect, then they are an unnatural abstraction layer.

I would either:
* write everything out so that Cycles are fully realized, or 
* remove the abstraction and reintroduce it when it is fully realized

It doesn't matter to me which one of those options you choose, but I have a problem with the current state. Even adding debug-logging could solve the issue though. There just has to be something to justify the concept. Also, if you want to keep them, I would change them to an enum.

On a separate note, I think grammatically these should be called "Stages". (On lifecycle with multiple stages, multiple tasks per stage).
dzikoysk: I'll take a look at this as soon as I find some time to propose alternative impl. I was mostly concerned about simplifying the handling itself, so let's do it now with lifecycle. 

Speaking of `Cycle`, when I started it was just called `Scope`, but changed this a few commits ago to simplify readability of concept. Stages are also fine 👍  
dzikoysk: Latest build:

```kotlin
dependencies {
    implementation("com.github.dzikoysk:javalin:ded7a51aa4")
}
```
dzikoysk: Random error in TestWebSocket, unrelated:

```
 Error:  Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0.038 s <<< FAILURE! - in io.javalin.TestWebSocket
Error:  io.javalin.TestWebSocket.websocket closeSession$_$_ methods  Time elapsed: 0.036 s  <<< ERROR!
org.java_websocket.exceptions.WebsocketNotConnectedException
	at io.javalin.TestWebSocket.doAndSleepWhile(TestWebSocket.kt:591)
	at io.javalin.TestWebSocket.doAndSleepWhile$default(TestWebSocket.kt:587)
	at io.javalin.TestWebSocket.websocket_closeSession___methods$lambda-149(TestWebSocket.kt:551)
	at io.javalin.TestWebSocket.websocket closeSession$_$_ methods(TestWebSocket.kt:534)
```
dzikoysk: Latest build:

```groovy
dependencies {
    implementation("com.github.dzikoysk:javalin:1928b145c9")
}
```

~ Applied to https://github.com/dzikoysk/reposilite/commit/d22b1735ec23d1292208430c33516a1dae71e19f
dzikoysk: > Random error in TestWebSocket, unrelated:
> 
> ```
>  Error:  Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0.038 s <<< FAILURE! - in io.javalin.TestWebSocket
> Error:  io.javalin.TestWebSocket.websocket closeSession$_$_ methods  Time elapsed: 0.036 s  <<< ERROR!
> org.java_websocket.exceptions.WebsocketNotConnectedException
> 	at io.javalin.TestWebSocket.doAndSleepWhile(TestWebSocket.kt:591)
> 	at io.javalin.TestWebSocket.doAndSleepWhile$default(TestWebSocket.kt:587)
> 	at io.javalin.TestWebSocket.websocket_closeSession___methods$lambda-149(TestWebSocket.kt:551)
> 	at io.javalin.TestWebSocket.websocket closeSession$_$_ methods(TestWebSocket.kt:534)
> ```

CI failed again, unrelated to PR.
dzikoysk: Latest build:

```groovy
dependencies {
    implementation("com.github.dzikoysk:javalin:97b4481c0a")
}
```

~ Applied to https://github.com/dzikoysk/reposilite/commit/ed7791ccb5325b6f94f98a86b5633ff2de689a13
dzikoysk: CI died on `TestWebBrowser`, unrelated to PR:

```
[INFO] Running io.javalin.TestWebBrowser
[main] INFO io.github.bonigarcia.wdm.WebDriverManager - Using chromedriver 99.0.4844.51 (resolved driver for Chrome 99)
Error:  Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 60.252 s <<< FAILURE! - in io.javalin.TestWebBrowser
[main] INFO io.github.bonigarcia.wdm.WebDriverManager - Exporting webdriver.chrome.driver as C:\Users\runneradmin\.cache\selenium\chromedriver\win32\99.0.4844.51\chromedriver.exe
Error:  io.javalin.TestWebBrowser  Time elapsed: 60.252 s  <<< ERROR!
Starting ChromeDriver 99.0.4844.51 (d537ec02474b5afe23684e7963d538896c63ac77-refs/branch-heads/4844@{#875}) on port 55372
org.openqa.selenium.WebDriverException: 
Only local connections are allowed.
unknown error: DevToolsActivePort file doesn't exist
Please see https://chromedriver.chromium.org/security-considerations for suggestions on keeping ChromeDriver safe.
Build info: version: '3.141.59', revision: 'e82be7d358', time: '2018-11-14T08:17:03'
ChromeDriver was started successfully.
System info: host: 'fv-az449-758', ip: '10.1.0.85', os.name: 'Windows Server 2022', os.arch: 'amd64', os.version: '10.0', java.version: '11.0.14'
Driver info: driver.version: ChromeDriver
remote stacktrace: Backtrace:
	Ordinal0 [0x00F69943+2595139]
	Ordinal0 [0x00EFC9F1+2148849]
	Ordinal0 [0x00DF4528+1066280]
	Ordinal0 [0x00E136B2+1193650]
	Ordinal0 [0x00E0FC59+1178713]
	Ordinal0 [0x00E0D6F1+1169137]
	Ordinal0 [0x00E40530+1377584]
	Ordinal0 [0x00E4018A+1376650]
	Ordinal0 [0x00E3B806+1357830]
	Ordinal0 [0x00E16086+1204358]
	Ordinal0 [0x00E16F96+1208214]
	GetHandleVerifier [0x0110B232+1658114]
	GetHandleVerifier [0x011C312C+2411516]
	GetHandleVerifier [0x00FFF261+560433]
	GetHandleVerifier [0x00FFE366+556598]
	Ordinal0 [0x00F0286B+2173035]
	Ordinal0 [0x00F075F8+2192888]
	Ordinal0 [0x00F076E5+2193125]
	Ordinal0 [0x00F111FC+2232828]
	BaseThreadInitThunk [0x76A36709+25]
	RtlGetFullPathName_UEx [0x77937CCD+173]
	RtlGetFullPathName_UEx [0x77937C9B+123]
	at io.javalin.TestWebBrowser.setupClass(TestWebBrowser.kt)
[INFO] Running io.javalin.TestWebBrowser
```
tipsy: > Build died executing TestWebBrowser on windows targets, unrelated to PR:

Probably something wrong with the project handling the browsers.

The war broke out the day before I was supposed to look at this, then I got busy at work again. 
Will finally look at this again tonight and tomorrow.
dzikoysk: CI died on internal GitHub Actions network failure

Patch:
diff --git a/javalin/src/main/java/io/javalin/http/Context.kt b/javalin/src/main/java/io/javalin/http/Context.kt
index 4685e8e4..f4f6a01e 100644
--- a/javalin/src/main/java/io/javalin/http/Context.kt
+++ b/javalin/src/main/java/io/javalin/http/Context.kt
@@ -21,6 +21,7 @@ import java.io.InputStream
 import java.nio.charset.Charset
 import java.util.*
 import java.util.concurrent.CompletableFuture
+import java.util.concurrent.atomic.AtomicReference
 import java.util.function.Consumer
 import javax.servlet.http.HttpServletRequest
 import javax.servlet.http.HttpServletResponse
@@ -34,18 +35,16 @@ import javax.servlet.http.HttpServletResponse
 open class Context(@JvmField val req: HttpServletRequest, @JvmField val res: HttpServletResponse, internal val appAttributes: Map<String, Any> = mapOf()) {
 
     // @formatter:off
-    @get:JvmSynthetic @set:JvmSynthetic internal var inExceptionHandler = false
     @get:JvmSynthetic @set:JvmSynthetic internal var matchedPath = ""
     @get:JvmSynthetic @set:JvmSynthetic internal var endpointHandlerPath = ""
     @get:JvmSynthetic @set:JvmSynthetic internal var pathParamMap = mapOf<String, String>()
     @get:JvmSynthetic @set:JvmSynthetic internal var handlerType = HandlerType.BEFORE
+    @get:JvmSynthetic @set:JvmSynthetic internal var resultReference = AtomicReference(Result())
     // @formatter:on
 
     private val cookieStore by lazy { CookieStore(this.jsonMapper(), cookie(CookieStore.COOKIE_NAME)) }
     private val characterEncoding by lazy { ContextUtil.getRequestCharset(this) ?: "UTF-8" }
-    private var resultStream: InputStream? = null
-    private var resultFuture: CompletableFuture<*>? = null
-    internal var futureConsumer: Consumer<Any?>? = null
+
     private val body by lazy {
         this.throwPayloadTooLargeIfPayloadTooLarge()
         req.inputStream.readBytes()
@@ -230,7 +229,7 @@ open class Context(@JvmField val req: HttpServletRequest, @JvmField val res: Htt
     inline fun <reified T : Any> headerAsClass(header: String) = headerAsClass(header, T::class.java)
 
     /** Gets a map with all the header keys and values on the request. */
-    fun headerMap(): Map<String, String> = req.headerNames.asSequence().associate { it to header(it)!! }
+    fun headerMap(): Map<String, String> = req.headerNames.asSequence().associateWith { header(it)!! }
 
     /** Gets the request host, or null. */
     fun host(): String? = contextResolver().host.invoke(this)
@@ -334,46 +333,38 @@ open class Context(@JvmField val req: HttpServletRequest, @JvmField val res: Htt
      */
     fun result(resultBytes: ByteArray) = result(resultBytes.inputStream())
 
-    /** Gets the current [resultStream] as a [String] (if possible), and reset [resultStream] */
-    fun resultString() = ContextUtil.readAndResetStreamIfPossible(resultStream, responseCharset())
+    /** Gets the current [resultReference] as a [String] (if possible), and reset the underlying stream */
+    fun resultString() = ContextUtil.readAndResetStreamIfPossible(resultStream(), responseCharset())
 
     /**
      * Sets context result to the specified [InputStream].
      * Will overwrite the current result if there is one.
      */
     fun result(resultStream: InputStream): Context {
-        this.resultFuture = null
-        this.resultStream = resultStream
-        return this
+        runCatching { resultStream()?.close() } // avoid memory leaks for multiple result() calls
+        return this.future(CompletableFuture.completedFuture(resultStream))
     }
 
     /** Writes the specified inputStream as a seekable stream */
     @JvmOverloads
     fun seekableStream(inputStream: InputStream, contentType: String, size: Long = inputStream.available().toLong()) =
-            SeekableWriter.write(this, inputStream, contentType, size)
+        SeekableWriter.write(this, inputStream, contentType, size)
 
-    /** Gets the current context result as an [InputStream] (if set). */
-    fun resultStream(): InputStream? = resultStream
+    fun resultStream(): InputStream? = resultReference.get().let { result ->
+        result.future.takeIf { it.isDone }?.get() as InputStream? ?: result.previous
+    }
 
     @JvmOverloads
     fun future(future: CompletableFuture<*>, callback: Consumer<Any?>? = null): Context {
-        if (!handlerType.isHttpMethod() || inExceptionHandler) {
-            throw IllegalStateException("You can only set CompletableFuture results in endpoint handlers.")
-        }
-        resultStream = null
-        resultFuture = future
-        futureConsumer = callback ?: Consumer { result ->
-            when (result) {
-                is InputStream -> result(result)
-                is String -> result(result)
-                is Any -> json(result)
-            }
+        resultReference.updateAndGet { oldResult ->
+            oldResult.future.cancel(true)
+            Result(oldResult.previous, future, callback)
         }
         return this
     }
 
     /** Gets the current context result as a [CompletableFuture] (if set). */
-    fun resultFuture(): CompletableFuture<*>? = resultFuture
+    fun resultFuture(): CompletableFuture<*>? = resultReference.get().future
 
     /** Sets response content type to specified [String] value. */
     fun contentType(contentType: String): Context {
diff --git a/javalin/src/main/java/io/javalin/http/ExceptionMapper.kt b/javalin/src/main/java/io/javalin/http/ExceptionMapper.kt
index 32bfd004..c31b8544 100644
--- a/javalin/src/main/java/io/javalin/http/ExceptionMapper.kt
+++ b/javalin/src/main/java/io/javalin/http/ExceptionMapper.kt
@@ -17,7 +17,6 @@ class ExceptionMapper {
     val handlers = mutableMapOf<Class<out Exception>, ExceptionHandler<Exception>?>()
 
     internal fun handle(exception: Exception, ctx: Context) {
-        ctx.inExceptionHandler = true // prevent user from setting Future as result in exception handlers
         if (HttpResponseExceptionMapper.canHandle(exception) && noUserHandler(exception)) {
             HttpResponseExceptionMapper.handle(exception, ctx)
         } else {
@@ -29,13 +28,6 @@ class ExceptionMapper {
                 HttpResponseExceptionMapper.handle(InternalServerErrorResponse(), ctx)
             }
         }
-        ctx.inExceptionHandler = false
-    }
-
-    internal inline fun catchException(ctx: Context, func: () -> Unit) = try {
-        func.invoke()
-    } catch (e: Exception) {
-        handle(e, ctx)
     }
 
     internal fun handleFutureException(ctx: Context, throwable: Throwable): Nothing? {
diff --git a/javalin/src/main/java/io/javalin/http/JavalinResponseWrapper.kt b/javalin/src/main/java/io/javalin/http/JavalinResponseWrapper.kt
index bae4dd0d..47b26a5d 100644
--- a/javalin/src/main/java/io/javalin/http/JavalinResponseWrapper.kt
+++ b/javalin/src/main/java/io/javalin/http/JavalinResponseWrapper.kt
@@ -28,8 +28,9 @@ class ResponseWrapperContext(request: HttpServletRequest, val config: JavalinCon
     val compStrat = config.inner.compressionStrategy
 }
 
-class JavalinResponseWrapper(val res: HttpServletResponse, private val rwc: ResponseWrapperContext) : HttpServletResponseWrapper(res) {
-
+class JavalinResponseWrapper(val ctx: Context, config: JavalinConfig) : HttpServletResponseWrapper(ctx.res) {
+    private val res: HttpServletResponse = ctx.res
+    private val rwc = ResponseWrapperContext(ctx.req, config)
     private val outputStreamWrapper by lazy { OutputStreamWrapper(res, rwc) }
     override fun getOutputStream() = outputStreamWrapper
 
diff --git a/javalin/src/main/java/io/javalin/http/JavalinServlet.kt b/javalin/src/main/java/io/javalin/http/JavalinServlet.kt
index 61600c0a..4026c55e 100644
--- a/javalin/src/main/java/io/javalin/http/JavalinServlet.kt
+++ b/javalin/src/main/java/io/javalin/http/JavalinServlet.kt
@@ -10,13 +10,14 @@ import io.javalin.core.JavalinConfig
 import io.javalin.core.security.RouteRole
 import io.javalin.core.util.CorsPlugin
 import io.javalin.core.util.LogUtil
+import io.javalin.http.HandlerType.AFTER
+import io.javalin.http.HandlerType.BEFORE
+import io.javalin.http.HandlerType.GET
+import io.javalin.http.HandlerType.HEAD
+import io.javalin.http.HandlerType.OPTIONS
 import io.javalin.http.util.ContextUtil
 import io.javalin.http.util.MethodNotAllowedUtil
-import java.util.concurrent.atomic.AtomicBoolean
-import javax.servlet.AsyncContext
-import javax.servlet.AsyncEvent
-import javax.servlet.AsyncListener
-import javax.servlet.ServletResponse
+import java.util.*
 import javax.servlet.http.HttpServlet
 import javax.servlet.http.HttpServletRequest
 import javax.servlet.http.HttpServletResponse
@@ -27,34 +28,33 @@ class JavalinServlet(val config: JavalinConfig) : HttpServlet() {
     val exceptionMapper = ExceptionMapper()
     val errorMapper = ErrorMapper()
 
-    override fun service(req: HttpServletRequest, res: HttpServletResponse) {
-        try {
-            val type = HandlerType.fromServletRequest(req)
-            val rwc = ResponseWrapperContext(req, config)
-            val requestUri = req.requestURI.removePrefix(req.contextPath)
-            val ctx = Context(req, res, config.inner.appAttributes)
-
-            fun tryWithExceptionMapper(func: () -> Unit) = exceptionMapper.catchException(ctx, func)
-
-            fun tryBeforeAndEndpointHandlers() = tryWithExceptionMapper {
-                matcher.findEntries(HandlerType.BEFORE, requestUri).forEach { entry ->
-                    entry.handler.handle(ContextUtil.update(ctx, entry, requestUri))
-                }
-                matcher.findEntries(type, requestUri).firstOrNull()?.let { entry ->
-                    entry.handler.handle(ContextUtil.update(ctx, entry, requestUri))
-                    return@tryWithExceptionMapper // return after first match
-                }
-                if (type == HandlerType.HEAD && hasGetHandlerMapped(requestUri)) {
-                    return@tryWithExceptionMapper // return 200, there is a get handler
+    /**
+     * Default request lifecycle used by servlet to handle HTTP requests.
+     * You can modify its state to add/remove stages and directly affect the way that Javalin handles requests.
+     */
+    val lifecycle = mutableListOf(
+        Stage(DefaultName.BEFORE) { submitTask ->
+            matcher.findEntries(BEFORE, requestUri).forEach { entry ->
+                submitTask { entry.handler.handle(ContextUtil.update(ctx, entry, requestUri)) }
+            }
+        },
+        Stage(DefaultName.HTTP) { submitTask ->
+            matcher.findEntries(type, requestUri).firstOrNull()?.let { entry ->
+                submitTask { entry.handler.handle(ContextUtil.update(ctx, entry, requestUri)) }
+                return@Stage // return after first match
+            }
+            submitTask {
+                if (type == HEAD && matcher.hasEntries(GET, requestUri)) { // return 200, there is a get handler
+                    return@submitTask
                 }
-                if (type == HandlerType.HEAD || type == HandlerType.GET) { // check for static resources (will write response if found)
-                    if (config.inner.resourceHandler?.handle(req, JavalinResponseWrapper(res, rwc)) == true) return@tryWithExceptionMapper
-                    if (config.inner.singlePageHandler.handle(ctx)) return@tryWithExceptionMapper
+                if (type == HEAD || type == GET) { // check for static resources (will write response if found)
+                    if (config.inner.resourceHandler?.handle(it.ctx.req, JavalinResponseWrapper(it.ctx, config)) == true) return@submitTask
+                    if (config.inner.singlePageHandler.handle(ctx)) return@submitTask
                 }
-                if (type == HandlerType.OPTIONS && isCorsEnabled(config)) { // CORS is enabled, so we return 200 for OPTIONS
-                    return@tryWithExceptionMapper
+                if (type == OPTIONS && config.isCorsEnabled()) { // CORS is enabled, so we return 200 for OPTIONS
+                    return@submitTask
                 }
-                if (ctx.handlerType == HandlerType.BEFORE) { // no match, status will be 404 or 405 after this point
+                if (ctx.handlerType == BEFORE) { // no match, status will be 404 or 405 after this point
                     ctx.endpointHandlerPath = "No handler matched request path/method (404/405)"
                 }
                 val availableHandlerTypes = MethodNotAllowedUtil.findAvailableHttpHandlerTypes(matcher, requestUri)
@@ -63,65 +63,40 @@ class JavalinServlet(val config: JavalinConfig) : HttpServlet() {
                 }
                 throw NotFoundResponse()
             }
-
-            val responseStarted = AtomicBoolean(false)
-
-            fun finishUpResponse(response: ServletResponse) {
-                if (responseStarted.getAndSet(true)) return // prevent writing twice (ex. both async requests+errors)
-
-                tryWithExceptionMapper { // run error mappers (can mutate ctx)
-                    errorMapper.handle(ctx.status(), ctx)
-                }
-                tryWithExceptionMapper { // run after handlers (can mutate ctx)
-                    matcher.findEntries(HandlerType.AFTER, requestUri).forEach { entry ->
-                        entry.handler.handle(ContextUtil.update(ctx, entry, requestUri))
-                    }
-                }
-                // write the response
-                JavalinResponseWrapper(response as HttpServletResponse, rwc).write(ctx.resultStream())
-                config.inner.requestLogger?.handle(ctx, LogUtil.executionTimeMs(ctx)) // log stuff
+        },
+        Stage(DefaultName.ERROR, haltsOnError = false) { submitTask ->
+            submitTask { errorMapper.handle(ctx.status(), ctx) }
+        },
+        Stage(DefaultName.AFTER, haltsOnError = false) { submitTask ->
+            matcher.findEntries(AFTER, requestUri).forEach { entry ->
+                submitTask { entry.handler.handle(ContextUtil.update(ctx, entry, requestUri)) }
             }
+        }
+    )
 
-            LogUtil.setup(ctx, matcher, config.inner.requestLogger != null) // start request lifecycle
+    override fun service(request: HttpServletRequest, response: HttpServletResponse) {
+        try {
+            val ctx = Context(request, response, config.inner.appAttributes)
+            LogUtil.setup(ctx, matcher, config.inner.requestLogger != null)
             ctx.contentType(config.defaultContentType)
-            tryBeforeAndEndpointHandlers()
-            val resultFuture = ctx.resultFuture() // store the future, so it's not overwritten by `result` below
-                ?: return finishUpResponse(res) // if result is null, request lifecycle is complete (blocking/synchronous)
-            // user has set a Future result, we call startAsync and setup callbacks
-            val asyncContext = req.startAsync().apply { timeout = config.asyncRequestTimeout }
-            asyncContext.addTimeoutListener {
-                ctx.status(500).result("Request timed out")
-                finishUpResponse(asyncContext.response).also { asyncContext.complete() }
-                resultFuture.cancel(true)
-            }
-            resultFuture.exceptionally { throwable ->
-                exceptionMapper.handleFutureException(ctx, throwable)
-            }.thenAccept { futureValue ->
-                tryWithExceptionMapper {
-                    ctx.futureConsumer?.accept(futureValue) // this consumer can set result, status, etc
-                }
-                finishUpResponse(asyncContext.response).also { asyncContext.complete() }
-            }.exceptionally { throwable -> // exception might occur when writing response
-                exceptionMapper.handleUnexpectedThrowable(res, throwable).also { asyncContext.complete() }
-            }
+
+            JavalinServletHandler(
+                stages = ArrayDeque(lifecycle),
+                config = config,
+                errorMapper = errorMapper,
+                exceptionMapper = exceptionMapper,
+                ctx = ctx
+            ).queueNextTaskOrFinish()
         } catch (throwable: Throwable) {
-            exceptionMapper.handleUnexpectedThrowable(res, throwable)
+            exceptionMapper.handleUnexpectedThrowable(response, throwable)
         }
     }
 
-    private fun hasGetHandlerMapped(requestUri: String) = matcher.findEntries(HandlerType.GET, requestUri).isNotEmpty()
-
     fun addHandler(handlerType: HandlerType, path: String, handler: Handler, roles: Set<RouteRole>) {
         val protectedHandler = if (handlerType.isHttpMethod()) Handler { ctx -> config.inner.accessManager.manage(handler, ctx, roles) } else handler
         matcher.add(HandlerEntry(handlerType, path, config.ignoreTrailingSlashes, protectedHandler, handler))
     }
 
-    private fun isCorsEnabled(config: JavalinConfig) = config.inner.plugins[CorsPlugin::class.java] != null
-}
+    private fun JavalinConfig.isCorsEnabled() = this.inner.plugins[CorsPlugin::class.java] != null
 
-private fun AsyncContext.addTimeoutListener(callback: () -> Unit) = this.addListener(object : AsyncListener {
-    override fun onComplete(event: AsyncEvent) {}
-    override fun onError(event: AsyncEvent) {}
-    override fun onStartAsync(event: AsyncEvent) {}
-    override fun onTimeout(event: AsyncEvent) = callback() // this is all we care about
-})
+}
diff --git a/javalin/src/main/java/io/javalin/http/JavalinServletHandler.kt b/javalin/src/main/java/io/javalin/http/JavalinServletHandler.kt
new file mode 100644
index 00000000..e6375fc3
--- /dev/null
+++ b/javalin/src/main/java/io/javalin/http/JavalinServletHandler.kt
@@ -0,0 +1,160 @@
+package io.javalin.http
+
+import io.javalin.core.JavalinConfig
+import io.javalin.core.util.LogUtil
+import java.io.InputStream
+import java.util.*
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.CompletableFuture.completedFuture
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.function.Consumer
+import javax.servlet.AsyncContext
+import javax.servlet.AsyncEvent
+import javax.servlet.AsyncListener
+
+interface StageName
+enum class DefaultName : StageName { BEFORE, HTTP, ERROR, AFTER }
+
+data class Stage(
+    val name: StageName,
+    val haltsOnError: Boolean = true, // tasks in this scope should be executed even if some previous stage ended up with exception
+    val initializer: StageInitializer // DSL method to add task to the stage's queue
+)
+
+internal data class Result(
+    val previous: InputStream? = null,
+    val future: CompletableFuture<*> = completedFuture(null),
+    val callback: Consumer<Any?>? = null,
+)
+
+internal data class Task(
+    val stage: Stage,
+    val handler: TaskHandler
+)
+
+typealias TaskHandler = (JavalinServletHandler) -> Unit
+typealias SubmitTask = (TaskHandler) -> Unit
+typealias StageInitializer = JavalinServletHandler.(submitTask: SubmitTask) -> Unit
+
+/**
+ * Executes request lifecycle.
+ * The lifecycle consists of multiple [stages] (before/http/etc), each of which
+ * can have one or more [tasks]. The default lifecycle is defined in [JavalinServlet].
+ * [JavalinServletHandler] is called only once per request, and has a mutable state.
+ */
+class JavalinServletHandler(
+    private val stages: ArrayDeque<Stage>,
+    private val config: JavalinConfig,
+    private val errorMapper: ErrorMapper,
+    private val exceptionMapper: ExceptionMapper,
+    val ctx: Context,
+    val type: HandlerType = HandlerType.fromServletRequest(ctx.req),
+    val requestUri: String = ctx.req.requestURI.removePrefix(ctx.req.contextPath),
+) {
+
+    /** Queue of tasks to execute within the current [Stage] */
+    private val tasks = ArrayDeque<Task>(4)
+
+    /** Future representing currently queued task */
+    private var currentTaskFuture: CompletableFuture<InputStream?> = completedFuture(null)
+
+    /** InputStream representing previous result */
+    private var previousResult: InputStream? = null
+
+    /** Indicates if exception occurred during execution of a tasks chain */
+    private var errored = false
+
+    /** Indicates if [JavalinServletHandler] already wrote response to client, requires support for atomic switch */
+    private val finished = AtomicBoolean(false)
+
+    /**
+     * This method starts execution process of all stages in a given lifecycle.
+     * Execution is based on recursive calls of this method,
+     * because we need a lazy evaluation of next tasks in a chain to support multiple concurrent stages.
+     */
+    internal fun queueNextTaskOrFinish() {
+        while (tasks.isEmpty() && stages.isNotEmpty()) { // refill tasks from next stage, if the current queue is empty
+            val stage = stages.poll()
+            stage.initializer.invoke(this) { taskHandler -> tasks.offer(Task(stage, taskHandler)) } // add tasks from stage to task queue
+        }
+        if (tasks.isEmpty())
+            finishResponse() // we looked but didn't find any more tasks, time to write the response
+        else
+            currentTaskFuture = currentTaskFuture
+                .thenAccept { inputStream -> previousResult = inputStream }
+                .thenCompose { executeNextTask() }  // chain next task into current future
+                .exceptionally { throwable -> exceptionMapper.handleUnexpectedThrowable(ctx.res, throwable) } // default catch-all for whole scope, might occur when e.g. finishResponse() will fail
+    }
+
+    /** Executes the given task with proper error handling and returns next task to execute as future */
+    private fun executeNextTask(): CompletableFuture<InputStream> {
+        val task = tasks.poll()
+        if (errored && task.stage.haltsOnError) {
+            queueNextTaskOrFinish() // each subsequent task for this stage will be queued and skipped
+            return completedFuture(previousResult)
+        }
+        try {
+            /** run code added through submitTask in [JavalinServlet]. This mutates [ctx] */
+            task.handler(this)
+        } catch (exception: Exception) {
+            errored = true
+            ctx.resultReference.getAndSet(Result(previousResult)).future.cancel(true)
+            exceptionMapper.handle(exception, ctx)
+        }
+        return ctx.resultReference.getAndSet(Result(previousResult))
+            .also { result -> if (!ctx.isAsync() && !result.future.isDone) startAsyncAndAddDefaultTimeoutListeners() } // start async context only if the future is not already completed
+            .also { result -> if (ctx.isAsync()) ctx.req.asyncContext.addTimeoutListener { result.future.cancel(true) } }
+            .let { result ->
+                result.future
+                    .thenAccept { any -> (result.callback ?: defaultFutureCallback()).accept(any) } // callback after future resolves - modifies ctx result, status, etc
+                    .thenApply { ctx.resultStream() ?: previousResult } // set value of future to be resultStream (or previous stream)
+                    .exceptionally { throwable -> exceptionMapper.handleFutureException(ctx, throwable) } // standard exception handler
+                    .thenApply { inputStream -> inputStream.also { queueNextTaskOrFinish() } } // we have to attach the "also" to the input stream to avoid mapping a void
+            }
+    }
+
+    private fun startAsyncAndAddDefaultTimeoutListeners() = ctx.req.startAsync()
+        .addTimeoutListener { // a timeout avoids the pipeline - we need to handle it manually
+            currentTaskFuture.cancel(true) // cancel current task
+            ctx.status(500).result("Request timed out") // default error handling
+            errorMapper.handle(ctx.status(), ctx) // user defined error handling
+            finishResponse() // write response
+        }
+        .also { asyncCtx -> asyncCtx.timeout = config.asyncRequestTimeout }
+
+    /** Writes response to the client and frees resources */
+    private fun finishResponse() {
+        if (finished.getAndSet(true)) return // prevent writing more than once (ex. both async requests+errors) [it's required because timeout listener can terminate the flow at any tim]
+        try {
+            JavalinResponseWrapper(ctx, config).write(ctx.resultStream())
+            config.inner.requestLogger?.handle(ctx, LogUtil.executionTimeMs(ctx))
+        } catch (throwable: Throwable) {
+            exceptionMapper.handleUnexpectedThrowable(ctx.res, throwable) // handle any unexpected error, e.g. write failure
+        } finally {
+            if (ctx.isAsync()) ctx.req.asyncContext.complete() // guarantee completion of async context to eliminate the possibility of hanging connections
+        }
+    }
+
+    /** Runs after a future is resolved, if not user defined callback exists */
+    private fun defaultFutureCallback(): Consumer<Any?> = Consumer { result ->
+        when (result) {
+            is Unit -> {}
+            is InputStream -> ctx.result(result)
+            is String -> ctx.result(result)
+            is Any -> ctx.json(result)
+        }
+    }
+
+}
+
+/** Checks if request is executed asynchronously */
+private fun Context.isAsync(): Boolean = req.isAsyncStarted
+
+private fun AsyncContext.addTimeoutListener(callback: () -> Unit) = this.apply {
+    addListener(object : AsyncListener {
+        override fun onComplete(event: AsyncEvent) {}
+        override fun onError(event: AsyncEvent) {}
+        override fun onStartAsync(event: AsyncEvent) {}
+        override fun onTimeout(event: AsyncEvent) = callback() // this is all we care about
+    })
+}
diff --git a/javalin/src/main/java/io/javalin/http/PathMatcher.kt b/javalin/src/main/java/io/javalin/http/PathMatcher.kt
index 85a8564c..8c3663ee 100644
--- a/javalin/src/main/java/io/javalin/http/PathMatcher.kt
+++ b/javalin/src/main/java/io/javalin/http/PathMatcher.kt
@@ -37,6 +37,9 @@ class PathMatcher {
     fun findEntries(handlerType: HandlerType, requestUri: String) =
         handlerEntries[handlerType]!!.filter { he -> match(he, requestUri) }
 
+    internal fun hasEntries(handlerType: HandlerType, requestUri: String): Boolean =
+        handlerEntries[handlerType]!!.any { entry -> match(entry, requestUri) }
+
     private fun match(entry: HandlerEntry, requestPath: String): Boolean = when {
         entry.path == "*" -> true
         entry.path == requestPath -> true


Test patch:
diff --git a/javalin/src/test/java/io/javalin/TestCustomRequestLifecycle.kt b/javalin/src/test/java/io/javalin/TestCustomRequestLifecycle.kt
new file mode 100644
index 00000000..e946812c
--- /dev/null
+++ b/javalin/src/test/java/io/javalin/TestCustomRequestLifecycle.kt
@@ -0,0 +1,32 @@
+package io.javalin
+
+import io.javalin.http.DefaultName
+import io.javalin.http.Stage
+import io.javalin.http.StageName
+import io.javalin.testing.TestUtil
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.Test
+
+class TestCustomRequestLifecycle {
+
+    @Test
+    fun `can remove lifecycle stage`() = TestUtil.test { app, http ->
+        app.javalinServlet().lifecycle.removeIf { it.name == DefaultName.AFTER }
+        app.get("/") { ctx -> ctx.result("Hello!") }
+        app.after { ctx -> ctx.result("Overridden") }
+        assertThat(http.getBody("/")).isEqualTo("Hello!")
+    }
+
+    enum class CustomName : StageName { CustomAfter }
+
+    @Test
+    fun `can add custom lifecycle stage`() = TestUtil.test { app, http ->
+        app.javalinServlet().lifecycle.removeIf { it.name == DefaultName.AFTER }
+        app.javalinServlet().lifecycle.add(Stage(CustomName.CustomAfter) {
+            ctx.result("Static after!")
+        })
+        app.get("/") { ctx -> ctx.result("Hello!") }
+        assertThat(http.getBody("/")).isEqualTo("Static after!")
+    }
+
+}
diff --git a/javalin/src/test/java/io/javalin/TestFuture.kt b/javalin/src/test/java/io/javalin/TestFuture.kt
index a418a6f4..e5dfa592 100644
--- a/javalin/src/test/java/io/javalin/TestFuture.kt
+++ b/javalin/src/test/java/io/javalin/TestFuture.kt
@@ -2,6 +2,7 @@ package io.javalin
 
 import io.javalin.core.util.Header
 import io.javalin.http.ContentType
+import io.javalin.http.HttpCode.INTERNAL_SERVER_ERROR
 import io.javalin.http.NotFoundResponse
 import io.javalin.testing.TestUtil
 import org.assertj.core.api.Assertions.assertThat
@@ -58,19 +59,12 @@ class TestFuture {
     }
 
     @Test
-    fun `calling future in after-handler throws`() = TestUtil.test { app, http ->
-        app.get("/test-future") { it.future(getFuture("Not result")) }
-        app.after("/test-future") { ctx -> ctx.future(getFuture("Not result")) }
-        assertThat(http.get("/test-future").body).isEqualTo("Internal server error")
-        assertThat(http.get("/test-future").status).isEqualTo(500)
-    }
+    fun `errors are handled as unexpected throwables`() = TestUtil.test { app, http ->
+        app.get("/out-of-memory") { throw OutOfMemoryError() }
+        assertThat(http.getStatus("/out-of-memory")).isEqualTo(INTERNAL_SERVER_ERROR)
 
-    @Test
-    fun `calling future in an exception-handler throws`() = TestUtil.test { app, http ->
-        app.get("/test-future") { throw Exception() }
-        app.exception(Exception::class.java) { _, ctx -> ctx.future(getFuture("Not result")) }
-        assertThat(http.getBody("/test-future")).isEqualTo("")
-        assertThat(http.get("/test-future").status).isEqualTo(500)
+        app.get("/out-of-memory-future") { it.future(getFailingFuture(OutOfMemoryError())) }
+        assertThat(http.getStatus("/out-of-memory-future")).isEqualTo(INTERNAL_SERVER_ERROR)
     }
 
     @Test
@@ -82,6 +76,25 @@ class TestFuture {
         assertThat(http.getBody("/test-future")).isEqualTo("Overridden")
     }
 
+    @Test
+    fun `calling future twice cancels first future`() = TestUtil.test { app, http ->
+        val firstFuture = getFuture("Result", delay = 5000)
+        app.get("/test-future") { ctx ->
+            ctx.future(firstFuture)
+            ctx.future(CompletableFuture.completedFuture("Second future"))
+        }
+        assertThat(http.getBody("/test-future")).isEqualTo("Second future")
+        assertThat(firstFuture.isCancelled).isTrue()
+    }
+
+    @Test
+    fun `calling future in (before - get - after) handlers works`() = TestUtil.test { app, http ->
+        app.before("/future") { it.future(getFuture("before")) }
+        app.get("/future") { it.future(getFuture("nothing")) { /* do nothing */ } }
+        app.after("/future") { it.future(getFuture("${it.resultString()}, after")) }
+        assertThat(http.get("/future").body).isEqualTo("before, after")
+    }
+
     @Test
     fun `exceptions that occur during response writing are handled`() = TestUtil.test { app, http ->
         app.get("/test-future") { it.future(getFutureFailingStream()) }
@@ -120,30 +133,52 @@ class TestFuture {
         assertThat(http.get("/").status).isEqualTo(404)
     }
 
+    @Test
+    fun `can use future in exception mapper`() = TestUtil.test { app, http ->
+        app.get("/") { throw Exception("Oh no!") }
+        app.exception(Exception::class.java) { _, ctx -> ctx.future(CompletableFuture.completedFuture("Wee")) }
+        assertThat(http.get("/").body).isEqualTo("Wee")
+    }
+
     private val impatientServer: Javalin by lazy { Javalin.create { it.asyncRequestTimeout = 5 } }
 
     @Test
     fun `default timeout error isn't jetty branded`() = TestUtil.test(impatientServer) { app, http ->
-        app.get("/") { it.future(getFuture("Test", delay = 50)) }
+        app.get("/") { it.future(getFuture("Test", delay = 5000)) }
         assertThat(http.get("/").body).isEqualTo("Request timed out")
     }
 
     @Test
     fun `can override timeout with custom error message`() = TestUtil.test(impatientServer) { app, http ->
-        app.get("/") { it.future(getFuture("Test", delay = 50)) }
+        app.get("/") { it.future(getFuture("Test", delay = 5000)) }
         app.error(500) { ctx -> ctx.result("My own simple error message") }
         assertThat(http.get("/").body).isEqualTo("My own simple error message")
     }
 
     @Test
     fun `timed out futures are canceled`() = TestUtil.test(impatientServer) { app, http ->
+        val future = getFuture("Test", delay = 5000)
+        app.get("/") { it.future(future) }
+        assertThat(http.get("/").body).isEqualTo("Request timed out")
+        assertThat(future.isCancelled).isTrue()
+    }
+
+    @Test
+    fun `user's future should be cancelled in case of exception in handler`() = TestUtil.test(impatientServer) { app, http ->
         val future = CompletableFuture<String>()
         app.get("/") {
-            Executors.newSingleThreadScheduledExecutor().schedule({
-                future.complete("Test")
-            }, 50, TimeUnit.MILLISECONDS)
             it.future(future)
+            throw RuntimeException()
         }
+        assertThat(http.get("/").body).isEqualTo("Internal server error")
+        assertThat(future.isCancelled).isTrue()
+    }
+
+    @Test
+    fun `latest timed out future is canceled`() = TestUtil.test(impatientServer) { app, http ->
+        app.before { it.future(CompletableFuture.completedFuture("Success")) }
+        val future = getFuture("Test", delay = 5000)
+        app.get("/") { it.future(future) }
         assertThat(http.get("/").body).isEqualTo("Request timed out")
         assertThat(future.isCancelled).isTrue()
     }
@@ -161,7 +196,7 @@ class TestFuture {
     }
 
     private fun getFailingFuture(failure: Throwable): CompletableFuture<String> {
-        return CompletableFuture.supplyAsync({ throw failure })
+        return CompletableFuture.supplyAsync { throw failure }
     }
 
     private fun getFutureFailingStream(): CompletableFuture<InputStream> {
diff --git a/javalin/src/test/java/io/javalin/testing/HttpUtil.kt b/javalin/src/test/java/io/javalin/testing/HttpUtil.kt
index 9a3d09d1..2f526353 100644
--- a/javalin/src/test/java/io/javalin/testing/HttpUtil.kt
+++ b/javalin/src/test/java/io/javalin/testing/HttpUtil.kt
@@ -10,6 +10,7 @@ import com.mashape.unirest.http.HttpMethod
 import com.mashape.unirest.http.Unirest
 import com.mashape.unirest.request.HttpRequestWithBody
 import io.javalin.http.ContentType
+import io.javalin.http.HttpCode
 import org.apache.http.impl.client.HttpClients
 
 class HttpUtil(port: Int) {
@@ -24,6 +25,7 @@ class HttpUtil(port: Int) {
     fun get(path: String) = Unirest.get(origin + path).asString()
     fun get(path: String, headers: Map<String, String>) = Unirest.get(origin + path).headers(headers).asString()
 
+    fun getStatus(path: String) = HttpCode.forStatus(get(path).status)
     fun getBody(path: String) = Unirest.get(origin + path).asString().body
     fun getBody(path: String, headers: Map<String, String>) = Unirest.get(origin + path).headers(headers).asString().body
     fun post(path: String) = Unirest.post(origin + path)