From a3fa27010ccf71cfa3eac6b1d1df2434a50cd69f Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Tue, 3 Nov 2026 21:00:23 +0100 Subject: [PATCH 1/2] JNI: async closures implemented in Java return a CompletableFuture An async closure parameter got a synchronous Java interface: Swift called it and continued without waiting for the Java side. Its functional interface now returns `CompletableFuture` (boxed, `Void` for no result), and the Swift closure awaits it with `_awaitCompletableFuture`, which resumes the task when the future completes instead of blocking a thread. It's the same mechanism as `async` requirements of Java-implemented protocols: `apply` is called dynamically, because wrap-java doesn't bind a generic `CompletableFuture` result, and the value is converted on the thread the task resumed on. A failed future throws in a throwing closure and traps otherwise. Results: `Void`, types translated directly and jextracted types. Async closures never use the non-escaping lowering or a JDK functional interface, and need the `completableFuture` async mode. The Java API matches swiftlang/swift-java#913, which awaits with a blocking `get()`. Co-Authored-By: Claude Opus 5.5 --- ...avaGenerator+EscapingClosureWrapJava.swift | 4 + ...Generator+InterfaceWrapperGeneration.swift | 9 +- ...ISwift2JavaGenerator+JavaTranslation.swift | 6 +- ...wift2JavaGenerator+NativeTranslation.swift | 37 ++++-- .../KnownFunctionalInterfaces.swift | 5 +- .../JNI/JNIAsyncClosureTests.swift | 117 ++++++++++++++++++ 6 files changed, 167 insertions(+), 11 deletions(-) create mode 100644 Tests/JExtractSwiftTests/JNI/JNIAsyncClosureTests.swift diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+EscapingClosureWrapJava.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+EscapingClosureWrapJava.swift index e9de9783f..467cbcb06 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+EscapingClosureWrapJava.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+EscapingClosureWrapJava.swift @@ -144,6 +144,10 @@ extension JNISwift2JavaGenerator { } let paramList = "(\(params.joined(separator: .comma)))" + // Java returns a `CompletableFuture` for an async closure, which the closure awaits. + if type.isAsync { + return "\(paramList) -> JavaCompletableFuture!" + } if type.resultType.isVoid { return paramList } else { diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift index 40a114f6d..ae3d46f11 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift @@ -145,11 +145,15 @@ extension JNISwift2JavaGenerator { /// wrapper awaits; its value arrives as a plain Java object. Supported results: `Void`, types /// translated directly (unboxed via `JavaBoxable`), and jextracted types. private func validateAsyncRequirement(_ signature: SwiftFunctionSignature) throws { + try validateAsyncResult(signature.result.type) + } + + /// An async requirement or closure implemented in Java returns a `CompletableFuture`: see `validateAsyncRequirement`. + private func validateAsyncResult(_ resultType: SwiftType) throws { guard asyncFuncMode == .completableFuture else { // A `java.util.concurrent.Future` can't notify Swift when it completes. throw JavaTranslationError.asyncRequirementNeedsCompletableFuture } - let resultType = signature.result.type if resultType.isVoid { return } @@ -173,6 +177,9 @@ extension JNISwift2JavaGenerator { javaInterfaceName: String, javaBinaryName: String, ) throws -> SyntheticEscapingClosureFunctionType { + if functionType.isAsync { + try validateAsyncResult(functionType.resultType) + } let parameterConversions = try functionType.parameters.enumerated().map { idx, param in try self.translateParameter( parameterName: param.parameterName ?? "_\(idx)", diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift index 30446963c..2ee42c06f 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift @@ -411,7 +411,11 @@ extension JNISwift2JavaGenerator { ) } - let translatedResult = try translateResult(swiftType: swiftType.resultType, methodName: name) + var translatedResult = try translateResult(swiftType: swiftType.resultType, methodName: name) + // An async closure implemented in Java returns a future, which Swift awaits. + if swiftType.isAsync { + translatedResult.javaType = .completableFuture(translatedResult.javaType) + } return TranslatedFunctionType( name: name, diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift index b0cfc3c97..5ade0533d 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift @@ -386,8 +386,8 @@ extension JNISwift2JavaGenerator { methodName: String, parentName: SwiftQualifiedTypeName ) throws -> NativeParameter { - if !functionType.isEscaping { - // The legacy lowering handles primitive arguments and results only. Other closures go through + if !functionType.isEscaping && !functionType.isAsync { + // The legacy lowering handles synchronous primitive closures only. Other closures go through // the escaping one: its Swift closure can be passed where a non-escaping one is expected. if let legacy = try? translateNonEscapingFunctionParameter( functionType: functionType, @@ -420,7 +420,7 @@ extension JNISwift2JavaGenerator { method: methodName, qualifier: parameterName, ) - let generator = JavaInterfaceProtocolWrapperGenerator() + let generator = JavaInterfaceProtocolWrapperGenerator(asyncFuncMode: config.effectiveAsyncFuncMode) let syntheticFunction = try generator.generateSyntheticClosureFunction( functionType: functionType, javaInterfaceName: javaClosureInterfaceName, @@ -1862,11 +1862,34 @@ extension JNISwift2JavaGenerator { let argumentPrefix = argPrinter.finalize() // Build result conversion - // Note: The Java interface is synchronous even for async closures. - // The async nature is on the Swift side, inferred from the expected type. var resultPrinter = SwiftPrinter() - let upcallExpr = "\(callInterfaceVar).apply(\(upcallArguments.joined(separator: .comma)))" - let resultConverted = syntheticFunction.resultConversion.render(&resultPrinter, upcallExpr) + var resultConverted: String + if fn.isAsync { + // Java returns a `CompletableFuture`, awaited without blocking a thread; a Java failure traps + // unless the closure throws. wrap-java doesn't bind a generic `CompletableFuture` result, + // so `apply` is called dynamically. + let tryKeyword = fn.isThrowing ? "try" : "try!" + let arguments = upcallArguments.isEmpty ? "" : "arguments: \(upcallArguments.joined(separator: .comma)), " + resultPrinter.print( + "let future$ = \(tryKeyword) \(callInterfaceVar).dynamicJavaMethodCall(methodName: \"apply\", \(arguments)resultType: JavaCompletableFuture?.self)" + ) + resultPrinter.print("guard let future$ else { fatalError(\"Upcall to apply unexpectedly returned a null future\") }") + if isVoid { + resultConverted = "_ = \(tryKeyword) await _awaitCompletableFuture(future$)" + } else { + resultPrinter.print("let result$ = \(tryKeyword) await _awaitCompletableFuture(future$)") + if fn.resultType.isDirectlyTranslatedToWrapJava { + // The task may have resumed on another thread: use its environment. + resultConverted = + "\(fn.resultType).fromJavaObject(result$?.javaThis, in: try! JavaVirtualMachine.shared().environment())" + } else { + resultConverted = syntheticFunction.resultConversion.render(&resultPrinter, "result$") + } + } + } else { + let upcallExpr = "\(callInterfaceVar).apply(\(upcallArguments.joined(separator: .comma)))" + resultConverted = syntheticFunction.resultConversion.render(&resultPrinter, upcallExpr) + } let resultPrefix = resultPrinter.finalize() // Note: async is part of the closure TYPE, not the closure literal syntax. diff --git a/Sources/JExtractSwiftLib/KnownFunctionalInterfaces.swift b/Sources/JExtractSwiftLib/KnownFunctionalInterfaces.swift index 9b1f75ab2..86d21d759 100644 --- a/Sources/JExtractSwiftLib/KnownFunctionalInterfaces.swift +++ b/Sources/JExtractSwiftLib/KnownFunctionalInterfaces.swift @@ -226,7 +226,8 @@ struct KnownJavaFunctionalInterface: Sendable { } static func find(_ functionType: SwiftFunctionType) -> KnownJavaFunctionalInterface? { - if functionType.isEscaping { + // An async closure's interface returns a `CompletableFuture`. + if functionType.isEscaping || functionType.isAsync { return nil } @@ -354,7 +355,7 @@ struct KnownJavaFunctionalInterface: Sendable { } static func find(_ functionType: JNISwift2JavaGenerator.TranslatedFunctionType) -> KnownJavaFunctionalInterface? { - if functionType.isEscaping { + if functionType.isEscaping || functionType.swiftType.isAsync { return nil } return find(parameters: functionType.parameters, result: functionType.result) diff --git a/Tests/JExtractSwiftTests/JNI/JNIAsyncClosureTests.swift b/Tests/JExtractSwiftTests/JNI/JNIAsyncClosureTests.swift new file mode 100644 index 000000000..b0530874b --- /dev/null +++ b/Tests/JExtractSwiftTests/JNI/JNIAsyncClosureTests.swift @@ -0,0 +1,117 @@ +//===----------------------------------------------------------------------===// +// +// 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 JExtractSwiftLib +import SwiftJavaConfigurationShared +import Testing + +/// Async closures implemented in Java return a `CompletableFuture`, which Swift awaits. +@Suite +struct JNIAsyncClosureTests { + let source = + """ + public struct Item { + public var id: String + } + public func perform(_ work: @escaping @Sendable (Item) async -> Void) async {} + public func load(_ fetch: @escaping @Sendable (Int64) async throws -> Item) async throws {} + public func count(_ f: (Int64) async -> Int64) async {} + """ + + @Test + func javaInterfaceReturnsFuture() throws { + try assertOutput( + input: source, + .jni, + .java, + detectChunkByInitialLines: 2, + expectedChunks: [ + """ + @FunctionalInterface + public interface work { + java.util.concurrent.CompletableFuture apply(Item _0); + } + """, + """ + @FunctionalInterface + public interface fetch { + java.util.concurrent.CompletableFuture apply(long _0); + } + """, + // Not `LongUnaryOperator`: a non-escaping async closure gets its own interface too. + """ + @FunctionalInterface + public interface f { + java.util.concurrent.CompletableFuture apply(long _0); + } + """, + ] + ) + } + + @Test + func swiftAwaitsTheFuture() throws { + try assertOutput( + input: source, + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + @JavaInterface("com.example.swift.SwiftModule$perform$work") + public struct JavaSwiftModule_perform_work { + @JavaMethod + public func apply(_ _0: JavaItem?) -> JavaCompletableFuture! + } + """, + """ + let future$ = try! javaInterface_work$call.dynamicJavaMethodCall(methodName: "apply", arguments: _0Class.wrapMemoryAddressUnsafe(Int64(Int(bitPattern: _0Pointer))), resultType: JavaCompletableFuture?.self) + guard let future$ else { fatalError("Upcall to apply unexpectedly returned a null future") } + _ = try! await _awaitCompletableFuture(future$) + """, + // A primitive result, read on the thread the task resumed on. + "return Int64.fromJavaObject(result$?.javaThis, in: try! JavaVirtualMachine.shared().environment())", + ] + ) + } + + @Test + func throwingClosurePropagatesTheJavaFailure() throws { + try assertOutput( + input: source, + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + "let future$ = try javaInterface_fetch$call.dynamicJavaMethodCall(methodName: \"apply\", arguments: _0, resultType: JavaCompletableFuture?.self)", + "let result$ = try await _awaitCompletableFuture(future$)", + "let result$MemoryAddress$ = unwrapped$.as(JavaJNISwiftInstance.self)!.memoryAddress()", + ] + ) + } + + @Test + func legacyFutureMode_isSkipped() throws { + var config = Configuration() + config.asyncFuncMode = .legacyFuture + try assertOutput( + input: "public func perform(_ work: @escaping @Sendable () async -> Void) async {}", + config: config, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [], + notExpectedChunks: ["perform("] + ) + } +} From 7961928b019dda208faccf50a1a4c7f0cef231f5 Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Tue, 3 Nov 2026 21:00:23 +0100 Subject: [PATCH 2/2] Samples: async closures from Java and Kotlin coroutines Co-Authored-By: Claude Opus 5.5 --- .../KotlinSampleLibrary/Closures.swift | 5 ++ .../kotlin/com/example/kotlin/ClosuresTest.kt | 22 ++++++ .../MySwiftLibrary/AsyncClosures.swift | 29 ++++++++ .../com/example/swift/AsyncClosuresTest.java | 70 +++++++++++++++++++ 4 files changed, 126 insertions(+) create mode 100644 Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/AsyncClosures.swift create mode 100644 Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/AsyncClosuresTest.java diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Closures.swift b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Closures.swift index 6a3af1c91..ed729b39f 100644 --- a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Closures.swift +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Closures.swift @@ -51,6 +51,11 @@ public final class Notifier { f(Item(id: id, count: 1)) } + /// An async closure: Swift awaits the future the Kotlin lambda returns. + public func perform(id: String, _ work: @escaping @Sendable (Item) async -> Void) async { + await work(Item(id: id, count: 1)) + } + /// A @Sendable closure called on another thread: the upcall uses that thread's JNIEnv. public func deliverInBackground(id: String, _ cb: @escaping @Sendable (Item) -> Void) { let group = DispatchGroup() diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ClosuresTest.kt b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ClosuresTest.kt index 19d4da448..6f64a40b9 100644 --- a/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ClosuresTest.kt +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ClosuresTest.kt @@ -14,6 +14,13 @@ package com.example.kotlin +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.future.await +import kotlinx.coroutines.future.future +import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.condition.EnabledOnOs import org.junit.jupiter.api.condition.OS import kotlin.test.Test @@ -24,6 +31,7 @@ import kotlin.test.assertTrue // Kotlin lambdas passed as Swift escaping closures. Functional-interface names follow // `..`; Kotlin converts lambdas to them. +@OptIn(DelicateCoroutinesApi::class) class ClosuresTest { @Test fun structParameter() { @@ -69,6 +77,20 @@ class ClosuresTest { assertEquals(listOf("#1", "#2"), listOf("1", "2").map { format.apply(Item.init(it, 1)) }) } + @Test + fun asyncClosureIsAwaited() = runBlocking { + val seen = java.util.concurrent.CopyOnWriteArrayList() + Notifier.init().perform("z") { item -> + // A coroutine, as a CompletableFuture + GlobalScope.future(Dispatchers.Default) { + delay(50) + seen += item.id + null + } + }.await() + assertEquals(listOf("z"), seen.toList()) + } + @Test fun sendableClosureCalledFromAnotherThread() { val testThread = Thread.currentThread() diff --git a/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/AsyncClosures.swift b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/AsyncClosures.swift new file mode 100644 index 000000000..58616eecf --- /dev/null +++ b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/AsyncClosures.swift @@ -0,0 +1,29 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 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 +// +//===----------------------------------------------------------------------===// + +/// Async closures implemented in Java: Swift awaits the `CompletableFuture` they return. +public enum AsyncClosures { + public static func perform(_ value: Int64, _ work: @escaping @Sendable (Int64) async -> Void) async { + await work(value) + } + + /// A non-escaping async closure. + public static func addOne(_ value: Int64, _ f: (Int64) async -> Int64) async -> Int64 { + await f(value) + 1 + } + + public static func capacity(_ fetch: @escaping @Sendable (Int64) async throws -> MySwiftStruct) async throws -> Int64 { + try await fetch(7).getCapacity() + } +} diff --git a/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/AsyncClosuresTest.java b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/AsyncClosuresTest.java new file mode 100644 index 000000000..4f0a67dc3 --- /dev/null +++ b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/AsyncClosuresTest.java @@ -0,0 +1,70 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 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 +// +//===----------------------------------------------------------------------===// + +package com.example.swift; + +import org.junit.jupiter.api.Test; +import org.swift.swiftkit.core.SwiftArena; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutionException; + +import static org.junit.jupiter.api.Assertions.*; + +public class AsyncClosuresTest { + @Test + void voidClosureIsAwaited() throws Exception { + List seen = new CopyOnWriteArrayList<>(); + AsyncClosures.perform(5, value -> CompletableFuture.runAsync(() -> { + sleep(50); + seen.add(value); + }).thenApply(ignored -> null)).get(); + // Swift returned only after the Java future completed. + assertEquals(List.of(5L), seen); + } + + @Test + void nonEscapingPrimitiveClosure() throws Exception { + long result = AsyncClosures.addOne(20, value -> CompletableFuture.supplyAsync(() -> value * 2)).get(); + assertEquals(41, result); + } + + @Test + void structResult() throws Exception { + try (var arena = SwiftArena.ofConfined()) { + long capacity = AsyncClosures.capacity(value -> + CompletableFuture.supplyAsync(() -> MySwiftStruct.init(value * 3, 0, arena)) + ).get(); + assertEquals(21, capacity); + } + } + + @Test + void failedFutureIsThrownInSwift() { + ExecutionException error = assertThrows(ExecutionException.class, () -> + AsyncClosures.capacity(value -> CompletableFuture.failedFuture(new IllegalStateException("offline"))).get() + ); + assertTrue(error.getCause().getMessage().contains("offline"), error.getCause().toString()); + } + + private static void sleep(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } +}