Skip to content

Commit 7f81ac0

Browse files
committed
Initial commit to support primitive escaping closures
1 parent 4fb6ca2 commit 7f81ac0

File tree

7 files changed

+502
-19
lines changed

7 files changed

+502
-19
lines changed
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2025 Apple Inc. and the Swift.org project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of Swift.org project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
public class CallbackManager {
16+
private var callback: (() -> Void)?
17+
private var intCallback: ((Int64) -> Int64)?
18+
19+
public init() {}
20+
21+
public func setCallback(callback: @escaping () -> Void) {
22+
self.callback = callback
23+
}
24+
25+
public func triggerCallback() {
26+
callback?()
27+
}
28+
29+
public func clearCallback() {
30+
callback = nil
31+
}
32+
33+
public func setIntCallback(callback: @escaping (Int64) -> Int64) {
34+
self.intCallback = callback
35+
}
36+
37+
public func triggerIntCallback(value: Int64) -> Int64? {
38+
return intCallback?(value)
39+
}
40+
}
41+
42+
// public func delayedExecution(closure: @escaping (Int64) -> Int64, input: Int64) -> Int64 {
43+
// // In a real implementation, this might be async
44+
// // For testing purposes, we just call it synchronously
45+
// return closure(input)
46+
// }
47+
48+
public class ClosureStore {
49+
private var closures: [() -> Void] = []
50+
51+
public init() {}
52+
53+
public func addClosure(closure: @escaping () -> Void) {
54+
closures.append(closure)
55+
}
56+
57+
public func executeAll() {
58+
for closure in closures {
59+
closure()
60+
}
61+
}
62+
63+
public func clear() {
64+
closures.removeAll()
65+
}
66+
67+
public func count() -> Int64 {
68+
return Int64(closures.count)
69+
}
70+
}
71+
72+
public func multipleEscapingClosures(
73+
onSuccess: @escaping (Int64) -> Void,
74+
onFailure: @escaping (Int64) -> Void,
75+
condition: Bool
76+
) {
77+
if condition {
78+
onSuccess(42)
79+
} else {
80+
onFailure(-1)
81+
}
82+
}
83+
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2024 Apple Inc. and the Swift.org project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of Swift.org project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
package com.example.swift;
16+
17+
import org.junit.jupiter.api.Test;
18+
import org.swift.swiftkit.core.SwiftArena;
19+
import java.util.OptionalLong;
20+
import java.util.concurrent.atomic.AtomicBoolean;
21+
import java.util.concurrent.atomic.AtomicLong;
22+
23+
import static org.junit.jupiter.api.Assertions.*;
24+
25+
public class EscapingClosuresTest {
26+
27+
@Test
28+
void testCallbackManager_singleCallback() {
29+
try (var arena = SwiftArena.ofConfined()) {
30+
CallbackManager manager = CallbackManager.init(arena);
31+
32+
AtomicBoolean wasCalled = new AtomicBoolean(false);
33+
34+
// Create an escaping closure (no try-with-resources needed - cleanup is automatic via Swift ARC)
35+
CallbackManager.setCallback.callback callback = () -> {
36+
wasCalled.set(true);
37+
};
38+
39+
// Set the callback
40+
manager.setCallback(callback);
41+
42+
// Trigger it
43+
manager.triggerCallback();
44+
assertTrue(wasCalled.get(), "Callback should have been called");
45+
46+
// Trigger again to ensure it's still stored
47+
wasCalled.set(false);
48+
manager.triggerCallback();
49+
assertTrue(wasCalled.get(), "Callback should be called multiple times");
50+
51+
// Clear the callback - this releases the closure on Swift side, triggering GlobalRef cleanup
52+
manager.clearCallback();
53+
}
54+
}
55+
56+
@Test
57+
void testCallbackManager_intCallback() {
58+
try (var arena = SwiftArena.ofConfined()) {
59+
CallbackManager manager = CallbackManager.init(arena);
60+
61+
CallbackManager.setIntCallback.callback callback = (value) -> {
62+
return value * 2;
63+
};
64+
65+
manager.setIntCallback(callback);
66+
67+
// Trigger the callback - returns OptionalLong since Swift returns Int64?
68+
OptionalLong result = manager.triggerIntCallback(21);
69+
assertTrue(result.isPresent(), "Result should be present");
70+
assertEquals(42, result.getAsLong(), "Callback should double the input");
71+
}
72+
}
73+
74+
@Test
75+
void testClosureStore() {
76+
try (var arena = SwiftArena.ofConfined()) {
77+
ClosureStore store = ClosureStore.init(arena);
78+
79+
AtomicLong counter = new AtomicLong(0);
80+
81+
// Add multiple closures
82+
ClosureStore.addClosure.closure closure1 = () -> {
83+
counter.incrementAndGet();
84+
};
85+
ClosureStore.addClosure.closure closure2 = () -> {
86+
counter.addAndGet(10);
87+
};
88+
ClosureStore.addClosure.closure closure3 = () -> {
89+
counter.addAndGet(100);
90+
};
91+
92+
store.addClosure(closure1);
93+
store.addClosure(closure2);
94+
store.addClosure(closure3);
95+
96+
assertEquals(3, store.count(), "Should have 3 closures stored");
97+
98+
// Execute all closures
99+
store.executeAll();
100+
assertEquals(111, counter.get(), "All closures should be executed");
101+
102+
// Execute again
103+
counter.set(0);
104+
store.executeAll();
105+
assertEquals(111, counter.get(), "Closures should be reusable");
106+
107+
// Clear - this releases closures on Swift side, triggering GlobalRef cleanup
108+
store.clear();
109+
assertEquals(0, store.count(), "Store should be empty after clear");
110+
}
111+
}
112+
113+
@Test
114+
void testMultipleEscapingClosures() {
115+
AtomicLong successValue = new AtomicLong(0);
116+
AtomicLong failureValue = new AtomicLong(0);
117+
118+
MySwiftLibrary.multipleEscapingClosures.onSuccess onSuccess = (value) -> {
119+
successValue.set(value);
120+
};
121+
MySwiftLibrary.multipleEscapingClosures.onFailure onFailure = (value) -> {
122+
failureValue.set(value);
123+
};
124+
125+
// Test success case
126+
MySwiftLibrary.multipleEscapingClosures(onSuccess, onFailure, true);
127+
assertEquals(42, successValue.get(), "Success callback should be called");
128+
assertEquals(0, failureValue.get(), "Failure callback should not be called");
129+
130+
// Reset and test failure case
131+
successValue.set(0);
132+
failureValue.set(0);
133+
MySwiftLibrary.multipleEscapingClosures(onSuccess, onFailure, false);
134+
assertEquals(0, successValue.get(), "Success callback should not be called");
135+
assertEquals(-1, failureValue.get(), "Failure callback should be called");
136+
}
137+
138+
@Test
139+
void testMultipleManagersWithDifferentClosures() {
140+
try (var arena = SwiftArena.ofConfined()) {
141+
CallbackManager manager1 = CallbackManager.init(arena);
142+
CallbackManager manager2 = CallbackManager.init(arena);
143+
144+
AtomicBoolean called1 = new AtomicBoolean(false);
145+
AtomicBoolean called2 = new AtomicBoolean(false);
146+
147+
CallbackManager.setCallback.callback callback1 = () -> {
148+
called1.set(true);
149+
};
150+
CallbackManager.setCallback.callback callback2 = () -> {
151+
called2.set(true);
152+
};
153+
154+
manager1.setCallback(callback1);
155+
manager2.setCallback(callback2);
156+
157+
// Trigger first manager
158+
manager1.triggerCallback();
159+
assertTrue(called1.get(), "First callback should be called");
160+
assertFalse(called2.get(), "Second callback should not be called");
161+
162+
// Trigger second manager
163+
manager2.triggerCallback();
164+
assertTrue(called2.get(), "Second callback should be called");
165+
}
166+
}
167+
}

Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift

