Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,17 @@ void asyncThrows() {
assertEquals("swiftError", cause.getMessage());
}

@Test
void asyncThrowsJavaFailure_preservesRuntimeExceptionCause() {
Future<Void> 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<OptionalLong> future = MySwiftLibrary.asyncOptional(42);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
"""
)
}
Expand Down
25 changes: 25 additions & 0 deletions Sources/SwiftJava/Exceptions/ExecutionException.swift
Original file line number Diff line number Diff line change
@@ -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)
}
22 changes: 22 additions & 0 deletions Sources/SwiftJavaRuntimeSupport/JNIMethodIDCaches.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
40 changes: 40 additions & 0 deletions Sources/SwiftJavaRuntimeSupport/_JNISwiftErrors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
27 changes: 23 additions & 4 deletions Tests/JExtractSwiftTests/JNI/JNIAsyncTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand All @@ -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)
}
}
}
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
""",
]
Expand Down