From 98b7184143a9b719f11b165846ff06b20ac65650 Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Fri, 11 Oct 2024 11:41:06 -0700 Subject: [PATCH 1/6] Lazily attach threads to the JVM when querying the JNI environment Instead of having the JavaVirtualMachine instance keep track of the JNI environment for the thread in which it was initialized, provide a JNI environment query function that attaches the current thread to the JVM if needed. This lets us query the JNI environment for multiple threads, should we want to. We no longer need to force all of the JavaKit runtime tests to the main actor. --- Sources/Java2Swift/JavaToSwift.swift | 2 +- Sources/JavaKitVM/JavaVirtualMachine.swift | 54 ++++++++++++++++++++-- Tests/JavaKitTests/BasicRuntimeTests.swift | 20 +++++--- 3 files changed, 63 insertions(+), 13 deletions(-) diff --git a/Sources/Java2Swift/JavaToSwift.swift b/Sources/Java2Swift/JavaToSwift.swift index fb24e091..b9df359c 100644 --- a/Sources/Java2Swift/JavaToSwift.swift +++ b/Sources/Java2Swift/JavaToSwift.swift @@ -71,7 +71,7 @@ struct JavaToSwift: ParsableCommand { let jvm = try JavaVirtualMachine(vmOptions: vmOptions) javaVirtualMachine = jvm - try run(environment: jvm.environment) + try run(environment: jvm.environment()) } mutating func run(environment: JNIEnvironment) throws { diff --git a/Sources/JavaKitVM/JavaVirtualMachine.swift b/Sources/JavaKitVM/JavaVirtualMachine.swift index 5fc26940..8985335c 100644 --- a/Sources/JavaKitVM/JavaVirtualMachine.swift +++ b/Sources/JavaKitVM/JavaVirtualMachine.swift @@ -17,12 +17,12 @@ import JavaKit typealias JavaVMPointer = UnsafeMutablePointer public final class JavaVirtualMachine: @unchecked Sendable { + /// The JNI version that we depend on. + static let jniVersion = JNI_VERSION_1_6 + /// The Java virtual machine instance. private let jvm: JavaVMPointer - /// The JNI environment for the JVM. - public let environment: JNIEnvironment - /// Initialize a new Java virtual machine instance. /// /// - Parameters: @@ -41,7 +41,7 @@ public final class JavaVirtualMachine: @unchecked Sendable { var jvm: JavaVMPointer? = nil var environment: UnsafeMutableRawPointer? = nil var vmArgs = JavaVMInitArgs() - vmArgs.version = JNI_VERSION_1_6 + vmArgs.version = JavaVirtualMachine.jniVersion vmArgs.ignoreUnrecognized = jboolean(ignoreUnrecognized ? JNI_TRUE : JNI_FALSE) // Construct the complete list of VM options. @@ -80,7 +80,6 @@ public final class JavaVirtualMachine: @unchecked Sendable { } self.jvm = jvm! - self.environment = environment!.assumingMemoryBound(to: JNIEnv?.self) } deinit { @@ -89,10 +88,55 @@ public final class JavaVirtualMachine: @unchecked Sendable { fatalError("Failed to destroy the JVM.") } } + + /// Produce the JNI environment for the active thread, attaching this + /// thread to the JVM if it isn't already. + /// + /// - Parameter + /// - asDaemon: Whether this thread should be treated as a daemon + /// thread in the Java Virtual Machine. + public func environment(asDaemon: Bool = false) throws -> JNIEnvironment { + // Check whether this thread is already attached. If so, return the + // corresponding environment. + var environment: UnsafeMutableRawPointer? = nil + let getEnvResult = jvm.pointee!.pointee.GetEnv( + jvm, + &environment, + JavaVirtualMachine.jniVersion + ) + if getEnvResult == JNI_OK, let environment { + return environment.assumingMemoryBound(to: JNIEnv?.self) + } + + // Attach the current thread to the JVM. + let attachResult: jint + if asDaemon { + attachResult = jvm.pointee!.pointee.AttachCurrentThreadAsDaemon(jvm, &environment, nil) + } else { + attachResult = jvm.pointee!.pointee.AttachCurrentThread(jvm, &environment, nil) + } + + if attachResult == JNI_OK, let environment { + return environment.assumingMemoryBound(to: JNIEnv?.self) + } + + throw VMError.failedToAttachThread + } + + /// Detach the current thread from the Java Virtual Machine. All Java + /// threads waiting for this thread to die are notified. + public func detachCurrentThread() throws { + let result = jvm.pointee!.pointee.DetachCurrentThread(jvm) + if result != JNI_OK { + throw VMError.failedToDetachThread + } + } } extension JavaVirtualMachine { enum VMError: Error { case failedToCreateVM + case failedToAttachThread + case failedToDetachThread } } diff --git a/Tests/JavaKitTests/BasicRuntimeTests.swift b/Tests/JavaKitTests/BasicRuntimeTests.swift index 33f795b9..55eee2d4 100644 --- a/Tests/JavaKitTests/BasicRuntimeTests.swift +++ b/Tests/JavaKitTests/BasicRuntimeTests.swift @@ -20,16 +20,16 @@ import XCTest // NOTE: Workaround for https://github.com/swiftlang/swift-java/is @MainActor let jvm = try! JavaVirtualMachine(vmOptions: []) -@MainActor class BasicRuntimeTests: XCTestCase { func testJavaObjectManagement() async throws { if isLinux { throw XCTSkip("Attempts to refcount a null pointer on Linux") } + let environment = try await jvm.environment() let sneakyJavaThis: jobject do { - let object = JavaObject(environment: jvm.environment) + let object = JavaObject(environment: environment) XCTAssert(object.toString().starts(with: "java.lang.Object")) // Make sure this object was promoted to a global reference. @@ -41,10 +41,10 @@ class BasicRuntimeTests: XCTestCase { // The reference should now be invalid, because we've deleted the // global reference. - XCTAssertEqual(jvm.environment.pointee?.pointee.GetObjectRefType(jvm.environment, sneakyJavaThis), JNIInvalidRefType) + XCTAssertEqual(environment.pointee?.pointee.GetObjectRefType(environment, sneakyJavaThis), JNIInvalidRefType) // 'super' and 'as' don't require allocating a new holder. - let url = try URL("http://swift.org", environment: jvm.environment) + let url = try URL("http://swift.org", environment: environment) let superURL = url.super XCTAssert(url.javaHolder === superURL.javaHolder) let urlAgain = superURL.as(URL.self)! @@ -56,8 +56,10 @@ class BasicRuntimeTests: XCTestCase { throw XCTSkip("Attempts to refcount a null pointer on Linux") } + let environment = try await jvm.environment() + do { - _ = try URL("bad url", environment: jvm.environment) + _ = try URL("bad url", environment: environment) } catch { XCTAssert(String(describing: error) == "no protocol: bad url") } @@ -68,13 +70,17 @@ class BasicRuntimeTests: XCTestCase { throw XCTSkip("Attempts to refcount a null pointer on Linux") } - let urlConnectionClass = try JavaClass(in: jvm.environment) + let environment = try await jvm.environment() + + let urlConnectionClass = try JavaClass(in: environment) XCTAssert(urlConnectionClass.getDefaultAllowUserInteraction() == false) } func testClassInstanceLookup() async throws { + let environment = try await jvm.environment() + do { - _ = try JavaClass(in: jvm.environment) + _ = try JavaClass(in: environment) } catch { XCTAssertEqual(String(describing: error), "org/swift/javakit/Nonexistent") } From adb1e0009f436963ed12e49c46447e9362f7135c Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Fri, 11 Oct 2024 12:20:16 -0700 Subject: [PATCH 2/6] Introduce support for a shared JavaVirtualMachine instance Java implementations have a longstanding limitation that there may only be one Java Virtual Machine instance per process. Take advantage of this to provide a shared JavaVirtualMachine instance, initialized on first use, that can be accessed from any thread. This eliminates the need to force JavaVirtualMachine creation to be on the main actor, since we can do it from any thread and get the same result. --- Sources/Java2Swift/JavaToSwift.swift | 7 +- Sources/JavaKitVM/JavaVirtualMachine.swift | 101 +++++++++++++++++- .../LockedState.swift | 0 Tests/JavaKitTests/BasicRuntimeTests.swift | 16 +-- 4 files changed, 109 insertions(+), 15 deletions(-) rename Sources/{_Subprocess => JavaKitVM}/LockedState.swift (100%) diff --git a/Sources/Java2Swift/JavaToSwift.swift b/Sources/Java2Swift/JavaToSwift.swift index b9df359c..ad9cf4f3 100644 --- a/Sources/Java2Swift/JavaToSwift.swift +++ b/Sources/Java2Swift/JavaToSwift.swift @@ -22,9 +22,6 @@ import JavaKitVM import SwiftSyntax import SwiftSyntaxBuilder -/// Global instance of the Java virtual machine that we keep alive forever. -var javaVirtualMachine: JavaVirtualMachine? = nil - /// Command-line utility to drive the export of Java classes into Swift types. @main struct JavaToSwift: ParsableCommand { @@ -68,9 +65,7 @@ struct JavaToSwift: ParsableCommand { vmOptions.append(contentsOf: classpath) } - let jvm = try JavaVirtualMachine(vmOptions: vmOptions) - javaVirtualMachine = jvm - + let jvm = try JavaVirtualMachine.shared(vmOptions: vmOptions) try run(environment: jvm.environment()) } diff --git a/Sources/JavaKitVM/JavaVirtualMachine.swift b/Sources/JavaKitVM/JavaVirtualMachine.swift index 8985335c..63c00ad9 100644 --- a/Sources/JavaKitVM/JavaVirtualMachine.swift +++ b/Sources/JavaKitVM/JavaVirtualMachine.swift @@ -23,6 +23,11 @@ public final class JavaVirtualMachine: @unchecked Sendable { /// The Java virtual machine instance. private let jvm: JavaVMPointer + /// Adopt an existing JVM pointer. + private init(adoptingJVM jvm: JavaVMPointer) { + self.jvm = jvm + } + /// Initialize a new Java virtual machine instance. /// /// - Parameters: @@ -33,7 +38,7 @@ public final class JavaVirtualMachine: @unchecked Sendable { /// be prefixed by the class-path argument described above. /// - ignoreUnrecognized: Whether the JVM should ignore any VM options it /// does not recognize. - public init( + private init( classPath: [String] = [], vmOptions: [String] = [], ignoreUnrecognized: Bool = true @@ -74,8 +79,13 @@ public final class JavaVirtualMachine: @unchecked Sendable { vmArgs.options = optionsBuffer.baseAddress vmArgs.nOptions = jint(optionsBuffer.count) - // Create the JVM. - if JNI_CreateJavaVM(&jvm, &environment, &vmArgs) != JNI_OK { + // Create the JVM instance. + let createResult = JNI_CreateJavaVM(&jvm, &environment, &vmArgs) + if createResult != JNI_OK { + if createResult == JNI_EEXIST { + throw VMError.existingVM + } + throw VMError.failedToCreateVM } @@ -88,7 +98,10 @@ public final class JavaVirtualMachine: @unchecked Sendable { fatalError("Failed to destroy the JVM.") } } +} +// MARK: Java thread management. +extension JavaVirtualMachine { /// Produce the JNI environment for the active thread, attaching this /// thread to the JVM if it isn't already. /// @@ -133,10 +146,92 @@ public final class JavaVirtualMachine: @unchecked Sendable { } } +// MARK: Shared Java Virtual Machine management. +extension JavaVirtualMachine { + /// The globally shared JavaVirtualMachine instance, behind a lock. + /// + /// TODO: If the use of the lock itself ends up being slow, we could + /// use an atomic here instead because our access pattern is fairly + /// simple. + private static let sharedJVM: LockedState = .init(initialState: nil) + + /// Access the shared Java Virtual Machine instance. + /// + /// If there is no shared Java Virtual Machine, create one with the given + /// arguments. Note that this function makes no attempt to try to augment + /// an existing virtual machine instance with the options given, so it is + /// up to clients to ensure that consistent arguments are provided to all + /// calls. + /// + /// - Parameters: + /// - classPath: The directories, JAR files, and ZIP files in which the JVM + /// should look to find classes. This maps to the VM option + /// `-Djava.class.path=`. + /// - vmOptions: Options that should be passed along to the JVM, which will + /// be prefixed by the class-path argument described above. + /// - ignoreUnrecognized: Whether the JVM should ignore any VM options it + /// does not recognize. + public static func shared( + classPath: [String] = [], + vmOptions: [String] = [], + ignoreUnrecognized: Bool = true + ) throws -> JavaVirtualMachine { + try sharedJVM.withLock { (sharedJVMPointer: inout JavaVirtualMachine?) in + // If we already have a JavaVirtualMachine instance, return it. + if let existingInstance = sharedJVMPointer { + return existingInstance + } + + while true { + var wasExistingVM: Bool = false + while true { + // Query the JVM itself to determine whether there is a JVM + // instance that we don't yet know about. + var jvm: UnsafeMutablePointer? = nil + var numJVMs: jsize = 0 + if JNI_GetCreatedJavaVMs(&jvm, 1, &numJVMs) == JNI_OK, numJVMs >= 1 { + // Adopt this JVM into a new instance of the JavaVirtualMachine + // wrapper. + let javaVirtualMachine = JavaVirtualMachine(adoptingJVM: jvm!) + sharedJVMPointer = javaVirtualMachine + return javaVirtualMachine + } + + precondition( + !wasExistingVM, + "JVM reports that an instance of the JVM was already created, but we didn't see it." + ) + + // Create a new instance of the JVM. + let javaVirtualMachine: JavaVirtualMachine + do { + javaVirtualMachine = try JavaVirtualMachine( + classPath: classPath, + vmOptions: vmOptions, + ignoreUnrecognized: ignoreUnrecognized + ) + } catch VMError.existingVM { + // We raced with code outside of this JavaVirtualMachine instance + // that created a VM while we were trying to do the same. Go + // through the loop again to pick up the underlying JVM pointer. + wasExistingVM = true + continue + } + + sharedJVMPointer = javaVirtualMachine + return javaVirtualMachine + } + } + } + } +} + extension JavaVirtualMachine { enum VMError: Error { case failedToCreateVM case failedToAttachThread case failedToDetachThread + case failedToQueryVM + case existingVM } } diff --git a/Sources/_Subprocess/LockedState.swift b/Sources/JavaKitVM/LockedState.swift similarity index 100% rename from Sources/_Subprocess/LockedState.swift rename to Sources/JavaKitVM/LockedState.swift diff --git a/Tests/JavaKitTests/BasicRuntimeTests.swift b/Tests/JavaKitTests/BasicRuntimeTests.swift index 55eee2d4..16669470 100644 --- a/Tests/JavaKitTests/BasicRuntimeTests.swift +++ b/Tests/JavaKitTests/BasicRuntimeTests.swift @@ -17,8 +17,12 @@ import JavaKitNetwork import JavaKitVM import XCTest // NOTE: Workaround for https://github.com/swiftlang/swift-java/issues/43 -@MainActor -let jvm = try! JavaVirtualMachine(vmOptions: []) +/// Handy reference to the JVM abstraction. +var jvm: JavaVirtualMachine { + get throws { + try .shared() + } +} class BasicRuntimeTests: XCTestCase { func testJavaObjectManagement() async throws { @@ -26,7 +30,7 @@ class BasicRuntimeTests: XCTestCase { throw XCTSkip("Attempts to refcount a null pointer on Linux") } - let environment = try await jvm.environment() + let environment = try jvm.environment() let sneakyJavaThis: jobject do { let object = JavaObject(environment: environment) @@ -56,7 +60,7 @@ class BasicRuntimeTests: XCTestCase { throw XCTSkip("Attempts to refcount a null pointer on Linux") } - let environment = try await jvm.environment() + let environment = try jvm.environment() do { _ = try URL("bad url", environment: environment) @@ -70,14 +74,14 @@ class BasicRuntimeTests: XCTestCase { throw XCTSkip("Attempts to refcount a null pointer on Linux") } - let environment = try await jvm.environment() + let environment = try jvm.environment() let urlConnectionClass = try JavaClass(in: environment) XCTAssert(urlConnectionClass.getDefaultAllowUserInteraction() == false) } func testClassInstanceLookup() async throws { - let environment = try await jvm.environment() + let environment = try jvm.environment() do { _ = try JavaClass(in: environment) From 7ec7f897b76bfca281fe65238363c8a44b4a22cc Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Fri, 11 Oct 2024 12:27:22 -0700 Subject: [PATCH 3/6] Don't destroy "adopted" JVM instances when JavaVirtualMachine is destroyed --- Sources/JavaKitVM/JavaVirtualMachine.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/Sources/JavaKitVM/JavaVirtualMachine.swift b/Sources/JavaKitVM/JavaVirtualMachine.swift index 63c00ad9..cf1b63ac 100644 --- a/Sources/JavaKitVM/JavaVirtualMachine.swift +++ b/Sources/JavaKitVM/JavaVirtualMachine.swift @@ -23,9 +23,13 @@ public final class JavaVirtualMachine: @unchecked Sendable { /// The Java virtual machine instance. private let jvm: JavaVMPointer + /// Whether to destroy the JVM on deinit. + private let destroyOnDeinit: Bool + /// Adopt an existing JVM pointer. private init(adoptingJVM jvm: JavaVMPointer) { self.jvm = jvm + self.destroyOnDeinit = false } /// Initialize a new Java virtual machine instance. @@ -90,12 +94,15 @@ public final class JavaVirtualMachine: @unchecked Sendable { } self.jvm = jvm! + self.destroyOnDeinit = true } deinit { - // Destroy the JVM. - if jvm.pointee!.pointee.DestroyJavaVM(jvm) != JNI_OK { - fatalError("Failed to destroy the JVM.") + if destroyOnDeinit { + // Destroy the JVM. + if jvm.pointee!.pointee.DestroyJavaVM(jvm) != JNI_OK { + fatalError("Failed to destroy the JVM.") + } } } } From 2fe5c0393ebb21b061baa36c6cee306f25f8fd7f Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Fri, 11 Oct 2024 12:38:58 -0700 Subject: [PATCH 4/6] Map all of the documented JNI errors to cases in the Swift VMError This also cleans up various error-handling code paths that were doing narrow checks. --- Sources/JavaKitVM/JavaVirtualMachine.swift | 58 +++++++++++++++------- 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/Sources/JavaKitVM/JavaVirtualMachine.swift b/Sources/JavaKitVM/JavaVirtualMachine.swift index cf1b63ac..ad16b46e 100644 --- a/Sources/JavaKitVM/JavaVirtualMachine.swift +++ b/Sources/JavaKitVM/JavaVirtualMachine.swift @@ -84,13 +84,8 @@ public final class JavaVirtualMachine: @unchecked Sendable { vmArgs.nOptions = jint(optionsBuffer.count) // Create the JVM instance. - let createResult = JNI_CreateJavaVM(&jvm, &environment, &vmArgs) - if createResult != JNI_OK { - if createResult == JNI_EEXIST { - throw VMError.existingVM - } - - throw VMError.failedToCreateVM + if let createError = VMError(fromJNIError: JNI_CreateJavaVM(&jvm, &environment, &vmArgs)) { + throw createError } self.jvm = jvm! @@ -100,8 +95,8 @@ public final class JavaVirtualMachine: @unchecked Sendable { deinit { if destroyOnDeinit { // Destroy the JVM. - if jvm.pointee!.pointee.DestroyJavaVM(jvm) != JNI_OK { - fatalError("Failed to destroy the JVM.") + if let resultError = VMError(fromJNIError: jvm.pointee!.pointee.DestroyJavaVM(jvm)) { + fatalError("Failed to destroy the JVM: \(resultError)") } } } @@ -136,19 +131,19 @@ extension JavaVirtualMachine { attachResult = jvm.pointee!.pointee.AttachCurrentThread(jvm, &environment, nil) } - if attachResult == JNI_OK, let environment { - return environment.assumingMemoryBound(to: JNIEnv?.self) + // If we failed to attach, report that. + if let attachError = VMError(fromJNIError: attachResult) { + throw attachError } - throw VMError.failedToAttachThread + return environment!.assumingMemoryBound(to: JNIEnv?.self) } /// Detach the current thread from the Java Virtual Machine. All Java /// threads waiting for this thread to die are notified. public func detachCurrentThread() throws { - let result = jvm.pointee!.pointee.DetachCurrentThread(jvm) - if result != JNI_OK { - throw VMError.failedToDetachThread + if let resultError = VMError(fromJNIError: jvm.pointee!.pointee.DetachCurrentThread(jvm)) { + throw resultError } } } @@ -234,11 +229,36 @@ extension JavaVirtualMachine { } extension JavaVirtualMachine { + /// Describes the kinds of errors that can occur when interacting with JNI. enum VMError: Error { - case failedToCreateVM - case failedToAttachThread - case failedToDetachThread - case failedToQueryVM + /// There is already a Java Virtual Machine. case existingVM + + /// JNI version mismatch error. + case jniVersion + + /// Thread is detached from the VM. + case threadDetached + + /// Out of memory. + case outOfMemory + + /// Invalid arguments. + case invalidArguments + + /// Unknown JNI error. + case unknown(jint) + + init?(fromJNIError error: jint) { + switch error { + case JNI_OK: return nil + case JNI_EDETACHED: self = .threadDetached + case JNI_EVERSION: self = .jniVersion + case JNI_ENOMEM: self = .outOfMemory + case JNI_EEXIST: self = .existingVM + case JNI_EINVAL: self = .invalidArguments + default: self = .unknown(error) + } + } } } From 1adeb87bafd7418e40470119c27e30b8d6618af7 Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Fri, 11 Oct 2024 12:43:10 -0700 Subject: [PATCH 5/6] Add an API to forget the shared JavaVirtualMachine instance --- Sources/JavaKitVM/JavaVirtualMachine.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Sources/JavaKitVM/JavaVirtualMachine.swift b/Sources/JavaKitVM/JavaVirtualMachine.swift index ad16b46e..2e95795c 100644 --- a/Sources/JavaKitVM/JavaVirtualMachine.swift +++ b/Sources/JavaKitVM/JavaVirtualMachine.swift @@ -226,6 +226,15 @@ extension JavaVirtualMachine { } } } + + /// "Forget" the shared JavaVirtualMachine instance. + /// + /// This will allow the shared JavaVirtualMachine instance to be deallocated. + public static func forgetShared() { + sharedJVM.withLock { sharedJVMPointer in + sharedJVMPointer = nil + } + } } extension JavaVirtualMachine { From 3ddd2b730fc502a06cacaa68140e05116ccc33fb Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Fri, 11 Oct 2024 12:58:44 -0700 Subject: [PATCH 6/6] License fix --- Sources/JavaKitVM/LockedState.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Sources/JavaKitVM/LockedState.swift b/Sources/JavaKitVM/LockedState.swift index 0cde3b94..e095668c 100644 --- a/Sources/JavaKitVM/LockedState.swift +++ b/Sources/JavaKitVM/LockedState.swift @@ -2,11 +2,13 @@ // // This source file is part of the Swift.org open source project // -// Copyright (c) 2022 Apple Inc. and the Swift project authors -// Licensed under Apache License v2.0 with Runtime Library Exception +// Copyright (c) 2024 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 // -// See https://swift.org/LICENSE.txt for license information -// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 // //===----------------------------------------------------------------------===//