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 @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,6 +31,7 @@ import kotlin.test.assertTrue

// Kotlin lambdas passed as Swift escaping closures. Functional-interface names follow
// `<Type>.<method>.<parameter>`; Kotlin converts lambdas to them.
@OptIn(DelicateCoroutinesApi::class)
class ClosuresTest {
@Test
fun structParameter() {
Expand Down Expand Up @@ -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<String>()
Notifier.init().perform("z") { item ->
// A coroutine, as a CompletableFuture
GlobalScope.future<Void?>(Dispatchers.Default) {
delay(50)
seen += item.id
null
}
}.await()
assertEquals(listOf("z"), seen.toList())
}

@Test
fun sendableClosureCalledFromAnotherThread() {
val testThread = Thread.currentThread()
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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<Long> 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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)",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<T>` 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.
Expand Down
5 changes: 3 additions & 2 deletions Sources/JExtractSwiftLib/KnownFunctionalInterfaces.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading