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
@@ -0,0 +1,69 @@
//===----------------------------------------------------------------------===//
//
// 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 Dispatch

/// Escaping closures whose parameters are jextracted types, optionals of them, and closure types
/// carrying @Sendable or @MainActor.
public final class Notifier {
private var handler: ((Item) -> Void)?

public init() {}

public func setHandler(_ handler: @escaping (Item) -> Void) {
self.handler = handler
}

public func fire(id: String) {
handler?(Item(id: id, count: 1))
}

/// Mixed arity with an optional struct parameter.
public func check(_ cb: @escaping (String, Int64, Item?) -> Bool) -> Bool {
cb("a", 1, nil) && cb("b", 2, Item(id: "b", count: 2))
}

/// 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()
group.enter()
DispatchQueue.global().async {
cb(Item(id: id, count: 1))
group.leave()
}
group.wait()
}
}

/// The shape of a view model's callbacks: @MainActor @Sendable closures taking structs.
public struct Callbacks: Sendable {
let onOpen: @MainActor @Sendable (Item) -> Void
let onClose: @MainActor @Sendable () -> Void

public init(
onOpen: @escaping @MainActor @Sendable (Item) -> Void,
onClose: @escaping @MainActor @Sendable () -> Void
) {
self.onOpen = onOpen
self.onClose = onClose
}
}

