Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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<Long> unwitnessed = actor.getUnwitnessed();
Future<Long> mutable = actor.getMutable();
assertEquals(43, unwitnessed.get());
assertEquals(44, mutable.get());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -682,7 +682,7 @@ extension JNISwift2JavaGenerator {
}

// Callee
let callee: String =
let directCallee: String =
switch decl.functionSignature.selfParameter {
case .instance:
nativeSignature.selfParameter!.conversion.render(
Expand All @@ -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 {
Expand Down
89 changes: 89 additions & 0 deletions Sources/SwiftExtract/SwiftTypes/SwiftFunctionSignature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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<SwiftNominalIdentity>) -> 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<SwiftNominalIdentity>) -> 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<SwiftNominalIdentity> = []
var getterVisited: Set<SwiftNominalIdentity> = []
if isNonisolatedProtocol(type, visited: &isolationVisited), hasGetter(type, visited: &getterVisited) {
return type
}
}
return nil
}

public init(
_ subscriptNode: SubscriptDeclSyntax,
isSet: Bool,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 67 additions & 0 deletions Tests/JExtractSwiftTests/JNI/JNIActorPropertyTests.swift
Original file line number Diff line number Diff line change
@@ -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<java.lang.Long> getUnwitnessed() {",
"public java.util.concurrent.CompletableFuture<java.lang.Long> getMutable() {",
"public java.util.concurrent.CompletableFuture<java.lang.Void> 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",
]
)
}
}
72 changes: 72 additions & 0 deletions Tests/SwiftExtractTests/ActorPropertyIsolationTests.swift
Original file line number Diff line number Diff line change
@@ -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" })
}
}
Loading