diff --git a/Sources/SwiftJavaJNICore/JNIError.swift b/Sources/SwiftJavaJNICore/JNIError.swift new file mode 100644 index 0000000..9105fcc --- /dev/null +++ b/Sources/SwiftJavaJNICore/JNIError.swift @@ -0,0 +1,25 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 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 +// +//===----------------------------------------------------------------------===// + +/// Errors originating from JNI environment operations. +public enum JNIError: Error { + /// The JVM was unable to allocate memory for a local reference frame. + /// + /// This occurs when `PushLocalFrame` fails, typically because the JVM + /// is running low on memory. The pending Java `OutOfMemoryError` is + /// cleared before this error is thrown. + /// + /// - Parameter framePushCapacity: The requested frame capacity that failed. + case outOfMemory(framePushCapacity: Int) +} diff --git a/Sources/SwiftJavaJNICore/JavaEnvironment+Refs.swift b/Sources/SwiftJavaJNICore/JavaEnvironment+Refs.swift new file mode 100644 index 0000000..bd9494a --- /dev/null +++ b/Sources/SwiftJavaJNICore/JavaEnvironment+Refs.swift @@ -0,0 +1,144 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 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 +// +//===----------------------------------------------------------------------===// + +#if canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#elseif canImport(Bionic) +import Bionic +#elseif canImport(Darwin) +import Darwin +#elseif os(Windows) +import ucrt +#endif + +// ==== ------------------------------------------------------------------- +// MARK: Local Frame Helpers + +// Local references are valid for the duration of a native method call. They are +// freed automatically after the native method returns. Each local reference +// costs some amount of Java Virtual Machine resource. Programmers need to make +// sure that native methods do not excessively allocate local references. +// Although local references are automatically freed after the native method +// returns to Java, excessive allocation of local references may cause the VM to +// run out of memory during the execution of a native method. +// +// See: https://docs.oracle.com/en/java/javase/21/docs/specs/jni/functions.html#local-references + +/// Whether to print JNI `OutOfMemoryError` stack traces to stderr. +/// +/// Checked once on first OOM and cached. Set the environment variable +/// `SWIFT_JAVA_JNI_EXCEPTION_DESCRIBE_OOM` to `true` or `1` to enable. +private let describeOOMException: Bool = { + guard let value = getenv("SWIFT_JAVA_JNI_EXCEPTION_DESCRIBE_OOM") else { + return false + } + let str = String(cString: value).lowercased() + return str == "1" || str == "true" || str == "yes" +}() + +extension UnsafeMutablePointer { + + /// Handle a `PushLocalFrame` failure by optionally describing the pending + /// exception to stderr, clearing it, and throwing a Swift error. + /// + /// Must be called while the `OutOfMemoryError` is still pending (i.e. + /// before `ExceptionClear`). `ExceptionDescribe` is safe to call with a + /// pending exception — it prints the stack trace to stderr and does **not** + /// clear the exception. + @inline(__always) + internal func throwPushLocalFrameOOM(capacity: Int) throws -> Never { + if describeOOMException { + // Print the pending OutOfMemoryError stack trace to stderr. + // ExceptionDescribe does not clear the exception. + self.interface.ExceptionDescribe(self) + } + self.interface.ExceptionClear(self) + throw JNIError.outOfMemory(framePushCapacity: capacity) + } + + /// Execute `body` inside a JNI local reference frame. + /// + /// All local references created inside `body` are freed when it returns. + /// This prevents local reference table overflow when making many JNI calls + /// (e.g., in loops or from non-JVM threads like Swift's cooperative pool). + /// + /// - Parameter capacity: Hint for how many local refs will be created. + /// The JVM may allocate more if needed. Must be > 0. + /// - Parameter body: The closure to execute inside the local frame. + /// - Returns: The value returned by `body`. + /// - Throws: ``JNIError/outOfMemory`` if `PushLocalFrame` fails, or + /// rethrows any error thrown by `body`. + /// + /// ## See Also + /// - [JNI PushLocalFrame](https://docs.oracle.com/javase/8/docs/technotes/guides/jni/spec/functions.html#PushLocalFrame) + /// - [JNI PopLocalFrame](https://docs.oracle.com/javase/8/docs/technotes/guides/jni/spec/functions.html#PopLocalFrame) + @inline(__always) + public func withLocalFrame(capacity: Int = 16, _ body: () throws -> R) throws -> R { + let pushed = self.interface.PushLocalFrame(self, Int32(capacity)) + if pushed != JNI_OK { + try self.throwPushLocalFrameOOM(capacity: capacity) + } + defer { _ = self.interface.PopLocalFrame(self, nil) } + return try body() + } + + /// Execute `body` inside a JNI local reference frame, promoting one result + /// object to the outer frame. + /// + /// All local references created inside `body` are freed, **except** for the + /// returned `jobject` which is promoted to the enclosing frame via + /// `PopLocalFrame(env, result)`. + /// + /// Use this when constructing a new Java object inside a frame that needs + /// to survive after the frame is popped. + /// + /// - Parameter capacity: Hint for how many local refs will be created. + /// - Parameter body: Closure that returns the `jobject` to promote. + /// - Returns: A new local reference in the outer frame to the same object. + /// - Throws: ``JNIError/outOfMemory`` if `PushLocalFrame` fails, or + /// rethrows any error thrown by `body`. + /// + /// ## See Also + /// - [JNI PushLocalFrame](https://docs.oracle.com/javase/8/docs/technotes/guides/jni/spec/functions.html#PushLocalFrame) + /// - [JNI PopLocalFrame](https://docs.oracle.com/javase/8/docs/technotes/guides/jni/spec/functions.html#PopLocalFrame) + @inline(__always) + public func withLocalFramePromotingResult(capacity: Int = 16, _ body: () throws -> jobject?) throws -> jobject? { + let pushed = self.interface.PushLocalFrame(self, Int32(capacity)) + if pushed != JNI_OK { + try self.throwPushLocalFrameOOM(capacity: capacity) + } + do { + let result = try body() + return self.interface.PopLocalFrame(self, result) + } catch { + // Pop the frame (freeing all inner refs) before rethrowing. + _ = self.interface.PopLocalFrame(self, nil) + throw error + } + } + + /// Delete a local reference. + /// + /// Shorthand for `interface.DeleteLocalRef(self, ref)`. Safe to call with + /// `nil` (no-op). + /// + /// ## See Also + /// - [JNI DeleteLocalRef](https://docs.oracle.com/javase/8/docs/technotes/guides/jni/spec/functions.html#DeleteLocalRef) + @inline(__always) + public func deleteLocalRef(_ ref: jobject?) { + self.interface.DeleteLocalRef(self, ref) + } +} diff --git a/Tests/SwiftJavaJNICoreTests/JavaEnvironmentTests.swift b/Tests/SwiftJavaJNICoreTests/JavaEnvironmentTests.swift new file mode 100644 index 0000000..2c62119 --- /dev/null +++ b/Tests/SwiftJavaJNICoreTests/JavaEnvironmentTests.swift @@ -0,0 +1,132 @@ +//===----------------------------------------------------------------------===// +// +// 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 Testing + +@testable import SwiftJavaJNICore + +#if canImport(FoundationEssentials) +import class FoundationEssentials.ProcessInfo +#else +import class Foundation.ProcessInfo +#endif + +@Suite +struct JavaEnvironmentTests { + + static var isSupportedPlatform: Bool { + #if os(Android) + let testSentinel = "0" + #else + let testSentinel = "1" + #endif + return (ProcessInfo.processInfo.environment["SWIFT_JAVA_JNI_TEST_JVM"] ?? testSentinel) != "0" + } + + @Test(.enabled(if: isSupportedPlatform)) + func withLocalFrame_returnsBodyValue() throws { + let env = try JavaVirtualMachine.shared().environment() + + let result = try env.withLocalFrame(capacity: 4) { + 42 + } + #expect(result == 42) + } + + @Test(.enabled(if: isSupportedPlatform)) + func withLocalFrame_defaultCapacity() throws { + let env = try JavaVirtualMachine.shared().environment() + + let result = try env.withLocalFrame { + "hello" + } + #expect(result == "hello") + } + + @Test(.enabled(if: isSupportedPlatform)) + func withLocalFrame_rethrowsErrors() throws { + let env = try JavaVirtualMachine.shared().environment() + + struct TestError: Error {} + + #expect(throws: TestError.self) { + try env.withLocalFrame { + throw TestError() + } + } + } + + @Test(.enabled(if: isSupportedPlatform)) + func withLocalFrame_localRefsWorkInsideFrame() throws { + let env = try JavaVirtualMachine.shared().environment() + + try env.withLocalFrame(capacity: 8) { + // Create a local ref inside the frame — it should be valid here + let cls = env.interface.FindClass(env, "java/lang/String") + #expect(cls != nil, "Should be able to find java.lang.String inside frame") + } + } + + @Test(.enabled(if: isSupportedPlatform)) + func withLocalFramePromotingResult_promotesObject() throws { + let env = try JavaVirtualMachine.shared().environment() + + let promoted = try env.withLocalFramePromotingResult(capacity: 8) { () -> jobject? in + // Create a Java String inside the frame + let str = env.interface.NewStringUTF(env, "test") + return str + } + + // The promoted reference should still be valid in the outer frame + #expect(promoted != nil, "Promoted reference should not be nil") + + // Verify it's a valid object by getting its class + let cls = env.interface.GetObjectClass(env, promoted) + #expect(cls != nil, "Promoted object should have a valid class") + + env.deleteLocalRef(promoted) + env.deleteLocalRef(cls) + } + + @Test(.enabled(if: isSupportedPlatform)) + func withLocalFramePromotingResult_nilResult() throws { + let env = try JavaVirtualMachine.shared().environment() + + let result = try env.withLocalFramePromotingResult { + nil + } + #expect(result == nil) + } + + @Test(.enabled(if: isSupportedPlatform)) + func withLocalFramePromotingResult_rethrowsErrors() throws { + let env = try JavaVirtualMachine.shared().environment() + + struct TestError: Error {} + + #expect(throws: TestError.self) { + try env.withLocalFramePromotingResult { + throw TestError() + } + } + } + + @Test(.enabled(if: isSupportedPlatform)) + func deleteLocalRef_nilIsSafe() throws { + let env = try JavaVirtualMachine.shared().environment() + + // Should not crash + env.deleteLocalRef(nil) + } +}