diff --git a/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/MainActorBox.swift b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/MainActorBox.swift new file mode 100644 index 000000000..ddf6dabaa --- /dev/null +++ b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/MainActorBox.swift @@ -0,0 +1,22 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +@MainActor +public struct MainActorBox { + public var typeName: String { "\(Value.self)" } + + public static func describeType() -> String { + "\(Value.self)" + } +} diff --git a/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/MainActorCounter.swift b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/MainActorCounter.swift new file mode 100644 index 000000000..5db4105f2 --- /dev/null +++ b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/MainActorCounter.swift @@ -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 +// +//===----------------------------------------------------------------------===// + +// Compiling the generated thunks exercises main actor isolation checking. +@MainActor +public final class MainActorCounter { + public var value: Int64 + + public init(value: Int64) { + self.value = value + } + + public func increment() { + value += 1 + } + + public func label() -> String { + "Count: \(value)" + } + + public func checkedValue() throws -> Int64 { + value + } + + public nonisolated func constant() -> Int64 { + 42 + } + + public nonisolated var constantValue: Int64 { 42 } + + public subscript(offset: Int64) -> Int64 { + get { value + offset } + set { value = newValue - offset } + } + + public static func initialValue() -> Int64 { + 0 + } +} + +extension MainActorCounter { + public func reset() { + value = 0 + } +} + +@_Concurrency.MainActor +public func mainActorAnswer() -> Int64 { + 42 +} + +@MainActor +public func mainActorThrowingAnswer() throws -> Int64 { + 42 +} diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift index c5ac7bba0..bd53ed5f2 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift @@ -402,7 +402,7 @@ extension JNISwift2JavaGenerator { } translated.parentName = boxParentName - printCDecl(&printer, translated) { printer in + printCDecl(&printer, translated, mainActorIsolated: isSynchronousMainActorIsolated(method)) { printer in self.printFunctionDowncall(&printer, method) } printer.println() @@ -485,6 +485,7 @@ extension JNISwift2JavaGenerator { printCDecl( &printer, translatedDecl, + mainActorIsolated: isSynchronousMainActorIsolated(decl), ) { printer in if let parent = decl.parentType?.asNominalType, parent.hasGenericParameter { self.printFunctionOpenerCall(&printer, decl) @@ -600,6 +601,48 @@ extension JNISwift2JavaGenerator { } } + private func isSynchronousMainActorIsolated(_ decl: ExtractedFunc) -> Bool { + guard !decl.isAsync, !decl.isIsolated, !decl.isImplicitlyAsync, + !decl.functionSignature.isNonisolated + else { + return false + } + + // Initializers and accessors also carry modifiers on their declarations. + if decl.swiftDecl.asProtocol(WithModifiersSyntax.self)?.modifiers.contains(where: { + $0.name.tokenKind == .keyword(.nonisolated) + }) == true { + return false + } + + func hasMainActor(_ attributes: AttributeListSyntax?) -> Bool { + attributes?.contains { element in + guard let attribute = element.as(AttributeSyntax.self) else { return false } + let name = attribute.attributeName.trimmedDescription + return name == "MainActor" || name == "_Concurrency.MainActor" + } ?? false + } + + if hasMainActor(decl.attributeList) + || hasMainActor(decl.parentType?.asNominalType?.nominalTypeDecl.syntax.attributes) + { + return true + } + + // An extension can isolate its members without isolating the nominal type. + var context = decl.swiftDecl.parent + while let node = context { + if let extensionDecl = node.as(ExtensionDeclSyntax.self) { + return hasMainActor(extensionDecl.attributes) + } + if node.isProtocol(DeclGroupSyntax.self) { + break + } + context = node.parent + } + return false + } + private func printFunctionDowncall( _ printer: inout SwiftPrinter, _ decl: ExtractedFunc, @@ -846,6 +889,7 @@ extension JNISwift2JavaGenerator { private func printCDecl( _ printer: inout SwiftPrinter, _ translatedDecl: TranslatedFunctionDecl, + mainActorIsolated: Bool = false, _ body: (inout SwiftPrinter) -> Void, ) { let nativeSignature = translatedDecl.nativeFunctionSignature @@ -865,6 +909,7 @@ extension JNISwift2JavaGenerator { parentName: translatedDecl.parentName, parameters: parameters, resultType: nativeSignature.result.javaType, + mainActorIsolated: mainActorIsolated, ) { printer in body(&printer) } @@ -876,6 +921,7 @@ extension JNISwift2JavaGenerator { parentName: SwiftQualifiedTypeName, parameters: [JavaParameter], resultType: JavaType, + mainActorIsolated: Bool = false, _ body: (inout SwiftPrinter) -> Void, ) { let jniSignature = parameters.reduce(into: "") { signature, parameter in @@ -911,10 +957,15 @@ extension JNISwift2JavaGenerator { #if compiler(>=6.4) @diagnose(DeprecatedDeclaration, as: ignored) #endif - @_cdecl("\(cName)") + \(mainActorIsolated ? "@MainActor\n" : "")@_cdecl("\(cName)") public func \(cName)(\(thunkParameters.joined(separator: .comma)))\(thunkReturnType) """ ) { printer in + if mainActorIsolated { + // JNI callers must already be on the main actor. Preserve synchronous + // calls and diagnose an incorrect executor before accessing Swift state. + printer.print("MainActor.preconditionIsolated()") + } body(&printer) } } @@ -1177,6 +1228,10 @@ extension JNISwift2JavaGenerator { ] + translatedParameters let thunkReturnType = resultType != .void ? " -> \(resultType.jniTypeName)" : "" + if isSynchronousMainActorIsolated(decl) { + printer.print("@MainActor") + } + let signature = #"static func \#(decl.openerMethodName)(\#(thunkParameters.joined(separator: .comma)))\#(thunkReturnType)"# if !skipMethodBody { printer.printBraceBlock(signature) { printer in diff --git a/Sources/SwiftJavaDocumentation/Documentation.docc/FeaturesJextract.md b/Sources/SwiftJavaDocumentation/Documentation.docc/FeaturesJextract.md index 24d50e508..cd3a43027 100644 --- a/Sources/SwiftJavaDocumentation/Documentation.docc/FeaturesJextract.md +++ b/Sources/SwiftJavaDocumentation/Documentation.docc/FeaturesJextract.md @@ -261,6 +261,21 @@ suspend fun useAsyncSum() { This relies on the Kotlin `kotlinx-coroutines-jdk8` library which adds the `await()` extension function on `java.util.concurrent.CompletableFuture`. +### Main actor isolation (JNI) + +Synchronous functions explicitly isolated to `@MainActor` remain synchronous in +Java. This also applies to initializers, methods, and property or subscript +accessors isolated by an enclosing `@MainActor` type or extension. + +The generated Swift thunk is annotated with `@MainActor` and calls +`MainActor.preconditionIsolated` to check that the caller is already executing on +the main actor. It does not dispatch the call to the main actor. Calling one of +these synchronous APIs from another executor terminates the process, so Java +callers must arrange to run on the main actor's executor before invoking it. + +Explicitly `nonisolated` members do not perform this check. Explicitly `async` +functions continue to return futures and await the isolated Swift call instead. + ### Collections Arrays are copied across the boundary. Dictionaries and sets are handed to Java as diff --git a/Tests/JExtractSwiftTests/JNI/JNIMainActorTests.swift b/Tests/JExtractSwiftTests/JNI/JNIMainActorTests.swift new file mode 100644 index 000000000..281d0ba3f --- /dev/null +++ b/Tests/JExtractSwiftTests/JNI/JNIMainActorTests.swift @@ -0,0 +1,214 @@ +//===----------------------------------------------------------------------===// +// +// 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 JExtractSwiftLib +import Testing + +@Suite +struct JNIMainActorTests { + @Test("Synchronous main actor functions check isolation", arguments: ["MainActor", "_Concurrency.MainActor"]) + func globalFunction_swift(attribute: String) throws { + try assertOutput( + input: "@\(attribute) public func answer() -> Int64 { 42 }", + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + @MainActor + @_cdecl("Java_com_example_swift_SwiftModule__00024answer__") + ... + MainActor.preconditionIsolated() + return SwiftModule.answer().getJNILocalRefValue(in: environment) + """ + ], + notExpectedChunks: ["Task.immediate"] + ) + } + + @Test("Synchronous main actor functions retain synchronous Java signatures") + func globalFunction_java() throws { + try assertOutput( + input: "@MainActor public func answer() -> Int64 { 42 }", + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + public static long answer() { + return SwiftModule.$answer(); + } + """ + ], + notExpectedChunks: ["CompletableFuture", "java.util.concurrent.Future"] + ) + } + + @Test( + "Main actor type members check isolation", + arguments: [ + ("public init() {}", "K.init()"), + ("public func hello() {}", "selfPointer$.pointee.hello()"), + ("public static func hello() {}", "K.hello()"), + ("public var value: Int64 { 42 }", "selfPointer$.pointee.value.getJNILocalRefValue(in: environment)"), + ("public var value: Int64 { get { 0 } set {} }", "selfPointer$.pointee.value = Int64(fromJNI: newValue, in: environment)"), + ("public subscript(index: Int64) -> Int64 { index }", "selfPointer$.pointee[Int64(fromJNI: index, in: environment)].getJNILocalRefValue(in: environment)"), + ] + ) + func typeMember_swift(member: String, call: String) throws { + try assertOutput( + input: "@MainActor public final class K { \(member) }", + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + "@MainActor\n@_cdecl(", "MainActor.preconditionIsolated()", + call, + ], + notExpectedChunks: ["Task.immediate"] + ) + } + + @Test( + "Explicitly isolated members check isolation", + arguments: [ + "public final class K { @MainActor public func hello() {} }", + "public final class K { @MainActor public var value: Int64 { 42 } }", + "public final class K {}\nextension K { @MainActor public func hello() {} }", + "@MainActor public final class K {}\nextension K { public func hello() {} }", + "public final class K {}\n@MainActor extension K { public func hello() {} }", + ] + ) + func isolatedMember_swift(source: String) throws { + try assertOutput( + input: source, + .jni, + .swift, + expectedChunks: ["@MainActor\n@_cdecl(", "MainActor.preconditionIsolated()"], + notExpectedChunks: ["Task.immediate"] + ) + } + + @Test( + "Nonisolated members do not check main actor isolation", + arguments: [ + "public nonisolated init() {}", + "public nonisolated func hello() {}", + "public nonisolated var value: Int64 { 42 }", + "public nonisolated(unsafe) var value: Int64 { 42 }", + ] + ) + func nonisolatedMember_swift(member: String) throws { + try assertOutput( + input: "@MainActor public final class K { \(member) }", + .jni, + .swift, + expectedChunks: ["@_cdecl("], + notExpectedChunks: ["MainActor.preconditionIsolated()", "@MainActor\n@_cdecl(", "Task.immediate"] + ) + } + + @Test("Generic opener requirements and witnesses preserve main actor isolation") + func genericMember_swift() throws { + try assertOutput( + input: "@MainActor public struct K { public var value: Int64 { 42 } }", + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + protocol _SwiftModule_K_opener { + @MainActor + static func _get_value(environment: UnsafeMutablePointer!, thisClass: jclass, selfPointer: jlong) -> jlong + } + """, + """ + extension K: _SwiftModule_K_opener { + @MainActor + static func _get_value(environment: UnsafeMutablePointer!, thisClass: jclass, selfPointer: jlong) -> jlong { + """, + ] + ) + } + + @Test("Throwing main actor calls translate exceptions inside the isolation check") + func throwingFunction_swift() throws { + try assertOutput( + input: "@MainActor public func answer() throws -> Int64 { 42 }", + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + MainActor.preconditionIsolated() + do { + return try SwiftModule.answer().getJNILocalRefValue(in: environment) + } catch { + environment.throwSwiftErrorAsJava(error) + return 0 + } + """ + ] + ) + } + + @Test("Reference-valued JNI results stay inside an isolated thunk") + func stringResult_swift() throws { + try assertOutput( + input: "@MainActor public func greeting() -> String { \"Hello\" }", + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + @MainActor + @_cdecl("Java_com_example_swift_SwiftModule__00024greeting__") + public func Java_com_example_swift_SwiftModule__00024greeting__(environment: UnsafeMutablePointer!, thisClass: jclass) -> jstring? { + MainActor.preconditionIsolated() + return SwiftModule.greeting().getJNILocalRefValue(in: environment) + } + """ + ], + notExpectedChunks: ["MainActor.assumeIsolated"] + ) + } + + @Test("Asynchronous main actor functions keep their asynchronous downcalls") + func asyncFunction_swift() throws { + try assertOutput( + input: "@MainActor public func answer() async -> Int64 { 42 }", + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + task = Task.immediate { + ... + let swiftResult$ = await SwiftModule.answer() + """ + ], + notExpectedChunks: ["MainActor.preconditionIsolated()", "@MainActor\n@_cdecl("] + ) + } + + @Test("Asynchronous main actor functions retain future-returning Java signatures") + func asyncFunction_java() throws { + try assertOutput( + input: "@MainActor public func answer() async -> Int64 { 42 }", + .jni, + .java, + expectedChunks: ["public static java.util.concurrent.CompletableFuture answer() {"] + ) + } +}