From f6f26dfd4038f072b98090b2ba157e53dfd325fe Mon Sep 17 00:00:00 2001 From: codingnoobno1 <164862821+codingnoobno1@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:51:26 +0530 Subject: [PATCH 1/2] fix(jextract/jni): preserve underlying exception in async closures Future.get() reports a failed Java future as ExecutionException. Unwrap that one layer before completeExceptionally so the outer future keeps the original throwable. Fixes #915 Co-authored-by: Cursor --- ...wift2JavaGenerator+NativeTranslation.swift | 3 +- .../JNIMethodIDCaches.swift | 22 ++++++++++ .../_JNISwiftErrors.swift | 40 +++++++++++++++++++ .../JNI/JNIAsyncTests.swift | 27 +++++++++++-- .../JNI/JNIDistributedActorTests.swift | 3 +- 5 files changed, 87 insertions(+), 8 deletions(-) diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift index 4ca6a03ca..01e048a95 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift @@ -2035,8 +2035,7 @@ extension JNISwift2JavaGenerator { printer.print( """ let catchEnvironment = try! JavaVirtualMachine.shared().environment() - let exception = catchEnvironment.makeJavaThrowable(for: error) - _ = catchEnvironment.interface.CallBooleanMethodA(catchEnvironment, globalFuture, \(completeExceptionallyMethodID), [jvalue(l: exception)]) + catchEnvironment.completeFutureExceptionally(error, future: globalFuture, method: \(completeExceptionallyMethodID)) """ ) } diff --git a/Sources/SwiftJavaRuntimeSupport/JNIMethodIDCaches.swift b/Sources/SwiftJavaRuntimeSupport/JNIMethodIDCaches.swift index c675ac7c2..885ce8d67 100644 --- a/Sources/SwiftJavaRuntimeSupport/JNIMethodIDCaches.swift +++ b/Sources/SwiftJavaRuntimeSupport/JNIMethodIDCaches.swift @@ -46,6 +46,28 @@ extension _JNIMethodIDCache { } } + /// `Future.get()` wraps the throwable a future was completed with. + public enum ExecutionException { + private static let getCauseMethod = Method( + name: "getCause", + signature: "()Ljava/lang/Throwable;" + ) + + private static let cache = _JNIMethodIDCache( + className: "java/util/concurrent/ExecutionException", + methods: [getCauseMethod] + ) + + public static var `class`: jclass { + cache.javaClass + } + + /// Throwable.getCause(), resolved on `ExecutionException` so the inherited method is found. + public static var getCause: jmethodID { + cache.methods[getCauseMethod]! + } + } + public enum SimpleCompletableFuture { private static let completeMethod = Method( name: "complete", diff --git a/Sources/SwiftJavaRuntimeSupport/_JNISwiftErrors.swift b/Sources/SwiftJavaRuntimeSupport/_JNISwiftErrors.swift index 2fb72c0d4..78bb87561 100644 --- a/Sources/SwiftJavaRuntimeSupport/_JNISwiftErrors.swift +++ b/Sources/SwiftJavaRuntimeSupport/_JNISwiftErrors.swift @@ -42,6 +42,46 @@ extension JNIEnvironment { return makeSwiftErrorException(for: error) } + /// Completes a Java future exceptionally from an async JNI thunk. + /// + /// `Future.get()` surfaces failure as `java.util.concurrent.ExecutionException`. + /// The throwable the Java caller completed with is that exception's cause. + /// Passing the wrapper itself to `completeExceptionally` makes the outer + /// future's cause a generic wrapper instead of the original exception + /// (swift-java issue #915). One `ExecutionException` layer is removed when + /// its cause is non-null. Every other error uses ``makeJavaThrowable(for:)``. + public func completeFutureExceptionally( + _ error: any Error, + future: jobject?, + method: jmethodID + ) { + let failure = asyncFailureThrowable(for: error) + _ = interface.CallBooleanMethodA(self, future, method, [jvalue(l: failure.throwable)]) + if failure.deleteLocalRef, let throwable = failure.throwable { + interface.DeleteLocalRef(self, throwable) + } + } + + /// The throwable to pass to `completeExceptionally`, plus whether it is a + /// local ref this function allocated and must delete after the JNI call. + private func asyncFailureThrowable( + for error: any Error + ) -> (throwable: jthrowable?, deleteLocalRef: Bool) { + if let javaObject = error as? any AnyJavaObject, + let throwable = javaObject.as(Throwable.self), + interface.IsInstanceOf(self, throwable.javaThis, _JNIMethodIDCache.ExecutionException.class) == JNI_TRUE, + let cause = interface.CallObjectMethodA( + self, + throwable.javaThis, + _JNIMethodIDCache.ExecutionException.getCause, + nil + ) + { + return (cause, true) + } + return (makeJavaThrowable(for: error), false) + } + /// Throw the given Swift error as a Java exception. public func throwSwiftErrorAsJava(_ error: any Error) { guard let throwable = makeJavaThrowable(for: error) else { diff --git a/Tests/JExtractSwiftTests/JNI/JNIAsyncTests.swift b/Tests/JExtractSwiftTests/JNI/JNIAsyncTests.swift index 866c542d4..ed1759461 100644 --- a/Tests/JExtractSwiftTests/JNI/JNIAsyncTests.swift +++ b/Tests/JExtractSwiftTests/JNI/JNIAsyncTests.swift @@ -154,8 +154,7 @@ struct JNIAsyncTests { } catch { let catchEnvironment = try! JavaVirtualMachine.shared().environment() - let exception = catchEnvironment.makeJavaThrowable(for: error) - _ = catchEnvironment.interface.CallBooleanMethodA(catchEnvironment, globalFuture, _JNIMethodIDCache.CompletableFuture.completeExceptionally, [jvalue(l: exception)]) + catchEnvironment.completeFutureExceptionally(error, future: globalFuture, method: _JNIMethodIDCache.CompletableFuture.completeExceptionally) } } } @@ -174,8 +173,7 @@ struct JNIAsyncTests { } catch { let catchEnvironment = try! JavaVirtualMachine.shared().environment() - let exception = catchEnvironment.makeJavaThrowable(for: error) - _ = catchEnvironment.interface.CallBooleanMethodA(catchEnvironment, globalFuture, _JNIMethodIDCache.CompletableFuture.completeExceptionally, [jvalue(l: exception)]) + catchEnvironment.completeFutureExceptionally(error, future: globalFuture, method: _JNIMethodIDCache.CompletableFuture.completeExceptionally) } } } @@ -185,6 +183,27 @@ struct JNIAsyncTests { ) } + /// Issue #915: a failed Java future is observed as `ExecutionException`. + /// The thunk must hand that failure to `completeFutureExceptionally`, which + /// keeps `getCause()` rather than completing with the wrapper. + @Test("Async JNI failure preserves the Java exception cause (#915)") + func asyncFailurePreservesJavaExceptionCause() throws { + try assertOutput( + input: "public func async() async throws", + .jni, + .swift, + detectChunkByInitialLines: 2, + expectedChunks: [ + """ + catch { + let catchEnvironment = try! JavaVirtualMachine.shared().environment() + catchEnvironment.completeFutureExceptionally(error, future: globalFuture, method: _JNIMethodIDCache.CompletableFuture.completeExceptionally) + } + """ + ] + ) + } + @Test("Import: (Int64) async -> Int64 (Java, CompletableFuture)") func completableFuture_asyncIntToInt_java() throws { try assertOutput( diff --git a/Tests/JExtractSwiftTests/JNI/JNIDistributedActorTests.swift b/Tests/JExtractSwiftTests/JNI/JNIDistributedActorTests.swift index 6b16459de..d5758ea00 100644 --- a/Tests/JExtractSwiftTests/JNI/JNIDistributedActorTests.swift +++ b/Tests/JExtractSwiftTests/JNI/JNIDistributedActorTests.swift @@ -73,8 +73,7 @@ struct JNIDistributedActorTests { """ catch { let catchEnvironment = try! JavaVirtualMachine.shared().environment() - let exception = catchEnvironment.makeJavaThrowable(for: error) - _ = catchEnvironment.interface.CallBooleanMethodA(catchEnvironment, globalFuture, _JNIMethodIDCache.CompletableFuture.completeExceptionally, [jvalue(l: exception)]) + catchEnvironment.completeFutureExceptionally(error, future: globalFuture, method: _JNIMethodIDCache.CompletableFuture.completeExceptionally) } """, ] From f90a76db858d4f97e2a39198339b66eca84f20af Mon Sep 17 00:00:00 2001 From: codingnoobno1 <164862821+codingnoobno1@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:47:29 +0530 Subject: [PATCH 2/2] Test that an async JNI failure keeps the original Java exception. The generator check never calls future.get(). Throw ExecutionException with a RuntimeException cause and assert the outer future still reports that cause. Co-authored-by: Cursor --- .../Sources/MySwiftLibrary/Async.swift | 8 ++++++ .../java/com/example/swift/AsyncTest.java | 11 ++++++++ .../Exceptions/ExecutionException.swift | 25 +++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 Sources/SwiftJava/Exceptions/ExecutionException.swift diff --git a/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/Async.swift b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/Async.swift index bb6e8aee6..4cadd33b9 100644 --- a/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/Async.swift +++ b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/Async.swift @@ -39,6 +39,14 @@ public func asyncThrows() async throws { throw MySwiftError.swiftError } +/// Issue #915. `Future.get()` reports failure as `ExecutionException`. +/// The generated async thunk must complete the outer future with that +/// exception's cause, not with the wrapper itself. +public func asyncThrowsJavaFailure() async throws { + let cause = RuntimeException("Java async failed") + throw ExecutionException(cause) +} + public func asyncString(input: String) async -> String { input } diff --git a/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/AsyncTest.java b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/AsyncTest.java index 4ff86284d..c95ba5840 100644 --- a/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/AsyncTest.java +++ b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/AsyncTest.java @@ -76,6 +76,17 @@ void asyncThrows() { assertEquals("swiftError", cause.getMessage()); } + @Test + void asyncThrowsJavaFailure_preservesRuntimeExceptionCause() { + Future future = MySwiftLibrary.asyncThrowsJavaFailure(); + + ExecutionException ex = assertThrows(ExecutionException.class, future::get); + Throwable cause = ex.getCause(); + assertNotNull(cause); + assertEquals(RuntimeException.class, cause.getClass()); + assertEquals("Java async failed", cause.getMessage()); + } + @Test void asyncOptional() throws Exception { Future future = MySwiftLibrary.asyncOptional(42); diff --git a/Sources/SwiftJava/Exceptions/ExecutionException.swift b/Sources/SwiftJava/Exceptions/ExecutionException.swift new file mode 100644 index 000000000..d1f4465a7 --- /dev/null +++ b/Sources/SwiftJava/Exceptions/ExecutionException.swift @@ -0,0 +1,25 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import SwiftJavaJNICore + +/// `java.util.concurrent.ExecutionException`. +/// +/// `Future.get()` throws this with the future's original failure as its cause. +@JavaClass("java.util.concurrent.ExecutionException") +open class ExecutionException: Exception { + /// Java constructor `ExecutionException(Throwable cause)`. + @JavaMethod + @_nonoverride public convenience init(_ arg0: Throwable?, environment: JNIEnvironment? = nil) +}