Skip to content

JNI: async closures implemented in Java return a CompletableFuture - #22

Open
piercifani wants to merge 3 commits into
jni/17-closure-resultsfrom
jni/18-async-closures
Open

piercifani wants to merge 3 commits into
jni/17-closure-resultsfrom
jni/18-async-closures

Conversation

@piercifani

@piercifani piercifani commented Nov 3, 2026 •

Copy link
Copy Markdown

Stack 18/21 · base: jni/17-closure-results · previous: #21 · next: #23 (jni/19-enum-swift-case-names)

Problem

An async closure parameter got a synchronous Java interface, void apply(Item). Swift called it and went on without waiting for the Java side, so Java couldn't do asynchronous work for an async closure. Kotlin couldn't either: a coroutine, or a CompletableFuture, returned from the lambda was ignored.

Changes

An async closure's functional interface returns CompletableFuture<T>, with T boxed and Void for no result:

public interface work {
  CompletableFuture<Void> apply(Item _0);
}

The Swift closure calls it and awaits the future with _awaitCompletableFuture, from #7. That resumes the task when the future completes instead of blocking a thread. It's the same mechanism as an async requirement of a Java-implemented protocol:

  • The call: apply is called dynamically, because wrap-java doesn't bind a generic CompletableFuture<T> result.
  • The result: its value is converted on the thread the task resumed on.
  • Failures: a failed future throws the Java exception in a throwing closure. In a non-throwing closure it traps, as a non-throwing protocol requirement does.
  • Result types: Void, types translated directly (primitives, String) and jextracted types.
  • What's excluded:
    • Async closures never use the non-escaping lowering or a JDK functional interface. (Int64) async -> Int64 isn't a LongUnaryOperator.
    • They need the default completableFuture async mode and are skipped in legacyFuture mode, since a Future can't tell Swift it completed.

Relation to swiftlang#913

Upstream swiftlang#913 gives async closures the same Java API. This PR implements it separately, on top of this stack:

When swiftlang#913 lands upstream, the two need reconciling.

Kotlin in this PR

ClosuresTest.kt with Closures.swift:

// public func perform(id: String, _ work: @escaping @Sendable (Item) async -> Void) async
Notifier.init().perform("z") { item ->
    GlobalScope.future<Void?>(Dispatchers.Default) {   // a coroutine, as a CompletableFuture
        delay(50)
        seen += item.id
        null
    }
}.await()
assertEquals(listOf("z"), seen.toList())   // Swift waited for the coroutine

Why Skip needs it

Skip bridges async Swift closures to Kotlin suspend functions, so a Kotlin implementation can suspend and Swift waits for it.

Testing

  • Kotlin sample: ./gradlew :Samples:SwiftJavaExtractJNIKotlinSampleApp:test passes (46 tests).
  • New JNIAsyncClosureTests (4 tests):
    • the Java interfaces return futures (also for a non-escaping primitive closure);
    • the Swift closure awaits the future;
    • a throwing closure propagates the Java failure;
    • legacyFuture mode skips them.
  • JNI sample: AsyncClosuresTest checks four cases:
    • Swift waits for a delayed Void future;
    • a non-escaping primitive closure;
    • a struct result;
    • a failed future surfacing as the Swift call's exception.
  • Full swift test --disable-experimental-prebuilts --disable-sandbox (686 tests), ./gradlew :Samples:SwiftJavaExtractJNISampleApp:test (302 tests) and ./gradlew :SwiftKitCore:test pass.
  • swift format format leaves changed files unchanged; swift format lint --strict; git diff --check.

Not in this PR

🤖 Generated with Claude Code

piercifani and others added 2 commits November 3, 2026 21:00
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<T>` (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<T>` 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#913, which awaits with a
blocking `get()`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant