From 07e66d937526eb957ce97cf083732ee61f70d002 Mon Sep 17 00:00:00 2001 From: Himeshi Date: Fri, 23 Oct 2026 15:33:30 +0900 Subject: [PATCH] JNI: preserve synchronous actor property witnesses Honor nonisolated property accessors and expose immutable actor properties through their synchronous protocol getter witnesses. Generate the witness cast so reads remain valid outside the actor's defining module. Add extraction, binding generation, and Java runtime regressions while keeping ordinary actor-isolated properties asynchronous. --- .../MySwiftLibrary/ActorProperties.swift | 29 ++++++ .../example/swift/ActorPropertiesTest.java | 46 ++++++++++ ...ift2JavaGenerator+SwiftThunkPrinting.swift | 7 +- .../SwiftTypes/SwiftFunctionSignature.swift | 89 +++++++++++++++++++ .../Documentation.docc/FeaturesJextract.md | 8 ++ .../JNI/JNIActorPropertyTests.swift | 67 ++++++++++++++ .../ActorPropertyIsolationTests.swift | 72 +++++++++++++++ 7 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/ActorProperties.swift create mode 100644 Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ActorPropertiesTest.java create mode 100644 Tests/JExtractSwiftTests/JNI/JNIActorPropertyTests.swift create mode 100644 Tests/SwiftExtractTests/ActorPropertyIsolationTests.swift 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" }) + } +}