diff --git a/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/ActorProperties.swift b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/ActorProperties.swift new file mode 100644 index 000000000..d5ef8ce87 --- /dev/null +++ b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/ActorProperties.swift @@ -0,0 +1,29 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +public protocol ActorIdentifier { + var identifier: Int64 { get } +} + +public actor IdentifiedActor: ActorIdentifier { + public let identifier: Int64 + public let unwitnessed: Int64 + public var mutable: Int64 { identifier + 2 } + public nonisolated var label: String { "actor" } + + public init(identifier: Int64) { + self.identifier = identifier + self.unwitnessed = identifier + 1 + } +} diff --git a/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ActorPropertiesTest.java b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ActorPropertiesTest.java new file mode 100644 index 000000000..37486af44 --- /dev/null +++ b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ActorPropertiesTest.java @@ -0,0 +1,46 @@ +//===----------------------------------------------------------------------===// +// +// 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.swift; + +import org.junit.jupiter.api.Test; +import org.swift.swiftkit.core.SwiftArena; + +import java.util.concurrent.Future; + +import static org.junit.jupiter.api.Assertions.*; + +public class ActorPropertiesTest { + @Test + void nonisolatedPropertiesStaySynchronous() { + try (var arena = SwiftArena.ofConfined()) { + IdentifiedActor actor = IdentifiedActor.init(42, arena); + long identifier = actor.getIdentifier(); + String label = actor.getLabel(); + assertEquals(42, identifier); + assertEquals("actor", label); + } + } + + @Test + void isolatedPropertiesStillReturnFutures() throws Exception { + try (var arena = SwiftArena.ofConfined()) { + IdentifiedActor actor = IdentifiedActor.init(42, arena); + Future unwitnessed = actor.getUnwitnessed(); + Future mutable = actor.getMutable(); + assertEquals(43, unwitnessed.get()); + assertEquals(44, mutable.get()); + } + } +} diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift index c5ac7bba0..2128519ae 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift @@ -682,7 +682,7 @@ extension JNISwift2JavaGenerator { } // Callee - let callee: String = + let directCallee: String = switch decl.functionSignature.selfParameter { case .instance: nativeSignature.selfParameter!.conversion.render( @@ -695,6 +695,11 @@ extension JNISwift2JavaGenerator { swiftModuleName } + let callee = + decl.functionSignature.nonisolatedWitnessProtocol.map { + "(\(directCallee) as any \($0))" + } ?? directCallee + // Build the result let result: String switch decl.apiKind { diff --git a/Sources/SwiftExtract/SwiftTypes/SwiftFunctionSignature.swift b/Sources/SwiftExtract/SwiftTypes/SwiftFunctionSignature.swift index bbfa23a63..1e63329c8 100644 --- a/Sources/SwiftExtract/SwiftTypes/SwiftFunctionSignature.swift +++ b/Sources/SwiftExtract/SwiftTypes/SwiftFunctionSignature.swift @@ -33,6 +33,11 @@ public struct SwiftFunctionSignature: Equatable { public var isNonisolated: Bool = false + /// The protocol through which an immutable actor property's synchronous getter + /// must be accessed. Direct access to the stored property is isolated outside + /// the actor's defining module, even when it witnesses a nonisolated requirement. + public var nonisolatedWitnessProtocol: SwiftType? = nil + public var isDistributed: Bool = false public var isAsync: Bool { @@ -374,6 +379,21 @@ extension SwiftFunctionSignature { } let valueType = try SwiftType(varTypeNode, lookupContext: lookupContext) + self.isNonisolated = varNode.modifiers.contains { $0.name.tokenKind == .keyword(.nonisolated) } + if !isNonisolated, !isSet, case .instance = selfParameter, + enclosingType?.isActor == true, + varNode.bindingSpecifier.tokenKind == .keyword(.let), + let name = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text + { + self.nonisolatedWitnessProtocol = Self.synchronousPropertyWitness( + name: name, + valueType: valueType, + enclosingType: enclosingType!, + lookupContext: lookupContext + ) + self.isNonisolated = nonisolatedWitnessProtocol != nil + } + var accessorEffects: AccessorEffects? = nil switch binding.accessorBlock?.accessors { case .getter(let getter): @@ -404,6 +424,75 @@ extension SwiftFunctionSignature { self.genericRequirements = [] } + /// Conservatively find an ordinary protocol getter witnessed by an actor's + /// immutable stored property. Async, throwing, and actor-isolated requirements + /// do not provide a synchronous access path for the generated thunk. + private static func synchronousPropertyWitness( + name: String, + valueType: SwiftType, + enclosingType: SwiftType, + lookupContext: SwiftTypeLookupContext + ) -> SwiftType? { + func inheritedTypes(of declaration: SwiftNominalTypeDeclaration) -> [SwiftType] { + (declaration.inheritanceTypes ?? []).compactMap { + try? SwiftType($0.type, lookupContext: lookupContext) + } + } + + func isNonisolatedProtocol(_ type: SwiftType, visited: inout Set) -> Bool { + guard let declaration = type.asNominalTypeDeclaration, + let syntax = declaration.syntax.as(ProtocolDeclSyntax.self), + // Attributes can introduce global-actor isolation. Leave these cases + // isolated rather than infer semantics from an unfamiliar attribute. + syntax.attributes.isEmpty, + declaration.name != "Actor", declaration.name != "DistributedActor" + else { return false } + if !visited.insert(declaration.identity).inserted { return true } + return (declaration.inheritanceTypes ?? []).allSatisfy { inherited in + guard let type = try? SwiftType(inherited.type, lookupContext: lookupContext) else { return false } + return isNonisolatedProtocol(type, visited: &visited) + } + } + + func hasGetter(_ type: SwiftType, visited: inout Set) -> Bool { + guard let declaration = type.asNominalTypeDeclaration, + visited.insert(declaration.identity).inserted + else { return false } + if declaration.syntax.memberBlock.members.contains(where: { member in + guard let property = member.decl.as(VariableDeclSyntax.self), + property.attributes.isEmpty, + !property.modifiers.contains(where: { + $0.name.tokenKind == .keyword(.static) || $0.name.tokenKind == .keyword(.class) + }) + else { return false } + return property.bindings.contains { binding in + guard binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text == name, + let annotation = binding.typeAnnotation, + (try? SwiftType(annotation.type, lookupContext: lookupContext)) == valueType, + case .accessors(let accessors) = binding.accessorBlock?.accessors, + accessors.count == 1, let getter = accessors.first, + getter.accessorSpecifier.tokenKind == .keyword(.get), + getter.effectSpecifiers?.asyncSpecifier == nil, + getter.effectSpecifiers?.throwsClause == nil + else { return false } + return true + } + }) { + return true + } + return inheritedTypes(of: declaration).contains { hasGetter($0, visited: &visited) } + } + + for type in enclosingType.asNominalTypeDeclaration.map(inheritedTypes) ?? [] { + var isolationVisited: Set = [] + var getterVisited: Set = [] + if isNonisolatedProtocol(type, visited: &isolationVisited), hasGetter(type, visited: &getterVisited) { + return type + } + } + return nil + } + public init( _ subscriptNode: SubscriptDeclSyntax, isSet: Bool, diff --git a/Sources/SwiftJavaDocumentation/Documentation.docc/FeaturesJextract.md b/Sources/SwiftJavaDocumentation/Documentation.docc/FeaturesJextract.md index 24d50e508..7c80e1b3f 100644 --- a/Sources/SwiftJavaDocumentation/Documentation.docc/FeaturesJextract.md +++ b/Sources/SwiftJavaDocumentation/Documentation.docc/FeaturesJextract.md @@ -534,6 +534,14 @@ currently supports tuples of primitive elements only. } } +### Actor properties in JNI mode + +Explicitly `nonisolated` actor properties are imported synchronously. An immutable +actor property that witnesses an ordinary synchronous, nonthrowing protocol getter +is also imported synchronously; its JNI thunk reads the property through the +protocol so that the access remains valid across module boundaries. Other +actor-isolated properties retain asynchronous access through a future. + ### Protocols Swift `protocol` types are imported as Java `interface`s. Concrete types wrapping diff --git a/Tests/JExtractSwiftTests/JNI/JNIActorPropertyTests.swift b/Tests/JExtractSwiftTests/JNI/JNIActorPropertyTests.swift new file mode 100644 index 000000000..2cdc603e8 --- /dev/null +++ b/Tests/JExtractSwiftTests/JNI/JNIActorPropertyTests.swift @@ -0,0 +1,67 @@ +//===----------------------------------------------------------------------===// +// +// 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 JNIActorPropertyTests { + static let source = """ + public protocol Identified { var identifier: Int64 { get } } + public actor Record: Identified { + public let identifier: Int64 + public let unwitnessed: Int64 + public var mutable: Int64 + public nonisolated var label: Int64 { 7 } + public nonisolated(unsafe) var unsafeValue: Int64 + } + """ + + @Test + func synchronousJavaProperties() throws { + try assertOutput( + input: Self.source, + .jni, + .java, + expectedChunks: [ + "public long getIdentifier() {", + "public long getLabel() {", + "public long getUnsafeValue() {", + "public void setUnsafeValue(long newValue) {", + "public java.util.concurrent.CompletableFuture getUnwitnessed() {", + "public java.util.concurrent.CompletableFuture getMutable() {", + "public java.util.concurrent.CompletableFuture setMutable(long newValue) {", + ] + ) + } + + @Test + func synchronousWitnessDowncall() throws { + try assertOutput( + input: Self.source, + .jni, + .swift, + expectedChunks: [ + "return (selfPointer$.pointee as any Identified).identifier.getJNILocalRefValue(in: environment)", + "return selfPointer$.pointee.label.getJNILocalRefValue(in: environment)", + "selfPointer$.pointee.unsafeValue = Int64(fromJNI: newValue, in: environment)", + "let swiftResult$ = await selfPointer$.pointee.mutable", + ], + notExpectedChunks: [ + "await selfPointer$.pointee.identifier", + "await selfPointer$.pointee.label", + ] + ) + } +} diff --git a/Tests/SwiftExtractTests/ActorPropertyIsolationTests.swift b/Tests/SwiftExtractTests/ActorPropertyIsolationTests.swift new file mode 100644 index 000000000..fa4056f26 --- /dev/null +++ b/Tests/SwiftExtractTests/ActorPropertyIsolationTests.swift @@ -0,0 +1,72 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftExtract +import Testing + +@Suite +struct ActorPropertyIsolationTests { + static let source = """ + public protocol Identified { var identifier: Int64 { get } } + public protocol InheritedIdentifier: Identified {} + @MainActor public protocol MainActorValue { var mainActorValue: Int64 { get } } + public protocol AsyncValue { var asynchronous: Int64 { get async } } + public protocol ThrowingValue { var throwing: Int64 { get throws } } + public protocol ActorValue: Actor { var isolated: Int64 { get } } + public actor Record: InheritedIdentifier, AsyncValue, ThrowingValue, ActorValue, MainActorValue { + public let identifier: Int64 + public let asynchronous: Int64 + public let throwing: Int64 + public let isolated: Int64 + public let mainActorValue: Int64 + public let unwitnessed: Int64 + public var mutable: Int64 + public nonisolated var label: Int64 { 7 } + public nonisolated(unsafe) var unsafeValue: Int64 + } + """ + + @Test + func explicitNonisolatedAccessors() throws { + let record = try record() + let accessors = record.variables.filter { ["label", "unsafeValue"].contains($0.name) } + #expect(accessors.count == 3) + #expect(accessors.allSatisfy { $0.functionSignature.isNonisolated }) + #expect(accessors.allSatisfy { !$0.functionSignature.isImplicitlyAsync }) + } + + @Test + func immutableSynchronousWitness() throws { + let accessor = try #require(try record().variables.first { $0.name == "identifier" }) + #expect(accessor.functionSignature.nonisolatedWitnessProtocol?.asNominalTypeDeclaration?.name == "InheritedIdentifier") + #expect(accessor.functionSignature.isNonisolated) + #expect(!accessor.functionSignature.isImplicitlyAsync) + } + + @Test + func otherActorPropertiesRemainIsolated() throws { + let accessors = try record().variables.filter { + ["asynchronous", "throwing", "isolated", "mainActorValue", "unwitnessed", "mutable"].contains($0.name) + } + #expect(accessors.count == 7) + for accessor in accessors { + #expect(accessor.functionSignature.isImplicitlyAsync, "\(accessor.name) must remain isolated") + } + } + + private func record() throws -> ExtractedNominalType { + let result = try analyze(sources: [("/fake/Source.swift", Self.source)], moduleName: "Test") + return try #require(result.extractedTypes.values.first { $0.swiftNominal.name == "Record" }) + } +}