From f6c879d5c3e43d23917ecffd2a6b9f3e1624efaa Mon Sep 17 00:00:00 2001 From: Himeshi Date: Fri, 23 Oct 2026 12:35:15 +0900 Subject: [PATCH] JNI: preserve MainActor isolation in synchronous thunks Synchronous MainActor APIs currently produce nonisolated JNI entry points, so their generated bindings fail to compile when calling isolated Swift initializers or members. Annotate these entry points with MainActor and check the caller's executor before converting arguments or accessing Swift state. Keep the Java API synchronous, preserve isolation through generic openers, and leave nonisolated members and asynchronous downcalls on their existing paths. Isolating the thunk itself also supports reference-valued JNI results in Swift 6 without requiring JNI pointers to conform to Sendable. Add regression tests, JNI sample compilation fixtures, and documentation of the executor requirement for Java callers. Verify the regressions against the original generator, run the full Swift and JNI sample test suites, and compile the fixtures in Swift 6 language mode. --- .../Sources/MySwiftLibrary/MainActorBox.swift | 22 ++ .../MySwiftLibrary/MainActorCounter.swift | 66 ++++++ ...ift2JavaGenerator+SwiftThunkPrinting.swift | 59 ++++- .../Documentation.docc/FeaturesJextract.md | 15 ++ .../JNI/JNIMainActorTests.swift | 214 ++++++++++++++++++ 5 files changed, 374 insertions(+), 2 deletions(-) create mode 100644 Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/MainActorBox.swift create mode 100644 Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/MainActorCounter.swift create mode 100644 Tests/JExtractSwiftTests/JNI/JNIMainActorTests.swift 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() {"] + ) + } +}