@MainActor
public enum Screen {
public static func open(_ callbacks: Callbacks, id: String) {
callbacks.onOpen(Item(id: id, count: 1))
callbacks.onClose()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//===----------------------------------------------------------------------===//
//
// 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
//
//===----------------------------------------------------------------------===//

package com.example.kotlin

import org.junit.jupiter.api.condition.EnabledOnOs
import org.junit.jupiter.api.condition.OS
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue

// Kotlin lambdas passed as Swift escaping closures. Functional-interface names follow
// `<Type>.<method>.<parameter>`; Kotlin converts lambdas to them.
class ClosuresTest {
@Test
fun structParameter() {
val notifier = Notifier.init()
val seen = mutableListOf<String>()
notifier.setHandler { item -> seen += item.id }
notifier.fire("a")
notifier.fire("b")
assertEquals(listOf("a", "b"), seen)
}

@Test
fun mixedArityWithOptionalStruct() {
val calls = mutableListOf<String>()
val ok = Notifier.init().check { s, i, item -> calls += "$s$i${item.map { it.id }.orElse("-")}"; true }
assertTrue(ok)
assertEquals(listOf("a1-", "b2b"), calls)
}

@Test
fun sendableClosureCalledFromAnotherThread() {
val testThread = Thread.currentThread()
var calledOn: Thread? = null
var seenId = ""
Notifier.init().deliverInBackground("bg") { item ->
calledOn = Thread.currentThread()
seenId = item.id
}
assertEquals("bg", seenId)
assertNotEquals(testThread, calledOn)
}

@Test
@EnabledOnOs(OS.MAC) // Screen is main-actor isolated, see MainActorTest
fun mainActorCallbacks() {
val events = mutableListOf<String>()
val callbacks = Callbacks.init({ item -> events += "open ${item.id}" }, { events += "close" })
Screen.open(callbacks, "7")
assertEquals(listOf("open 7", "close"), events)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,17 @@ public func multipleEscapingClosures(
onFailure(-1)
}
}

public func deliverStruct(
len: Int64,
callback: @escaping @Sendable (MySwiftStruct) -> Void
) {
callback(MySwiftStruct(cap: len, len: len))
}

public func deliverOptionalStruct(
len: Int64?,
callback: @escaping (MySwiftStruct?) -> Int64
) -> Int64 {
callback(len.map { MySwiftStruct(cap: $0, len: $0) })
}
Original file line number Diff line number Diff line change
Expand Up @@ -166,4 +166,17 @@ void testMultipleManagersWithDifferentClosures() {
assertTrue(called2.get(), "Second callback should be called");
}
}

@Test
void testEscapingClosureWithStructParameter() {
AtomicLong received = new AtomicLong(-1);
MySwiftLibrary.deliverStruct(7, value -> received.set(value.getLen()));
assertEquals(7, received.get());
}

@Test
void testEscapingClosureWithOptionalStructParameter() {
assertEquals(5, MySwiftLibrary.deliverOptionalStruct(java.util.OptionalLong.of(5), value -> value.map(MySwiftStruct::getLen).orElse(-1L)));
assertEquals(-1, MySwiftLibrary.deliverOptionalStruct(java.util.OptionalLong.empty(), value -> value.map(MySwiftStruct::getLen).orElse(-1L)));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,87 @@ extension JNISwift2JavaGenerator {
guard let closureTy = ty.syntheticClosureType else {
continue
}
// With Java callbacks enabled, wrap-java generates these classes for us.
if !config.effectiveEnableJavaCallbacks {
let fn = closureTy.functionType
for type in fn.parameters.map(\.type) + [fn.resultType] {
if let nominal = jextractedNominal(type) ?? optionalWrapped(type).flatMap(jextractedNominal) {
printMinimalJavaClassWrapper(&printer, nominal)
}
}
}
printEscapingClosureWrapJavaInterface(&printer, closureTy)
}
}

/// A nominal type this module jextracts, which crosses into Java as its generated Java class.
private func jextractedNominal(_ type: SwiftType) -> SwiftNominalTypeDeclaration? {
guard case .nominal(let nominal) = type, nominal.asKnownType == nil,
nominal.nominalTypeDecl.moduleName == swiftModuleName
else {
return nil
}
return nominal.nominalTypeDecl
}

private func optionalWrapped(_ type: SwiftType) -> SwiftType? {
guard case .nominal(let nominal) = type, case .optional(let wrapped) = nominal.asKnownType else {
return nil
}
return wrapped
}

/// The Swift spelling of a closure parameter or result in the `@JavaInterface` wrapper.
/// Jextracted types are passed as their Java class, through its JavaKit wrapper.
private func wrapJavaTypeName(_ type: SwiftType, isResult: Bool) -> String {
if let nominal = jextractedNominal(type) {
return nominal.generatedJavaClassMacroName + (isResult ? "!" : "?")
}
if !isResult, let nominal = optionalWrapped(type).flatMap(jextractedNominal) {
return "JavaOptional<\(nominal.generatedJavaClassMacroName)>?"
}
return type.description
}

/// Prints just enough of a JavaKit wrapper (`@JavaClass` + `wrapMemoryAddressUnsafe`) for the
/// generated Java class of a jextracted type, so closures can hand Swift values to Java without
/// running wrap-java. Parents of nested types are printed first; each type is printed once.
private func printMinimalJavaClassWrapper(_ printer: inout SwiftPrinter, _ decl: SwiftNominalTypeDeclaration) {
guard !printedMinimalJavaClassWrappers.contains(decl.qualifiedName) else {
return
}
printedMinimalJavaClassWrappers.insert(decl.qualifiedName)

var names: [String] = []
var current: SwiftNominalTypeDeclaration? = decl
while let c = current {
names.insert(c.name, at: 0)
current = c.parent
}
let binaryName = "\(javaPackage).\(names.joined(separator: "$"))"
let className = "Java\(decl.name)"

var classDecl =
"""
@JavaClass("\(binaryName)")
open class \(className): JavaObject {}
"""
if let parent = decl.parent {
printMinimalJavaClassWrapper(&printer, parent)
classDecl = "extension \(parent.generatedJavaClassMacroName) {\n\(classDecl)\n}"
}
printer.print(
"""
\(classDecl)
extension JavaClass<\(decl.generatedJavaClassMacroName)> {
@JavaStaticMethod
public func wrapMemoryAddressUnsafe(_ selfPointer: Int64) -> \(decl.generatedJavaClassMacroName)!
}
"""
)
printer.println()
}

private func printEscapingClosureWrapJavaInterface(
_ printer: inout SwiftPrinter,
_ closureTy: SyntheticEscapingClosureFunctionType,
Expand All @@ -60,15 +137,14 @@ extension JNISwift2JavaGenerator {
private func renderEscapingClosureApplySignature(_ type: SwiftFunctionType) -> String {
let params = type.parameters.enumerated().map { idx, param -> String in
let name = param.parameterName ?? "_\(idx)"
let typeName = param.type.description
return "_ \(name): \(typeName)"
return "_ \(name): \(wrapJavaTypeName(param.type, isResult: false))"
}
let paramList = "(\(params.joined(separator: .comma)))"

if type.resultType.isVoid {
return paramList
} else {
return "\(paramList) -> \(type.resultType.description)"
return "\(paramList) -> \(wrapJavaTypeName(type.resultType, isResult: true))"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,6 @@ extension JNISwift2JavaGenerator {
methodName: String,
parentName: SwiftQualifiedTypeName
) throws -> NativeParameter {
// @Sendable is not supported yet as "environment" is later captured inside the closure.
if functionType.isEscaping {
// For escaping closures we e need to create a Swift wrapper around
// the passed down Java functional interface because we must keep it
Expand Down Expand Up @@ -1733,20 +1732,26 @@ extension JNISwift2JavaGenerator {

let javaInterfaceVar = "javaInterface_\(placeholder)$"

// Build upcall arguments using UpcallConversionStep conversions
// A @Sendable closure may run on any thread, but the wrapper holds the JNIEnv of the
// thread that created it. Re-wrap the Java object with the current thread's JNIEnv per call.
let callInterfaceVar = fn.isSendable ? "javaInterface_\(placeholder)$call" : javaInterfaceVar

// Build upcall arguments using UpcallConversionStep conversions. Conversions may print
// statements (e.g. wrapping a Swift value in its Java class), which go in the closure body.
var argPrinter = SwiftPrinter()
var upcallArguments: [String] = []
for (idx, conversion) in syntheticFunction.parameterConversions.enumerated() {
var argPrinter = SwiftPrinter()
let paramName = parameterNames[idx]
let converted = conversion.render(&argPrinter, paramName)
upcallArguments.append(converted)
}
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 = "\(javaInterfaceVar).apply(\(upcallArguments.joined(separator: .comma)))"
let upcallExpr = "\(callInterfaceVar).apply(\(upcallArguments.joined(separator: .comma)))"
let resultConverted = syntheticFunction.resultConversion.render(&resultPrinter, upcallExpr)
let resultPrefix = resultPrinter.finalize()

Expand All @@ -1757,6 +1762,11 @@ extension JNISwift2JavaGenerator {
? "{"
: "{ \(closureParameters) in"

let sendableRebind =
fn.isSendable
? "let \(callInterfaceVar) = \(syntheticFunction.javaInterfaceName)(javaThis: \(javaInterfaceVar).javaThis, environment: try! JavaVirtualMachine.shared().environment())\n"
: ""

// Construct the generated `@JavaInterface` wrap-java struct.
// It will cause a new global ref on the javaThis, so no need for explicit global refs.
// This object will be closed over by the closure we pass to Swift, and therefore keep alive the
Expand All @@ -1767,9 +1777,9 @@ extension JNISwift2JavaGenerator {
guard let \(placeholder) else {
fatalError(\"\(placeholder) is null\")
}
let \(javaInterfaceVar) = \(syntheticFunction.javaInterfaceName)(javaThis: \(placeholder), environment: environment)
\(fn.isSendable ? "nonisolated(unsafe) " : "")let \(javaInterfaceVar) = \(syntheticFunction.javaInterfaceName)(javaThis: \(placeholder), environment: environment)
return \(closureHeader)
\(resultPrefix)\(isVoid ? resultConverted : "return \(resultConverted)")
\(sendableRebind)\(argumentPrefix)\(resultPrefix)\(isVoid ? resultConverted : "return \(resultConverted)")
}
}()
"""
Expand Down
3 changes: 3 additions & 0 deletions Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ package class JNISwift2JavaGenerator: Swift2JavaGenerator {

var thunkNameRegistry = ThunkNameRegistry()

/// Jextracted types that already have a minimal JavaKit class wrapper printed for closures.
var printedMinimalJavaClassWrappers: Set<String> = []

/// Accumulates every ``@_cdecl`` symbol name emitted during thunk printing.
/// Written to a linker version script after generation when
/// ``Configuration/linkerExportListOutput`` is set.
Expand Down
8 changes: 7 additions & 1 deletion Sources/SwiftExtract/SwiftTypes/SwiftFunctionSignature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -392,8 +392,14 @@ extension SwiftFunctionSignature {
self.thrownTypedError = accessorEffects?.thrownTypedError

if isSet {
// A closure stored in a variable is always escaping, so its setter's `newValue` is too.
var newValueType = valueType
if case .function(var functionType) = valueType {
functionType.isEscaping = true
newValueType = .function(functionType)
}
self.parameters = [
SwiftParameter(convention: .byValue, parameterName: "newValue", type: valueType)
SwiftParameter(convention: .byValue, parameterName: "newValue", type: newValueType)
]
self.result = .void
} else {
Expand Down
9 changes: 8 additions & 1 deletion Sources/SwiftExtract/SwiftTypes/SwiftFunctionType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ public struct SwiftFunctionType: Equatable {
public var parameters: [SwiftParameter]
public var resultType: SwiftType
public var isEscaping: Bool = false
/// Whether the function type is marked `@Sendable`.
public var isSendable: Bool = false
/// Whether the function type is isolated to the main actor (`@MainActor`).
public var isMainActorIsolated: Bool = false

public var effectSpecifiers: [SwiftEffectSpecifier] = []

Expand Down Expand Up @@ -58,7 +62,10 @@ extension SwiftFunctionType: CustomStringConvertible {
case .c: "@convention(c) "
case .swift: ""
}
let escapingPrefix = isEscaping ? "@escaping " : ""
let escapingPrefix =
(isEscaping ? "@escaping " : "")
+ (isSendable ? "@Sendable " : "")
+ (isMainActorIsolated ? "@MainActor " : "")
let throwsString =
switch (isThrowing, thrownTypedError) {
case (true, .some(let errorType)): " throws(\(errorType.description))"
Expand Down
Loading
Loading