Lines changed: 88 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,8 @@ extension JNISwift2JavaGenerator {
151151
)
152152

153153
case .function(let fn):
154+
155+
// @Sendable is not supported yet as "environment" is later captured inside the closure.
154156
var parameters = [NativeParameter]()
155157
for (i, parameter) in fn.parameters.enumerated() {
156158
let parameterName = parameter.parameterName ?? "_\(i)"
@@ -163,15 +165,28 @@ extension JNISwift2JavaGenerator {
163165

164166
let result = try translateClosureResult(fn.resultType)
165167

166-
return NativeParameter(
167-
parameters: [
168-
JavaParameter(name: parameterName, type: .class(package: javaPackage, name: "\(parentName).\(methodName).\(parameterName)"))
169-
],
170-
conversion: .closureLowering(
171-
parameters: parameters,
172-
result: result
168+
if fn.isEscaping {
169+
return NativeParameter(
170+
parameters: [
171+
JavaParameter(name: parameterName, type: .class(package: javaPackage, name: "\(parentName).\(methodName).\(parameterName)"))
172+
],
173+
conversion: .escapingClosureLowering(
174+
parameters: parameters,
175+
result: result,
176+
closureName: parameterName
177+
)
173178
)
174-
)
179+
} else {
180+
return NativeParameter(
181+
parameters: [
182+
JavaParameter(name: parameterName, type: .class(package: javaPackage, name: "\(parentName).\(methodName).\(parameterName)"))
183+
],
184+
conversion: .closureLowering(
185+
parameters: parameters,
186+
result: result
187+
)
188+
)
189+
}
175190

176191
case .optional(let wrapped):
177192
return try translateOptionalParameter(
@@ -407,6 +422,15 @@ extension JNISwift2JavaGenerator {
407422
switch type {
408423
case .nominal(let nominal):
409424
if let knownType = nominal.nominalTypeDecl.knownTypeKind {
425+
426+
if knownType == .void {
427+
return NativeResult(
428+
javaType: .void,
429+
conversion: .placeholder,
430+
outParameters: []
431+
)
432+
}
433+
410434
guard let javaType = JNIJavaTypeTranslator.translate(knownType: knownType, config: self.config),
411435
javaType.implementsJavaValue else {
412436
throw JavaTranslationError.unsupportedSwiftType(type)
@@ -692,6 +716,8 @@ extension JNISwift2JavaGenerator {
692716
indirect case pointee(NativeSwiftConversionStep)
693717

694718
indirect case closureLowering(parameters: [NativeParameter], result: NativeResult)
719+
720+
indirect case escapingClosureLowering(parameters: [NativeParameter], result: NativeResult, closureName: String)
695721

696722
indirect case initializeSwiftJavaWrapper(NativeSwiftConversionStep, wrapperName: String)
697723

@@ -917,6 +943,60 @@ extension JNISwift2JavaGenerator {
917943
printer.print("}")
918944

919945
return printer.finalize()
946+
947+
case .escapingClosureLowering(let parameters, let nativeResult, let closureName):
948+
var printer = CodePrinter()
949+
950+
let methodSignature = MethodSignature(
951+
resultType: nativeResult.javaType,
952+
parameterTypes: parameters.flatMap {
953+
$0.parameters.map { parameter in
954+
guard case .concrete(let type) = parameter.type else {
955+
fatalError("Closures do not support Java generics")
956+
}
957+
return type
958+
}
959+
}
960+
)
961+
962+
let arguments = parameters.map {
963+
$0.conversion.render(&printer, $0.parameters.first!.name)
964+
}
965+
966+
let closureParameters = parameters.flatMap { $0.parameters.map(\.name) }.joined(separator: ", ")
967+
968+
let upcall = "env$.interface.\(nativeResult.javaType.jniCallMethodAName)(env$, closureContext_\(closureName)$.object!, methodID$, arguments$)"
969+
let result = nativeResult.conversion.render(&printer, upcall)
970+
let returnResult = if nativeResult.javaType.isVoid { result } else { "return \(result)" }
971+
972+
printer.print(
973+
"""
974+
{
975+
guard let \(placeholder) else {
976+
fatalError(\"\(placeholder) is null")
977+
}
978+
979+
let closureContext_\(closureName)$ = JavaObjectHolder(object: \(placeholder), environment: environment)
980+
return { \(parameters.isEmpty ? "" : "\(closureParameters) in")
981+
guard let env$ = try? JavaVirtualMachine.shared().environment() else {
982+
fatalError(\"Failed to get JNI environment for escaping closure call\")
983+
}
984+
985+
// Call the Java closure
986+
let class$ = env$.interface.GetObjectClass(env$, closureContext_\(closureName)$.object!)
987+
guard let methodID$ = env$.interface.GetMethodID(env$, class$, \"apply\", \"\(methodSignature.mangledName)\") else {
988+
fatalError(\"Failed to find apply method on closure\")
989+
}
990+
991+
let arguments$: [jvalue] = [\(arguments.joined(separator: ", "))]
992+
993+
\(returnResult)
994+
}
995+
}()
996+
"""
997+
)
998+
999+
return printer.finalize()
9201000

9211001
case .initializeSwiftJavaWrapper(let inner, let wrapperName):
9221002
let inner = inner.render(&printer, placeholder)

0 commit comments

Comments
 (0)