Repository: tipsy/javalin (kotlin)

Issue:
Option to be informed if client cancels a request
**Describe the feature**

In NodeJS (using the Express library), [it's possible to be informed](https://stackoverflow.com/a/6572790/4337765) if a HTTP client (e.g. a browser) canceled a request. That option doesn't seem to be available in Javalin, nor in the underlying `HttpServletRequest`. I wonder if there's anything Javalin could do to make a hook similar to the `req.on("close", ...)` hook in NodeJS/Express, so that we can stop processing on our side (e.g. stop database queries).

**Additional context**

We have some long-running calls that our frontend does against of our backend APIs. We often see that users change query parameters or settings that cause the request to be canceled on the client. However, our backend happily continues processing those calls, which also slows down subsequent requests. It'd be great if we could be informed server-side that the client canceled a query and stop processing on our side.

Pull request description:
I think it may be quite cool enhancement to the async request setup. See more in the associated issue:

* Resolves #2053

Patch:
diff --git a/javalin/src/main/java/io/javalin/http/servlet/JavalinServlet.kt b/javalin/src/main/java/io/javalin/http/servlet/JavalinServlet.kt
index 5255d86c6..b457723db 100644
--- a/javalin/src/main/java/io/javalin/http/servlet/JavalinServlet.kt
+++ b/javalin/src/main/java/io/javalin/http/servlet/JavalinServlet.kt
@@ -12,8 +12,8 @@ import io.javalin.http.HttpStatus.INTERNAL_SERVER_ERROR
 import io.javalin.http.HttpStatus.REQUEST_TIMEOUT
 import io.javalin.http.servlet.SubmitOrder.FIRST
 import io.javalin.http.servlet.SubmitOrder.LAST
-import io.javalin.http.util.AsyncUtil.addListener
 import io.javalin.http.util.AsyncUtil.isAsync
+import io.javalin.http.util.AsyncUtil.newAsyncListener
 import io.javalin.http.util.ETagGenerator
 import io.javalin.util.javalinLazy
 import jakarta.servlet.http.HttpServlet
@@ -74,7 +74,7 @@ class JavalinServlet(val cfg: JavalinConfig) : HttpServlet() {
         if (!isAsync()) startAsyncAndAddDefaultTimeoutListeners() // start async if not already started
 
         val userFuture = handleTask { userFutureSupplier.get() } ?: return handleSync() // get future from supplier or handle error
-        req().asyncContext.addListener(onTimeout = { userFuture.cancel(true) }) // cancel user's future if timeout occurs
+        req().asyncContext.addListener(newAsyncListener(onTimeout = { userFuture.cancel(true) })) // cancel user's future if timeout occurs
 
         userFuture
             .thenApply { handleSync() }
@@ -86,12 +86,12 @@ class JavalinServlet(val cfg: JavalinConfig) : HttpServlet() {
 
     private fun JavalinServletContext.startAsyncAndAddDefaultTimeoutListeners() = req().startAsync().also {
         it.timeout = cfg.http.asyncTimeout
-        it.addListener(onTimeout = { // a timeout avoids the pipeline - we need to handle it manually + it's not thread-safe
+        it.addListener(newAsyncListener(onTimeout = { // a timeout avoids the pipeline - we need to handle it manually + it's not thread-safe
             status(INTERNAL_SERVER_ERROR) // default error handling
             router.handleHttpError(statusCode(), this) // user defined error handling
             if (resultInputStream() == null) result(REQUEST_TIMEOUT.message) // write default response only if handler didn't do anything
             writeResponseAndLog()
-        })
+        }))
     }
 
     private fun <R> JavalinServletContext.handleTask(handler: TaskHandler<R>): R? =
diff --git a/javalin/src/main/java/io/javalin/http/util/AsyncUtil.kt b/javalin/src/main/java/io/javalin/http/util/AsyncUtil.kt
index 8d0d1bc6b..231ee7402 100644
--- a/javalin/src/main/java/io/javalin/http/util/AsyncUtil.kt
+++ b/javalin/src/main/java/io/javalin/http/util/AsyncUtil.kt
@@ -4,7 +4,6 @@ import io.javalin.config.HttpConfig
 import io.javalin.http.Context
 import io.javalin.util.ConcurrencyUtil
 import io.javalin.util.function.ThrowingRunnable
-import jakarta.servlet.AsyncContext
 import jakarta.servlet.AsyncEvent
 import jakarta.servlet.AsyncListener
 import java.util.concurrent.CompletableFuture
@@ -13,23 +12,29 @@ import java.util.concurrent.TimeUnit.MILLISECONDS
 import java.util.concurrent.TimeoutException
 import java.util.function.Consumer
 
-class AsyncTaskConfig(
+class AsyncTaskConfig {
     /**
      * Thread-pool used to execute the given task,
      * You can change this default in [io.javalin.config.JavalinConfig].
      */
-    @JvmField var executor: ExecutorService? = null,
+    @JvmField var executor: ExecutorService? = null
     /**
      * Timeout in milliseconds,
      * by default it's 0 which means timeout watcher is disabled.
      */
-    @JvmField var timeout: Long = 0,
+    @JvmField var timeout: Long = 0
+
+    @JvmSynthetic internal var onTimeout: Consumer<Context>? = null
+
     /**
      * Timeout listener executed when [TimeoutException] is thrown in specified task.
      * This timeout listener is a part of request lifecycle, so you can still modify context here.
      */
-    @JvmField var onTimeout: Consumer<Context>? = null,
-)
+    fun onTimeout(timeoutListener: Consumer<Context>) {
+        this.onTimeout = timeoutListener
+    }
+
+}
 
 internal class AsyncExecutor(useVirtualThreads: Boolean) {
 
@@ -72,23 +77,23 @@ internal class AsyncExecutor(useVirtualThreads: Boolean) {
     }
 }
 
-internal object AsyncUtil {
+object AsyncUtil {
 
     internal fun Context.isAsync(): Boolean =
         req().isAsyncStarted
 
-    internal fun AsyncContext.addListener(
-        onComplete: (AsyncEvent) -> Unit = {},
-        onError: (AsyncEvent) -> Unit = {},
+    @JvmOverloads
+    fun newAsyncListener(
         onStartAsync: (AsyncEvent) -> Unit = {},
+        onError: (AsyncEvent) -> Unit = {},
         onTimeout: (AsyncEvent) -> Unit = {},
-    ): AsyncContext = apply {
-        addListener(object : AsyncListener {
+        onComplete: (AsyncEvent) -> Unit = {},
+    ): AsyncListener =
+        object : AsyncListener {
             override fun onComplete(event: AsyncEvent) = onComplete(event)
             override fun onError(event: AsyncEvent) = onError(event)
             override fun onStartAsync(event: AsyncEvent) = onStartAsync(event)
             override fun onTimeout(event: AsyncEvent) = onTimeout(event)
-        })
-    }
+        }
 
 }


Test patch:
diff --git a/javalin/src/test/java/io/javalin/TestFuture.kt b/javalin/src/test/java/io/javalin/TestFuture.kt
index 325b25422..83ab398f9 100644
--- a/javalin/src/test/java/io/javalin/TestFuture.kt
+++ b/javalin/src/test/java/io/javalin/TestFuture.kt
@@ -19,7 +19,6 @@ import java.util.concurrent.CompletableFuture.completedFuture
 import java.util.concurrent.Executors
 import java.util.concurrent.TimeUnit
 import java.util.concurrent.TimeUnit.SECONDS
-import java.util.function.Consumer
 
 @Timeout(value = 5, unit = SECONDS)
 internal class TestFuture {
@@ -277,7 +276,7 @@ internal class TestFuture {
             app.get("/") { ctx ->
                 ctx.async({ config ->
                     config.timeout = 10L
-                    config.onTimeout = Consumer { it.result("Timeout") }
+                    config.onTimeout { it.result("Timeout") }
                 }) {
                     Thread.sleep(500L)
                     ctx.result("Result")
diff --git a/javalin/src/test/java/io/javalin/examples/HelloWorldAsync.java b/javalin/src/test/java/io/javalin/examples/HelloWorldAsync.java
index 565aa8ed9..7ba2281e9 100644
--- a/javalin/src/test/java/io/javalin/examples/HelloWorldAsync.java
+++ b/javalin/src/test/java/io/javalin/examples/HelloWorldAsync.java
@@ -17,7 +17,7 @@ public static void main(String[] args) {
             ctx.async(
                 (asyncConfig) -> {
                     asyncConfig.timeout = 1000L;
-                    asyncConfig.onTimeout = timeoutCtx -> timeoutCtx.result("Request timed out :<");
+                    asyncConfig.onTimeout(timeoutCtx -> timeoutCtx.result("Request timed out :<"));
                 },
                 () -> {
                     Thread.sleep((long) (Math.random() * 2000L));