From c30dfd1a37e7900938f765fa9d5a96a58fc95210 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 17 Jun 2026 04:42:57 +0300 Subject: [PATCH 1/3] fix: route JDK transport async dispatch failures through the future MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JdkHttpTransport.executeAsync guarded only the request-adaptation call. The dispatch kickoff that follows on the caller's thread — HttpClient.sendAsync plus the bridgeAsyncResponse wiring — ran unguarded. sendAsync does not guarantee that every failure reaches its returned future: on a closed java.net.http.HttpClient (JDK 21+, where the client is AutoCloseable) it throws synchronously, so the exception escaped on the caller's thread and bypassed the future. That breaks the method's documented contract that all errors arrive through the returned CompletableFuture, and a future-composing caller's .exceptionally/.handle would never observe such a throw. Widen the try/catch to enclose the whole post-adaptation dispatch path so any synchronous throw becomes a failed future. The OkHttp transport is already correct here — newCall does no throwing work and dispatch failures arrive via Callback.onFailure — so it is left unchanged apart from a comment recording why its post-adapt path is future-safe. The catch stays at Exception (not RuntimeException) in both transports: the intent is that any non-fatal failure, including an unexpected adapter bug such as an NPE, surfaces via the future; only Error / JVM-fatal conditions propagate. The inline comments now state that breadth is deliberate. Both transports' async adaptation-failure tests now assert future.isCompletedExceptionally() is already true on return (completion is synchronous, not merely eventual) and assert a substring of the adapter's message so an unrelated IllegalArgumentException cannot satisfy the test. A new JDK test closes an AutoCloseable client and asserts the synchronous sendAsync throw is delivered through the future. Closes #120 Closes #121 Closes #122 --- .../sdk/transport/jdkhttp/JdkHttpTransport.kt | 35 +++++++++-------- .../transport/jdkhttp/JdkHttpTransportTest.kt | 38 +++++++++++++++++++ .../sdk/transport/okhttp/OkHttpTransport.kt | 11 ++++-- .../transport/okhttp/OkHttpTransportTest.kt | 13 +++++++ 4 files changed, 79 insertions(+), 18 deletions(-) diff --git a/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransport.kt b/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransport.kt index fdb0e83a..5b3858f4 100644 --- a/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransport.kt +++ b/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransport.kt @@ -138,21 +138,26 @@ public class JdkHttpTransport private constructor( * completions to release the body's connection back to the pool. */ override fun executeAsync(request: Request): CompletableFuture { - val jdkRequest = - try { - requestAdapter.adapt(request, responseTimeout) - } catch (e: Exception) { - // The async contract is that errors arrive through the returned future. Request - // adaptation runs on the caller's thread and can throw (e.g. a CONNECT request the - // JDK client rejects), so route the failure into a failed future instead of - // throwing synchronously where a future-composing caller's .exceptionally/.handle - // would never observe it. Errors (OOM and other JVM-fatal conditions) are left to - // propagate up the caller's stack rather than be packaged into a future that may - // never be awaited. - return CompletableFuture.failedFuture(e) - } - val inFlight = client.sendAsync(jdkRequest, HttpResponse.BodyHandlers.ofInputStream()) - return bridgeAsyncResponse(inFlight) { jdkResponse -> responseAdapter.adapt(request, jdkResponse) } + return try { + val jdkRequest = requestAdapter.adapt(request, responseTimeout) + // `sendAsync` is inside the guard too: it does not promise that every failure arrives + // through its returned future. On a closed `java.net.http.HttpClient` (JDK 21+, where + // the client is `AutoCloseable`) it throws synchronously on the caller's thread, which + // would bypass the future and break the error-delivery contract documented above. + val inFlight = client.sendAsync(jdkRequest, HttpResponse.BodyHandlers.ofInputStream()) + bridgeAsyncResponse(inFlight) { jdkResponse -> responseAdapter.adapt(request, jdkResponse) } + } catch (e: Exception) { + // The async contract is that errors arrive through the returned future. The dispatch + // path above runs on the caller's thread and can throw — request adaptation rejecting a + // CONNECT request, `sendAsync` on a closed client, or an unexpected adapter bug such as + // an NPE — so route any of these into a failed future instead of throwing synchronously + // where a future-composing caller's .exceptionally/.handle would never observe it. The + // breadth is intentional: catching `Exception` (not `RuntimeException`) keeps a future + // adapter step's checked exception on the future too. Only `Error` (OOM and other + // JVM-fatal conditions) is left to propagate up the caller's stack rather than be + // packaged into a future that may never be awaited. + CompletableFuture.failedFuture(e) + } } /** diff --git a/sdk-transport-jdkhttp/src/test/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransportTest.kt b/sdk-transport-jdkhttp/src/test/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransportTest.kt index 3e5ec734..8646f183 100644 --- a/sdk-transport-jdkhttp/src/test/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransportTest.kt +++ b/sdk-transport-jdkhttp/src/test/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransportTest.kt @@ -162,11 +162,49 @@ class JdkHttpTransportTest { .build() // Must return a future rather than throwing on the caller's thread. val future = transport.executeAsync(request) + // Completion is synchronous, not merely eventual: the future is already completed + // exceptionally on return, before anything is awaited. + assertTrue( + future.isCompletedExceptionally, + "adaptation failure must complete the future exceptionally synchronously on return", + ) val ex = assertFailsWith { future.get(5, TimeUnit.SECONDS) } assertTrue( ex.cause is IllegalArgumentException, "adaptation failure must surface as the future's cause, was: ${ex.cause?.let { it::class }}", ) + // Assert the message so an unrelated IllegalArgumentException cannot satisfy the test. + // RequestAdapter rejects CONNECT with "java.net.http.HttpClient does not support + // user-issued CONNECT requests." + assertTrue( + ex.cause?.message?.contains("does not support user-issued CONNECT requests") == true, + "expected the JDK CONNECT-rejection message, was: ${ex.cause?.message}", + ) + } + + @Test + fun `executeAsyncDeliversDispatchFailureThroughFuture`() { + // `sendAsync` does not promise that every failure arrives through its returned future: + // on a closed `java.net.http.HttpClient` it throws synchronously on the caller's thread. + // The dispatch path runs after a successful adaptation, so this exercises the post-adapt + // guard specifically — the failure must still arrive through the returned future, not be + // thrown on the caller's thread. `HttpClient` became `AutoCloseable` in JDK 21 (JEP 461); + // on older runtimes there is no close hook to trigger this, so the case is skipped. + val client = HttpClient.newHttpClient() + val closeable = client as? AutoCloseable ?: return // JDK 21+ only; no close hook earlier. + closeable.close() + val closedTransport = JdkHttpTransport.create(client) + + val request = simpleGet("/async-dispatch-fail") + // Must return a future rather than throwing on the caller's thread. + val future = closedTransport.executeAsync(request) + assertTrue( + future.isCompletedExceptionally, + "a synchronous sendAsync throw must complete the future exceptionally synchronously", + ) + // The closed-client throw is an unchecked exception; surface it as the future's cause + // rather than letting it escape executeAsync on the caller's thread. + assertFailsWith { future.get(5, TimeUnit.SECONDS) } } // -------- headers round-trip -------- diff --git a/sdk-transport-okhttp/src/main/kotlin/org/dexpace/sdk/transport/okhttp/OkHttpTransport.kt b/sdk-transport-okhttp/src/main/kotlin/org/dexpace/sdk/transport/okhttp/OkHttpTransport.kt index c2a30777..db5f1c13 100644 --- a/sdk-transport-okhttp/src/main/kotlin/org/dexpace/sdk/transport/okhttp/OkHttpTransport.kt +++ b/sdk-transport-okhttp/src/main/kotlin/org/dexpace/sdk/transport/okhttp/OkHttpTransport.kt @@ -116,11 +116,16 @@ public class OkHttpTransport private constructor( // adaptation runs on the caller's thread and can throw (e.g. a method/body // mismatch OkHttp rejects), so route the failure into a completed-exceptionally // future instead of throwing synchronously where a future-composing caller's - // .exceptionally/.handle would never observe it. Errors (OOM and other JVM-fatal - // conditions) are left to propagate up the caller's stack rather than be packaged - // into a future that may never be awaited. + // .exceptionally/.handle would never observe it. The breadth is intentional: + // catching `Exception` (not `RuntimeException`) also funnels an unexpected adapter + // bug such as an NPE through the future so the caller can observe it. Only `Error` + // (OOM and other JVM-fatal conditions) is left to propagate up the caller's stack + // rather than be packaged into a future that may never be awaited. return failedFuture(e) } + // The post-adaptation dispatch needs no guard: `newCall(...)` does no throwing work, and a + // dispatch failure (including a `RejectedExecutionException` from a shut-down dispatcher) + // is delivered through `Callback.onFailure` below, so it already reaches the future. val call = client.newCall(okRequest) val future = CompletableFuture() call.enqueue( diff --git a/sdk-transport-okhttp/src/test/kotlin/org/dexpace/sdk/transport/okhttp/OkHttpTransportTest.kt b/sdk-transport-okhttp/src/test/kotlin/org/dexpace/sdk/transport/okhttp/OkHttpTransportTest.kt index c4cc9dbb..77b88bd3 100644 --- a/sdk-transport-okhttp/src/test/kotlin/org/dexpace/sdk/transport/okhttp/OkHttpTransportTest.kt +++ b/sdk-transport-okhttp/src/test/kotlin/org/dexpace/sdk/transport/okhttp/OkHttpTransportTest.kt @@ -207,11 +207,24 @@ class OkHttpTransportTest { .build() // Must return a future rather than throwing on the caller's thread. val future = transport.executeAsync(request) + // Completion is synchronous, not merely eventual: the future is already completed + // exceptionally on return, before anything is awaited. + assertTrue( + future.isCompletedExceptionally, + "adaptation failure must complete the future exceptionally synchronously on return", + ) val ex = assertFailsWith { future.get(5, TimeUnit.SECONDS) } assertTrue( ex.cause is IllegalArgumentException, "adaptation failure must surface as the future's cause, was: ${ex.cause?.let { it::class }}", ) + // Assert the message so an unrelated IllegalArgumentException cannot satisfy the test. + // OkHttp's Request.Builder.method rejects a body on GET with " must not have a + // request body." + assertTrue( + ex.cause?.message?.contains("must not have a request body") == true, + "expected OkHttp's body-on-GET rejection message, was: ${ex.cause?.message}", + ) } // -------- headers round-trip -------- From 2b6be9c45f3472665ea2795c63e81bf09de09b76 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 22 Jun 2026 01:48:49 +0300 Subject: [PATCH 2/3] test: deterministically exercise the JDK transport's async dispatch guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The async dispatch-failure test closed a java.net.http.HttpClient to provoke a synchronous sendAsync throw, but two things undercut it: the JDK client returns an already-failed future for a closed client rather than throwing (so the bridge, not executeAsync's guard, handled it), and on this module's JDK 11 test runtime HttpClient is not AutoCloseable, so the test took its early-return skip path and asserted nothing on every run. Replace it with an injected HttpClient whose sendAsync throws on the caller's thread. That drives the post-adaptation guard directly and deterministically on every JDK; narrowing the guard back to wrap only request adaptation makes the test fail, confirming it pins the behaviour. Also correct the executeAsync comments, which stated as fact that sendAsync throws synchronously on a closed client. The guard is defence-in-depth for a client that throws on dispatch — not a description of the stock JDK client, which delivers such failures through the returned future. --- .../sdk/transport/jdkhttp/JdkHttpTransport.kt | 26 +++--- .../transport/jdkhttp/JdkHttpTransportTest.kt | 93 +++++++++++++++---- 2 files changed, 90 insertions(+), 29 deletions(-) diff --git a/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransport.kt b/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransport.kt index c51bffb8..6d87374d 100644 --- a/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransport.kt +++ b/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransport.kt @@ -140,22 +140,26 @@ public class JdkHttpTransport private constructor( override fun executeAsync(request: Request): CompletableFuture { return try { val jdkRequest = requestAdapter.adapt(request, responseTimeout) - // `sendAsync` is inside the guard too: it does not promise that every failure arrives - // through its returned future. On a closed `java.net.http.HttpClient` (JDK 21+, where - // the client is `AutoCloseable`) it throws synchronously on the caller's thread, which - // would bypass the future and break the error-delivery contract documented above. + // `sendAsync` is inside the guard too. Its contract does not promise that every failure + // is delivered through the returned future: the JDK's own Javadoc permits a synchronous + // `IllegalArgumentException` for a request it rejects, and a custom or future + // `HttpClient` is free to throw on the caller's thread instead of returning a failed + // future. Guarding the dispatch keeps the error-delivery contract documented above + // intact whichever way a failure surfaces. (Today's stock JDK client packages such + // failures into an already-failed future — e.g. on a closed client — which the bridge + // propagates and so never reaches this catch; the guard is for the throwing case.) val inFlight = client.sendAsync(jdkRequest, HttpResponse.BodyHandlers.ofInputStream()) bridgeAsyncResponse(inFlight) { jdkResponse -> responseAdapter.adapt(request, jdkResponse) } } catch (e: Exception) { // The async contract is that errors arrive through the returned future. The dispatch // path above runs on the caller's thread and can throw — request adaptation rejecting a - // CONNECT request, `sendAsync` on a closed client, or an unexpected adapter bug such as - // an NPE — so route any of these into a failed future instead of throwing synchronously - // where a future-composing caller's .exceptionally/.handle would never observe it. The - // breadth is intentional: catching `Exception` (not `RuntimeException`) keeps a future - // adapter step's checked exception on the future too. Only `Error` (OOM and other - // JVM-fatal conditions) is left to propagate up the caller's stack rather than be - // packaged into a future that may never be awaited. + // CONNECT request, a synchronous `sendAsync` rejection, or an unexpected adapter bug + // such as an NPE — so route any of these into a failed future instead of throwing + // synchronously where a future-composing caller's .exceptionally/.handle would never + // observe it. The breadth is intentional: catching `Exception` (not `RuntimeException`) + // keeps a future adapter step's checked exception on the future too. Only `Error` (OOM + // and other JVM-fatal conditions) is left to propagate up the caller's stack rather + // than be packaged into a future that may never be awaited. CompletableFuture.failedFuture(e) } } diff --git a/sdk-transport-jdkhttp/src/test/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransportTest.kt b/sdk-transport-jdkhttp/src/test/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransportTest.kt index 8646f183..83d5b225 100644 --- a/sdk-transport-jdkhttp/src/test/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransportTest.kt +++ b/sdk-transport-jdkhttp/src/test/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransportTest.kt @@ -24,22 +24,31 @@ import org.dexpace.sdk.io.OkioIoProvider import org.dexpace.sdk.transport.jdkhttp.internal.BodyPublishers import java.io.ByteArrayOutputStream import java.io.IOException +import java.net.Authenticator +import java.net.CookieHandler import java.net.InetSocketAddress +import java.net.ProxySelector import java.net.ServerSocket import java.net.URL import java.net.http.HttpClient import java.net.http.HttpRequest +import java.net.http.HttpResponse import java.nio.ByteBuffer import java.security.MessageDigest import java.time.Duration +import java.util.Optional import java.util.concurrent.CancellationException +import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletionException import java.util.concurrent.CountDownLatch import java.util.concurrent.ExecutionException +import java.util.concurrent.Executor import java.util.concurrent.Flow import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLParameters import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertContentEquals @@ -183,28 +192,32 @@ class JdkHttpTransportTest { } @Test - fun `executeAsyncDeliversDispatchFailureThroughFuture`() { - // `sendAsync` does not promise that every failure arrives through its returned future: - // on a closed `java.net.http.HttpClient` it throws synchronously on the caller's thread. - // The dispatch path runs after a successful adaptation, so this exercises the post-adapt - // guard specifically — the failure must still arrive through the returned future, not be - // thrown on the caller's thread. `HttpClient` became `AutoCloseable` in JDK 21 (JEP 461); - // on older runtimes there is no close hook to trigger this, so the case is skipped. - val client = HttpClient.newHttpClient() - val closeable = client as? AutoCloseable ?: return // JDK 21+ only; no close hook earlier. - closeable.close() - val closedTransport = JdkHttpTransport.create(client) - - val request = simpleGet("/async-dispatch-fail") + fun `executeAsyncRoutesSynchronousDispatchThrowThroughFuture`() { + // `sendAsync`'s contract does not promise that every failure is delivered through the + // returned future — a client may throw synchronously on the caller's thread. The dispatch + // path runs after a successful adaptation, so this injects a client whose `sendAsync` + // throws to exercise the post-adapt guard deterministically on every JDK. (The stock JDK + // client instead returns an already-failed future for, e.g., a closed client; the bridge + // propagates that and it never reaches the guard, so a real closed client cannot prove + // this path. The test double does.) The failure must arrive through the returned future, + // never escape executeAsync on the caller's thread. + val boom = IllegalStateException("synchronous dispatch failure") + val throwingTransport = JdkHttpTransport.create(SyncThrowingDispatchClient(boom)) + // Must return a future rather than throwing on the caller's thread. - val future = closedTransport.executeAsync(request) + val future = throwingTransport.executeAsync(simpleGet("/async-dispatch-throw")) + // Completion is synchronous, not merely eventual: the future is already completed + // exceptionally on return, before anything is awaited. assertTrue( future.isCompletedExceptionally, - "a synchronous sendAsync throw must complete the future exceptionally synchronously", + "a synchronous dispatch throw must complete the future exceptionally synchronously on return", + ) + val ex = assertFailsWith { future.get(5, TimeUnit.SECONDS) } + assertEquals( + boom, + ex.cause, + "the dispatch throw must surface verbatim as the future's cause, was: ${ex.cause}", ) - // The closed-client throw is an unchecked exception; surface it as the future's cause - // rather than letting it escape executeAsync on the caller's thread. - assertFailsWith { future.get(5, TimeUnit.SECONDS) } } // -------- headers round-trip -------- @@ -870,4 +883,48 @@ class JdkHttpTransportTest { override fun canHandle(challenges: List): Boolean = false } + + /** + * A minimal [HttpClient] whose async dispatch throws synchronously on the caller's thread, + * modelling a client (or a future JDK) that does not package every `sendAsync` failure into the + * returned future. Only [sendAsync] is exercised by [JdkHttpTransport.executeAsync]; the rest + * are inert stubs that are never invoked on the dispatch path under test. + */ + private class SyncThrowingDispatchClient( + private val failure: RuntimeException, + ) : HttpClient() { + override fun cookieHandler(): Optional = Optional.empty() + + override fun connectTimeout(): Optional = Optional.empty() + + override fun followRedirects(): HttpClient.Redirect = HttpClient.Redirect.NEVER + + override fun proxy(): Optional = Optional.empty() + + override fun sslContext(): SSLContext = SSLContext.getDefault() + + override fun sslParameters(): SSLParameters = SSLParameters() + + override fun authenticator(): Optional = Optional.empty() + + override fun version(): HttpClient.Version = HttpClient.Version.HTTP_1_1 + + override fun executor(): Optional = Optional.empty() + + override fun send( + request: HttpRequest, + responseBodyHandler: HttpResponse.BodyHandler, + ): HttpResponse = throw UnsupportedOperationException("synchronous send is not used by this test double") + + override fun sendAsync( + request: HttpRequest, + responseBodyHandler: HttpResponse.BodyHandler, + ): CompletableFuture> = throw failure + + override fun sendAsync( + request: HttpRequest, + responseBodyHandler: HttpResponse.BodyHandler, + pushPromiseHandler: HttpResponse.PushPromiseHandler?, + ): CompletableFuture> = throw failure + } } From 62f85a6e739ef255d576ebecbc088fa4438df05a Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 22 Jun 2026 02:18:40 +0300 Subject: [PATCH 3/3] fix: release the JDK exchange when async dispatch wiring fails JdkHttpTransport.executeAsync guards the whole post-adaptation dispatch path so a synchronous throw becomes a failed future. But if sendAsync had already returned a live in-flight exchange when a later step threw, that exchange kept running on a future nothing would await, leaking its connection. Hoist the in-flight handle out of the try and cancel it from the catch so the exchange is aborted on the failure path; the cancel is a no-op when the throw happened at or before dispatch (handle still null). Also document on the test's HttpClient double why its sslContext() and send() stubs are inert: executeAsync only ever calls sendAsync, so neither is reached on the path under test. --- .../sdk/transport/jdkhttp/JdkHttpTransport.kt | 13 ++++++++++++- .../sdk/transport/jdkhttp/JdkHttpTransportTest.kt | 6 +++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransport.kt b/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransport.kt index 6d87374d..a80a4b0e 100644 --- a/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransport.kt +++ b/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransport.kt @@ -138,6 +138,10 @@ public class JdkHttpTransport private constructor( * completions to release the body's connection back to the pool. */ override fun executeAsync(request: Request): CompletableFuture { + // Held outside the try so the catch can release the JDK exchange if dispatch succeeded but a + // later step threw (see the catch). Null until `sendAsync` returns: a throw at or before + // dispatch leaves nothing to clean up. + var inFlight: CompletableFuture>? = null return try { val jdkRequest = requestAdapter.adapt(request, responseTimeout) // `sendAsync` is inside the guard too. Its contract does not promise that every failure @@ -148,7 +152,7 @@ public class JdkHttpTransport private constructor( // intact whichever way a failure surfaces. (Today's stock JDK client packages such // failures into an already-failed future — e.g. on a closed client — which the bridge // propagates and so never reaches this catch; the guard is for the throwing case.) - val inFlight = client.sendAsync(jdkRequest, HttpResponse.BodyHandlers.ofInputStream()) + inFlight = client.sendAsync(jdkRequest, HttpResponse.BodyHandlers.ofInputStream()) bridgeAsyncResponse(inFlight) { jdkResponse -> responseAdapter.adapt(request, jdkResponse) } } catch (e: Exception) { // The async contract is that errors arrive through the returned future. The dispatch @@ -160,6 +164,13 @@ public class JdkHttpTransport private constructor( // keeps a future adapter step's checked exception on the future too. Only `Error` (OOM // and other JVM-fatal conditions) is left to propagate up the caller's stack rather // than be packaged into a future that may never be awaited. + // + // If `sendAsync` already returned an in-flight exchange, the only way control reaches + // here is `bridgeAsyncResponse` throwing before it wired that future's cancellation + // propagation — its result is never returned, so cancel the exchange directly to release + // its connection rather than leak it on a future nothing will await. The cancel is a + // no-op when `inFlight` is null (the throw happened at or before dispatch). + inFlight?.cancel(true) CompletableFuture.failedFuture(e) } } diff --git a/sdk-transport-jdkhttp/src/test/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransportTest.kt b/sdk-transport-jdkhttp/src/test/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransportTest.kt index 83d5b225..b78be3d0 100644 --- a/sdk-transport-jdkhttp/src/test/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransportTest.kt +++ b/sdk-transport-jdkhttp/src/test/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransportTest.kt @@ -888,7 +888,11 @@ class JdkHttpTransportTest { * A minimal [HttpClient] whose async dispatch throws synchronously on the caller's thread, * modelling a client (or a future JDK) that does not package every `sendAsync` failure into the * returned future. Only [sendAsync] is exercised by [JdkHttpTransport.executeAsync]; the rest - * are inert stubs that are never invoked on the dispatch path under test. + * are inert stubs that are never invoked on the dispatch path under test. In particular + * [sslContext] returns `SSLContext.getDefault()` only to satisfy the abstract member — its + * checked `NoSuchAlgorithmException` and the cost of materialising the JVM default SSL context + * never apply here because the path under test never reads the context; likewise [send] throws + * rather than returning a stub response, as the synchronous path is never reached. */ private class SyncThrowingDispatchClient( private val failure: RuntimeException,