From 59144f7fd18374aacb6603d8f2dc2dabbc05817b Mon Sep 17 00:00:00 2001 From: amanmaurya92 Date: Mon, 5 Oct 2026 21:32:46 +0530 Subject: [PATCH] [JExtract] Mark @Sendable closures as @ThreadSafe (#832) --- .../MySwiftLibrary/EscapingClosures.swift | 10 +- .../example/swift/EscapingClosuresTest.java | 59 ++++- ...t2JavaGenerator+JavaBindingsPrinting.swift | 6 + ...avaGenerator+EscapingClosureWrapJava.swift | 3 +- ...t2JavaGenerator+JavaBindingsPrinting.swift | 3 + ...wift2JavaGenerator+NativeTranslation.swift | 21 +- .../KnownFunctionalInterfaces.swift | 6 +- .../SwiftTypes/SwiftFunctionType.swift | 8 +- .../SwiftTypes/SwiftParameter.swift | 6 + .../SwiftExtract/SwiftTypes/SwiftType.swift | 48 ++-- Tests/JExtractSwiftTests/SendableTests.swift | 205 ++++++++++++++++++ .../FunctionTypeEffectsTests.swift | 50 +++++ 12 files changed, 384 insertions(+), 41 deletions(-) diff --git a/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/EscapingClosures.swift b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/EscapingClosures.swift index ad69fb03c..6ed25162d 100644 --- a/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/EscapingClosures.swift +++ b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/EscapingClosures.swift @@ -16,6 +16,7 @@ public class CallbackManager { private var callback: (() -> Void)? private var intCallback: ((Int64) -> Int64)? + private var sendableCallback: (@Sendable (Int64) -> Int64)? public init() {} @@ -38,6 +39,14 @@ public class CallbackManager { public func triggerIntCallback(value: Int64) -> Int64? { intCallback?(value) } + + public func setSendableCallback(callback: @escaping @Sendable (Int64) -> Int64) { + self.sendableCallback = callback + } + + public func triggerSendableCallback(value: Int64) -> Int64? { + sendableCallback?(value) + } } // snippet.end @@ -64,7 +73,6 @@ public class ClosureStore { Int64(closures.count) } } - public func multipleEscapingClosures( onSuccess: @escaping (Int64) -> Void, onFailure: @escaping (Int64) -> Void, diff --git a/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/EscapingClosuresTest.java b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/EscapingClosuresTest.java index e602e0ac2..1f1402930 100644 --- a/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/EscapingClosuresTest.java +++ b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/EscapingClosuresTest.java @@ -72,7 +72,64 @@ void testCallbackManager_intCallback() { assertEquals(42, result.getAsLong(), "Callback should double the input"); } } - + + @Test + void testCallbackManager_sendableCallback() { + try (var arena = SwiftArena.ofConfined()) { + CallbackManager manager = CallbackManager.init(arena); + + // Verify that the generated interface is annotated with @ThreadSafe + assertTrue( + CallbackManager.setSendableCallback.callback.class.isAnnotationPresent( + org.swift.swiftkit.core.annotations.ThreadSafe.class + ), + "Sendable callback interface should be annotated with @ThreadSafe" + ); + + CallbackManager.setSendableCallback.callback callback = (value) -> { + return value * 3; + }; + + manager.setSendableCallback(callback); + + OptionalLong result = manager.triggerSendableCallback(14); + assertTrue(result.isPresent(), "Result should be present"); + assertEquals(42, result.getAsLong(), "Sendable callback should multiply input by 3"); + } + } + + @Test + void testCallbackManager_sendableCallbackConcurrently() throws Exception { + var arena = SwiftArena.ofAuto(); + CallbackManager manager = CallbackManager.init(arena); + + CallbackManager.setSendableCallback.callback callback = (value) -> { + return value * 10; + }; + + manager.setSendableCallback(callback); + + int threadCount = 4; + var executor = java.util.concurrent.Executors.newFixedThreadPool(threadCount); + var futures = new java.util.ArrayList>(); + + for (int i = 0; i < 20; i++) { + final long input = i; + futures.add(executor.submit(() -> { + OptionalLong res = manager.triggerSendableCallback(input); + assertTrue(res.isPresent()); + return res.getAsLong(); + })); + } + + for (int i = 0; i < 20; i++) { + assertEquals(i * 10L, futures.get(i).get()); + } + + executor.shutdown(); + assertTrue(executor.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS)); + } + @Test void testClosureStore() { try (var arena = SwiftArena.ofConfined()) { diff --git a/Sources/JExtractSwiftLib/FFM/FFMSwift2JavaGenerator+JavaBindingsPrinting.swift b/Sources/JExtractSwiftLib/FFM/FFMSwift2JavaGenerator+JavaBindingsPrinting.swift index 4dc611028..3c0e1460c 100644 --- a/Sources/JExtractSwiftLib/FFM/FFMSwift2JavaGenerator+JavaBindingsPrinting.swift +++ b/Sources/JExtractSwiftLib/FFM/FFMSwift2JavaGenerator+JavaBindingsPrinting.swift @@ -315,6 +315,9 @@ extension FFMSwift2JavaGenerator { if !isKnownFuncInterface { // If the user-facing functional interface is C ABI compatible, just extend // the lowered function pointer parameter interface. + if functionType.swiftType.isSendable { + printer.print("@ThreadSafe // Sendable") + } printer.print( """ @FunctionalInterface @@ -336,6 +339,9 @@ extension FFMSwift2JavaGenerator { "\($0.parameter.type) \($0.parameter.name)" } + if functionType.swiftType.isSendable { + printer.print("@ThreadSafe // Sendable") + } printer.print( """ @FunctionalInterface diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+EscapingClosureWrapJava.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+EscapingClosureWrapJava.swift index 6b6a4e729..30b47e489 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+EscapingClosureWrapJava.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+EscapingClosureWrapJava.swift @@ -40,10 +40,11 @@ extension JNISwift2JavaGenerator { _ printer: inout SwiftPrinter, _ closureTy: SyntheticEscapingClosureFunctionType, ) { + let sendableSuffix = closureTy.functionType.isSendable ? ": @unchecked Sendable" : "" printer.printBraceBlock( """ @JavaInterface("\(closureTy.javaBinaryName)") - public struct \(closureTy.javaInterfaceName) + public struct \(closureTy.javaInterfaceName)\(sendableSuffix) """ ) { p in let signature = self.renderEscapingClosureApplySignature(closureTy.functionType) diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift index 98d1004c3..50e418dd1 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift @@ -824,6 +824,9 @@ extension JNISwift2JavaGenerator { Corresponds to the Swift closure parameter of type {@code \(functionType.swiftType)}. """ ) + if functionType.swiftType.isSendable { + printer.print("@ThreadSafe // Sendable") + } printer.print( """ @FunctionalInterface diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift index 229e48f55..101a3f73e 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift @@ -450,7 +450,8 @@ extension JNISwift2JavaGenerator { ], conversion: .closureLowering( parameters: parameters, - result: result + result: result, + functionType: functionType ), indirectConversion: nil, conversionCheck: nil @@ -1364,7 +1365,7 @@ extension JNISwift2JavaGenerator { /// of the `Unsafe(Mutable)Pointer` types in Swift. indirect case pointee(NativeSwiftConversionStep) - indirect case closureLowering(parameters: [NativeParameter], result: NativeResult) + indirect case closureLowering(parameters: [NativeParameter], result: NativeResult, functionType: SwiftFunctionType) /// Escaping closure lowering using the protocol infrastructure. /// This uses UpcallConversionStep for full support of optionals, arrays, custom types, etc. @@ -1667,7 +1668,7 @@ extension JNISwift2JavaGenerator { let inner = inner.render(&printer, placeholder) return "\(inner).pointee" - case .closureLowering(let parameters, let nativeResult): + case .closureLowering(let parameters, let nativeResult, let functionType): var printer = SwiftPrinter() let methodSignature = MethodSignature( @@ -1683,8 +1684,10 @@ extension JNISwift2JavaGenerator { ) let names = parameters.flatMap { $0.parameters.map(\.name) } - let closureParameters = !parameters.isEmpty ? "\(names.joined(separator: .comma)) in" : "" - printer.print("{ \(closureParameters)") + let sendablePrefix = functionType.isSendable ? "@Sendable " : "" + let closureParameters = !parameters.isEmpty ? "\(sendablePrefix)\(names.joined(separator: .comma)) in" : (functionType.isSendable ? "@Sendable in" : "") + let space = closureParameters.isEmpty ? "" : " " + printer.print("{\(space)\(closureParameters)") printer.indent() // TODO: Add support for types that are lowered to multiple parameters in closures @@ -1692,9 +1695,8 @@ extension JNISwift2JavaGenerator { $0.conversion.render(&printer, $0.parameters.first!.name) } - let methodName = - if let known = KnownJavaFunctionalInterface.find(methodSignature) { + if let known = KnownJavaFunctionalInterface.find(functionType) { known.method } else { "apply" @@ -1755,10 +1757,11 @@ extension JNISwift2JavaGenerator { // Note: async is part of the closure TYPE, not the closure literal syntax. // For closures without parameters, we can omit "in" entirely. + let sendablePrefix = fn.isSendable ? "@Sendable " : "" let closureHeader = fn.parameters.isEmpty - ? "{" - : "{ \(closureParameters) in" + ? (fn.isSendable ? "{ @Sendable in" : "{") + : "{ \(sendablePrefix)\(closureParameters) in" // Construct the generated `@JavaInterface` wrap-java struct. // It will cause a new global ref on the javaThis, so no need for explicit global refs. diff --git a/Sources/JExtractSwiftLib/KnownFunctionalInterfaces.swift b/Sources/JExtractSwiftLib/KnownFunctionalInterfaces.swift index 9b1f75ab2..6f63b4ad1 100644 --- a/Sources/JExtractSwiftLib/KnownFunctionalInterfaces.swift +++ b/Sources/JExtractSwiftLib/KnownFunctionalInterfaces.swift @@ -226,7 +226,7 @@ struct KnownJavaFunctionalInterface: Sendable { } static func find(_ functionType: SwiftFunctionType) -> KnownJavaFunctionalInterface? { - if functionType.isEscaping { + if functionType.isEscaping || functionType.isSendable { return nil } @@ -354,14 +354,14 @@ struct KnownJavaFunctionalInterface: Sendable { } static func find(_ functionType: JNISwift2JavaGenerator.TranslatedFunctionType) -> KnownJavaFunctionalInterface? { - if functionType.isEscaping { + if functionType.isEscaping || functionType.swiftType.isSendable { return nil } return find(parameters: functionType.parameters, result: functionType.result) } static func find(_ functionType: FFMSwift2JavaGenerator.TranslatedFunctionType) -> KnownJavaFunctionalInterface? { - if functionType.swiftType.isEscaping { + if functionType.swiftType.isEscaping || functionType.swiftType.isSendable { return nil } return find(parameters: functionType.parameters.map(\.parameter.type.javaType), result: functionType.result.javaResultType) diff --git a/Sources/SwiftExtract/SwiftTypes/SwiftFunctionType.swift b/Sources/SwiftExtract/SwiftTypes/SwiftFunctionType.swift index a7e8f43c5..9357bcc66 100644 --- a/Sources/SwiftExtract/SwiftTypes/SwiftFunctionType.swift +++ b/Sources/SwiftExtract/SwiftTypes/SwiftFunctionType.swift @@ -24,6 +24,7 @@ public struct SwiftFunctionType: Equatable { public var parameters: [SwiftParameter] public var resultType: SwiftType public var isEscaping: Bool = false + public var isSendable: Bool = false public var effectSpecifiers: [SwiftEffectSpecifier] = [] @@ -38,6 +39,7 @@ public struct SwiftFunctionType: Equatable { parameters: [SwiftParameter], resultType: SwiftType, isEscaping: Bool = false, + isSendable: Bool = false, effectSpecifiers: [SwiftEffectSpecifier] = [], thrownTypedError: SwiftType? = nil ) { @@ -45,6 +47,7 @@ public struct SwiftFunctionType: Equatable { self.parameters = parameters self.resultType = resultType self.isEscaping = isEscaping + self.isSendable = isSendable self.effectSpecifiers = effectSpecifiers self.thrownTypedError = thrownTypedError } @@ -59,6 +62,7 @@ extension SwiftFunctionType: CustomStringConvertible { case .swift: "" } let escapingPrefix = isEscaping ? "@escaping " : "" + let sendablePrefix = isSendable ? "@Sendable " : "" let throwsString = switch (isThrowing, thrownTypedError) { case (true, .some(let errorType)): " throws(\(errorType.description))" @@ -66,7 +70,7 @@ extension SwiftFunctionType: CustomStringConvertible { case (false, _): "" } let effectsSuffix = (isAsync ? " async" : "") + throwsString - return "\(escapingPrefix)\(conventionPrefix)(\(parameterString))\(effectsSuffix) -> \(resultType.description)" + return "\(escapingPrefix)\(sendablePrefix)\(conventionPrefix)(\(parameterString))\(effectsSuffix) -> \(resultType.description)" } } @@ -75,10 +79,12 @@ extension SwiftFunctionType { _ node: FunctionTypeSyntax, convention: Convention, isEscaping: Bool = false, + isSendable: Bool = false, lookupContext: SwiftTypeLookupContext ) throws { self.convention = convention self.isEscaping = isEscaping + self.isSendable = isSendable self.parameters = try node.parameters.map { param in let isInout = param.inoutKeyword != nil return SwiftParameter( diff --git a/Sources/SwiftExtract/SwiftTypes/SwiftParameter.swift b/Sources/SwiftExtract/SwiftTypes/SwiftParameter.swift index 896449e0a..728d3e71e 100644 --- a/Sources/SwiftExtract/SwiftTypes/SwiftParameter.swift +++ b/Sources/SwiftExtract/SwiftTypes/SwiftParameter.swift @@ -152,6 +152,12 @@ extension SwiftParameter { // Determine the type. self.type = try SwiftType(type, lookupContext: lookupContext) + if node.attributes.trimmedDescription.contains("@Sendable"), + case .function(var fnType) = self.type + { + fnType.isSendable = true + self.type = .function(fnType) + } // Variadic / default-value information. self.isVariadic = node.ellipsis != nil diff --git a/Sources/SwiftExtract/SwiftTypes/SwiftType.swift b/Sources/SwiftExtract/SwiftTypes/SwiftType.swift index 6a9a89141..97765a4fe 100644 --- a/Sources/SwiftExtract/SwiftTypes/SwiftType.swift +++ b/Sources/SwiftExtract/SwiftTypes/SwiftType.swift @@ -379,37 +379,35 @@ extension SwiftType { self = .inlineArray(count: count, element: elementType) case .attributedType(let attributedType): - // Recognize "@convention(c)", "@convention(swift)", and "@escaping" attributes on function types. + // Recognize "@convention(c)", "@convention(swift)", "@escaping", and "@Sendable" attributes on function types. // FIXME: This string matching is a horrible hack. let attrs = attributedType.attributes.trimmedDescription - - // Handle @escaping attribute - if attrs.contains("@escaping") { - let innerType = try SwiftType(attributedType.baseType, lookupContext: lookupContext) - switch innerType { - case .function(var functionType): + let innerType = try SwiftType(attributedType.baseType, lookupContext: lookupContext) + switch innerType { + case .function(var functionType): + var recognized = false + if attrs.contains("@escaping") { functionType.isEscaping = true - self = .function(functionType) - default: - throw TypeTranslationError.unimplementedType(type) + recognized = true } - } else { - // Handle @convention attributes - switch attrs { - case "@convention(c)", "@convention(swift)": - let innerType = try SwiftType(attributedType.baseType, lookupContext: lookupContext) - switch innerType { - case .function(var functionType): - let isConventionC = attrs == "@convention(c)" - let convention: SwiftFunctionType.Convention = isConventionC ? .c : .swift - functionType.convention = convention - self = .function(functionType) - default: - throw TypeTranslationError.unimplementedType(type) - } - default: + if attrs.contains("@Sendable") { + functionType.isSendable = true + recognized = true + } + if attrs.contains("@convention(c)") { + functionType.convention = .c + recognized = true + } else if attrs.contains("@convention(swift)") { + functionType.convention = .swift + recognized = true + } + if recognized { + self = .function(functionType) + } else { throw TypeTranslationError.unimplementedType(type) } + default: + throw TypeTranslationError.unimplementedType(type) } case .functionType(let functionType): diff --git a/Tests/JExtractSwiftTests/SendableTests.swift b/Tests/JExtractSwiftTests/SendableTests.swift index 3fd494fff..16d039a89 100644 --- a/Tests/JExtractSwiftTests/SendableTests.swift +++ b/Tests/JExtractSwiftTests/SendableTests.swift @@ -57,4 +57,209 @@ final class SendableTests { ) } + @Test("Import: Sendable escaping closure (jni)") + func sendableEscapingClosure_jni() throws { + let closureSource = + """ + public func onEvent(_ f: @escaping @Sendable (Int64) -> Void) {} + """ + + try assertOutput( + input: closureSource, + .jni, + .java, + expectedChunks: [ + """ + public static class onEvent { + /** Corresponds to the Swift closure parameter of type {@code @escaping @Sendable (Int64) -> Void}. */ + @ThreadSafe // Sendable + @FunctionalInterface + public interface f { + void apply(long _0); + } + } + """ + ] + ) + } + + @Test("Import: Sendable escaping closure Swift wrapper (jni)") + func sendableEscapingClosureSwift_jni() throws { + let closureSource = + """ + public func onEvent(_ f: @escaping @Sendable (Int64) -> Void) {} + """ + + try assertOutput( + input: closureSource, + .jni, + .swift, + expectedChunks: [ + """ + @JavaInterface("com.example.swift.SwiftModule$onEvent$f") + public struct JavaSwiftModule_onEvent_f: @unchecked Sendable { + @JavaMethod + public func apply(_ _0: Int64) + } + """, + """ + return { @Sendable _0 in + javaInterface_f$.apply(_0) + } + """, + ] + ) + } + + @Test("Import: Sendable escaping closure (ffm)") + func sendableEscapingClosure_ffm() throws { + let closureSource = + """ + public func onEvent(_ f: @escaping @Sendable (Int64) -> Void) {} + """ + + try assertOutput( + input: closureSource, + .ffm, + .java, + expectedChunks: [ + """ + public static class onEvent { + @ThreadSafe // Sendable + @FunctionalInterface + public interface f extends swiftjava_SwiftModule_onEvent__.$f.Function {} + private static MemorySegment $toUpcallStub(f fi, Arena arena) { + return swiftjava_SwiftModule_onEvent__.$f.toUpcallStub(fi, arena); + } + } + """ + ] + ) + } + + @Test("Import: Sendable non-escaping closure (jni)") + func sendableNonEscapingClosure_jni() throws { + let closureSource = + """ + public func performAction(_ f: @Sendable (Int64) -> Void) {} + """ + + try assertOutput( + input: closureSource, + .jni, + .java, + expectedChunks: [ + """ + public static class performAction { + /** Corresponds to the Swift closure parameter of type {@code @Sendable (Int64) -> Void}. */ + @ThreadSafe // Sendable + @FunctionalInterface + public interface f { + void apply(long _0); + } + } + """ + ] + ) + } + + @Test("Import: Sendable non-escaping closure (ffm)") + func sendableNonEscapingClosure_ffm() throws { + let closureSource = + """ + public func performAction(_ f: @Sendable (Int64) -> Void) {} + """ + + try assertOutput( + input: closureSource, + .ffm, + .java, + expectedChunks: [ + """ + public static class performAction { + @ThreadSafe // Sendable + @FunctionalInterface + public interface f extends swiftjava_SwiftModule_performAction__.$f.Function {} + private static MemorySegment $toUpcallStub(f fi, Arena arena) { + return swiftjava_SwiftModule_performAction__.$f.toUpcallStub(fi, arena); + } + } + """ + ] + ) + } + + @Test("Import: Sendable closure does not use known functional interface (jni)") + func sendableClosure_doesNotUseKnownFunctionalInterface_jni() throws { + let closureSource = + """ + public func runStandard(closure: () -> Void) {} + public func runSendable(closure: @Sendable () -> Void) {} + """ + + try assertOutput( + input: closureSource, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + public static void runStandard(java.lang.Runnable closure) { + SwiftModule.$runStandard(closure); + } + """, + """ + public static class runSendable { + /** Corresponds to the Swift closure parameter of type {@code @Sendable () -> Void}. */ + @ThreadSafe // Sendable + @FunctionalInterface + public interface closure { + void apply(); + } + } + """, + """ + public static void runSendable(com.example.swift.SwiftModule.runSendable.closure closure) { + SwiftModule.$runSendable(closure); + } + """, + ] + ) + + try assertOutput( + input: closureSource, + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + @_cdecl("Java_com_example_swift_SwiftModule__00024runStandard__Ljava_lang_Runnable_2") + public func Java_com_example_swift_SwiftModule__00024runStandard__Ljava_lang_Runnable_2(environment: UnsafeMutablePointer!, thisClass: jclass, closure: jobject?) { + SwiftModule.runStandard(closure: { + let class$ = environment.interface.GetObjectClass(environment, closure) + let methodID$ = environment.interface.GetMethodID(environment, class$, "run", "()V")! + environment.interface.DeleteLocalRef(environment, class$) + let arguments$: [jvalue] = [] + environment.interface.CallVoidMethodA(environment, closure, methodID$, arguments$) + } + ) + } + """, + """ + @_cdecl("Java_com_example_swift_SwiftModule__00024runSendable__Lcom_example_swift_SwiftModule_00024runSendable_00024closure_2") + public func Java_com_example_swift_SwiftModule__00024runSendable__Lcom_example_swift_SwiftModule_00024runSendable_00024closure_2(environment: UnsafeMutablePointer!, thisClass: jclass, closure: jobject?) { + SwiftModule.runSendable(closure: { @Sendable in + let class$ = environment.interface.GetObjectClass(environment, closure) + let methodID$ = environment.interface.GetMethodID(environment, class$, "apply", "()V")! + environment.interface.DeleteLocalRef(environment, class$) + let arguments$: [jvalue] = [] + environment.interface.CallVoidMethodA(environment, closure, methodID$, arguments$) + } + ) + } + """, + ] + ) + } + } diff --git a/Tests/SwiftExtractTests/FunctionTypeEffectsTests.swift b/Tests/SwiftExtractTests/FunctionTypeEffectsTests.swift index f6bc3d2ff..685186103 100644 --- a/Tests/SwiftExtractTests/FunctionTypeEffectsTests.swift +++ b/Tests/SwiftExtractTests/FunctionTypeEffectsTests.swift @@ -126,6 +126,56 @@ struct FunctionTypeEffectSpecifierSuite { } #expect(fnType.effectSpecifiers == [.async]) } + + @Test + func sendableClosureRecordsSendable() throws { + let fnType = try closureParameterType("public func take(_ cb: @Sendable () -> Void) {}") + + #expect(fnType.isSendable) + #expect(!fnType.isEscaping) + #expect(fnType.description == "@Sendable () -> Void") + } + + @Test + func escapingSendableClosureRecordsBoth() throws { + let fnType = try closureParameterType("public func take(_ cb: @escaping @Sendable (Int) -> Void) {}") + + #expect(fnType.isSendable) + #expect(fnType.isEscaping) + #expect(fnType.description == "@escaping @Sendable (Int) -> Void") + } + + @Test + func sendableClosureWithMultipleParametersAndReturn() throws { + let fnType = try closureParameterType("public func take(_ cb: @Sendable (Int, Double) -> Bool) {}") + + #expect(fnType.isSendable) + #expect(!fnType.isEscaping) + #expect(fnType.description == "@Sendable (Int, Double) -> Bool") + } + + @Test + func functionReturningSendableClosure() throws { + let result = try analyze( + sources: [ + ( + "/fake/Source.swift", + """ + public func make() -> @Sendable (Int) -> String { fatalError() } + """ + ) + ], + moduleName: "Test" + ) + + let fn = try #require(result.extractedGlobalFuncs.first { $0.name == "make" }) + guard case .function(let fnType) = fn.functionSignature.result.type else { + Issue.record("expected .function result, got \(fn.functionSignature.result.type)") + return + } + #expect(fnType.isSendable) + #expect(fnType.description == "@Sendable (Int) -> String") + } } private struct TestError: Error, CustomStringConvertible {