diff --git a/swift/toolchains/xcode_swift_toolchain.bzl b/swift/toolchains/xcode_swift_toolchain.bzl index 39e450ed3..80896f342 100644 --- a/swift/toolchains/xcode_swift_toolchain.bzl +++ b/swift/toolchains/xcode_swift_toolchain.bzl @@ -235,7 +235,11 @@ def _swift_linkopts_cc_info( ), ) -def _test_linking_context(apple_toolchain, target_triple, toolchain_label): +def _test_linking_context( + apple_toolchain, + target_triple, + toolchain_label, + xcode_config): """Returns a `CcLinkingContext` containing linker flags for test binaries. Args: @@ -243,6 +247,7 @@ def _test_linking_context(apple_toolchain, target_triple, toolchain_label): target_triple: The target triple `struct`. toolchain_label: The label of the Swift toolchain that will act as the owner of the linker input propagating the flags. + xcode_config: The Xcode configuration. Returns: A `CcLinkingContext` that will provide linker flags to `swift_test` @@ -261,6 +266,8 @@ def _test_linking_context(apple_toolchain, target_triple, toolchain_label): "-Wl,-weak_framework,XCTest", "-Wl,-weak-lXCTestSwiftSupport", ] + if _is_xcode_at_least_version(xcode_config, "16.0"): + linkopts.append("-Wl,-weak_framework,Testing") if platform_developer_framework_dir: linkopts.extend([ @@ -616,6 +623,7 @@ def _xcode_swift_toolchain_impl(ctx): apple_toolchain = apple_toolchain, target_triple = target_triple, toolchain_label = ctx.label, + xcode_config = xcode_config, ) # `--define=SWIFT_USE_TOOLCHAIN_ROOT=` is a rapid development feature diff --git a/tools/test_discoverer/TestDiscoverer.swift b/tools/test_discoverer/TestDiscoverer.swift index 0edc3f687..dfe987145 100644 --- a/tools/test_discoverer/TestDiscoverer.swift +++ b/tools/test_discoverer/TestDiscoverer.swift @@ -103,6 +103,10 @@ struct TestDiscoverer: ParsableCommand { } } + // These shenanigans are necessary because `XCTestSuite.default.run()` doesn't like to be called + // from an `async main()` (it crashes in the runtime's stack allocator if it tries to run an + // async test method), but we have to do async work to run swift-testing tests. See + // https://forums.swift.org/t/74010 for additional context. var contents = """ import BazelTestObservation import Foundation @@ -112,17 +116,34 @@ struct TestDiscoverer: ParsableCommand { @main struct Main { static func main() { + do { + try loadTestingLibraries() + } catch { + print("Fatal error loading runtime libraries: \\(error)") + exit(1) + } do { try XCTestRunner.run(__allDiscoveredXCTests) - - try XUnitTestRecorder.shared.writeXML() - guard !XUnitTestRecorder.shared.hasFailure else { - exit(1) - } } catch { - print("Test runner failed with \\(error)") + print("Fatal error running XCTest tests: \\(error)") exit(1) } + Task { + do { + try await SwiftTestingRunner.run() + } catch { + print("Fatal error running swift-testing tests: \\(error)") + exit(1) + } + do { + try XUnitTestRecorder.shared.writeXML() + } catch { + print("Fatal error writing test results to XML: \\(error)") + exit(1) + } + exit(XUnitTestRecorder.shared.hasFailure ? 1 : 0) + } + _asyncMainDrainQueue() } } diff --git a/tools/test_observer/BUILD b/tools/test_observer/BUILD index fa4b9d3aa..8e0d73a83 100644 --- a/tools/test_observer/BUILD +++ b/tools/test_observer/BUILD @@ -8,11 +8,14 @@ swift_library( testonly = 1, srcs = [ "BazelXMLTestObserver.swift", + "JSON.swift", "LinuxXCTestRunner.swift", "Locked.swift", "ObjectiveCXCTestRunner.swift", + "RuntimeLibraries.swift", "ShardingFilteringTestCollector.swift", "StringInterpolation+XMLEscaping.swift", + "SwiftTestingRunner.swift", "XUnitTestRecorder.swift", ], module_name = "BazelTestObservation", diff --git a/tools/test_observer/JSON.swift b/tools/test_observer/JSON.swift new file mode 100644 index 000000000..d2fc5f6cb --- /dev/null +++ b/tools/test_observer/JSON.swift @@ -0,0 +1,211 @@ +// Copyright 2024 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +/// A lightweight `Codable` JSON type. +public enum JSON: Sendable { + case null + case bool(Bool) + case number(Number) + case string(String) + case array([JSON]) + case object([String: JSON]) + + public static func number(_ value: Int) -> JSON { + .number(Number(value)) + } + + public static func number(_ value: Double) -> JSON { + .number(Number(value)) + } +} + +/// A wrapper around `NSNumber` that is `Sendable` and simplifies other interactions. +/// +/// The only way to represent 64-bit integers without loss of precision in Foundation's JSON +/// `Codable` implementations is to use `NSNumber` as the encoded type. +public struct Number: @unchecked Sendable { + /// The underlying `NSNumber` that wraps the numeric value. + private let value: NSNumber + + /// The `Int` value of the receiver. + public var intValue: Int { + return value.intValue + } + + /// The `Double` value of the receiver. + public var doubleValue: Double { + return value.doubleValue + } + + /// Creates a new `Number` from the given integer. + public init(_ value: Int) { + self.value = NSNumber(value: value) + } + + /// Creates a new `Number` from the given floating-point value. + public init(_ value: Double) { + self.value = NSNumber(value: value) + } +} + +extension JSON { + /// Creates a new JSON value by decoding the given UTF-8-encoded JSON string represented as + /// `Data`. + public init(byDecoding data: Data) throws { + self = try JSONDecoder().decode(JSON.self, from: data) + } + + /// A `Data` representing the UTF-8-encoded JSON string value of the receiver. + public var encodedData: Data { + get throws { + return try JSONEncoder().encode(self) + } + } +} + +extension JSON: ExpressibleByNilLiteral { + public init(nilLiteral: ()) { + self = .null + } +} + +extension JSON: ExpressibleByBooleanLiteral { + public init(booleanLiteral value: Bool) { + self = .bool(value) + } +} + +extension JSON: ExpressibleByFloatLiteral { + public init(floatLiteral value: Double) { + self = .number(value) + } +} + +extension JSON: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self = .number(value) + } +} + +extension JSON: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = .string(value) + } +} + +extension JSON: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: JSON...) { + self = .array(elements) + } +} + +extension JSON: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (String, JSON)...) { + self = .object(.init(uniqueKeysWithValues: elements)) + } +} + +extension JSON: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + guard !container.decodeNil() else { + self = .null + return + } + + if let bool = try container.decode(ifValueIs: Bool.self) { + self = .bool(bool) + } else if let number = try container.decode(ifValueIs: Double.self) { + // In what appears to be a bug in Foundation, perfectly legitimate floating point values + // (e.g., 348956.52160425) are failing to decode through `NSNumber`. But we have to use + // `NSNumber` to handle 64-bit integers, like the `Int.min` that swift-testing requires for + // the verbosity level to avoid printing anything to stdout when listing tests. To deal with + // both cases, we try to decode as a `Double` first, and if that fails, we try to decode as an + // `NSNumber`. + self = .number(Number(number)) + } else if let number = try container.decode(ifValueIs: Number.self) { + self = .number(number) + } else if let string = try container.decode(ifValueIs: String.self) { + self = .string(string) + } else if let array = try container.decode(ifValueIs: [JSON].self) { + self = .array(array) + } else if let object = try container.decode(ifValueIs: [String: JSON].self) { + self = .object(object) + } else { + throw DecodingError.typeMismatch( + JSON.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "unable to decode as a supported JSON type")) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: + try container.encodeNil() + case .bool(let bool): + try container.encode(bool) + case .number(let number): + try container.encode(number) + case .string(let string): + try container.encode(string) + case .array(let array): + try container.encode(array) + case .object(let object): + try container.encode(object) + } + } +} + +extension Number: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let int = try container.decode(ifValueIs: Int.self) { + self = .init(int) + } else if let double = try container.decode(ifValueIs: Double.self) { + self = .init(double) + } else { + throw DecodingError.typeMismatch( + JSON.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "unable to decode as a supported number type")) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + if value.objCType.pointee == UInt8(ascii: "d") { + try container.encode(value.doubleValue) + } else { + try container.encode(value.intValue) + } + } +} + +extension SingleValueDecodingContainer { + /// Decodes a value of the given type if the value in the container is of the same type, or + /// returns nil if the value is of a different type. + fileprivate func decode(ifValueIs type: T.Type) throws -> T? { + do { + return try self.decode(type) + } catch DecodingError.typeMismatch { + return nil + } + } +} diff --git a/tools/test_observer/LinuxXCTestRunner.swift b/tools/test_observer/LinuxXCTestRunner.swift index 61bad52d1..0ffd9a5b1 100644 --- a/tools/test_observer/LinuxXCTestRunner.swift +++ b/tools/test_observer/LinuxXCTestRunner.swift @@ -16,22 +16,12 @@ import Foundation import XCTest - @available( - *, deprecated, - message: """ - Not actually deprecated. Marked as deprecated to allow inclusion of deprecated tests (which \ - test deprecated functionality) without warnings. - """ - ) public typealias XCTestRunner = LinuxXCTestRunner - @available( - *, deprecated, - message: """ - Not actually deprecated. Marked as deprecated to allow inclusion of deprecated tests (which \ - test deprecated functionality) without warnings. - """ - ) + /// A test runner for tests that use the XCTest framework on Linux. + /// + /// This test runner uses test case entries that were constructed by scanning the symbol graph + /// output of the compiler. @MainActor public enum LinuxXCTestRunner { /// A wrapper around a single test from an `XCTestCaseEntry` used by the test collector. diff --git a/tools/test_observer/ObjectiveCXCTestRunner.swift b/tools/test_observer/ObjectiveCXCTestRunner.swift index 16b3a130d..670a5340a 100644 --- a/tools/test_observer/ObjectiveCXCTestRunner.swift +++ b/tools/test_observer/ObjectiveCXCTestRunner.swift @@ -16,22 +16,13 @@ import Foundation import XCTest - @available(*, deprecated, message: """ - Not actually deprecated. Marked as deprecated to allow inclusion of deprecated tests (which \ - test deprecated functionality) without warnings. - """) public typealias XCTestRunner = ObjectiveCXCTestRunner - @available(*, deprecated, message: """ - Not actually deprecated. Marked as deprecated to allow inclusion of deprecated tests (which \ - test deprecated functionality) without warnings. - """) + /// A test runner for tests that use the XCTest framework on Apple platforms. + /// + /// This test runner uses the Objective-C runtime to discover the tests to run. @MainActor public enum ObjectiveCXCTestRunner { - struct Error: Swift.Error, CustomStringConvertible { - let description: String - } - /// A wrapper around an `XCTestCase` used by the test collector. struct Test: Testable { /// The underlying `XCTestCase` that this wrapper represents. @@ -49,44 +40,16 @@ guard let spaceIndex = trimmedName.lastIndex(of: " ") else { return String(trimmedName) } - return "\(trimmedName[.. XCTestSuite { diff --git a/tools/test_observer/RuntimeLibraries.swift b/tools/test_observer/RuntimeLibraries.swift new file mode 100644 index 000000000..b771595d4 --- /dev/null +++ b/tools/test_observer/RuntimeLibraries.swift @@ -0,0 +1,85 @@ +// Copyright 2024 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +/// Dynamically load the testing libraries needed by the test observer. +/// +/// On Apple platforms, we weakly link against XCTest.framework, the XCTest Swift support dylib, and +/// Testing.framework because the machine that links the test binary might not be the same that runs +/// it, and they might have Xcode installed at different paths. To handle this, we find the path +/// that Bazel says Xcode they're installed at on the machine where the test is running and load +/// them dynamically. +@MainActor +public func loadTestingLibraries() throws { + #if os(Linux) + // Nothing to do here. All dependencies, including testing frameworks, are statically linked. + #else + guard let sdkRoot = ProcessInfo.processInfo.environment["SDKROOT"] else { + throw LibraryLoadError("ERROR: Bazel must set the SDKROOT in order to find XCTest") + } + let sdkRootURL = URL(fileURLWithPath: sdkRoot) + let platformDeveloperPath = + sdkRootURL // .../Developer/SDKs/MacOSX.sdk + .deletingLastPathComponent() // .../Developer/SDKs + .deletingLastPathComponent() // .../Developer + + let xcTestPath = + platformDeveloperPath + .appendingPathComponent("Library/Frameworks/XCTest.framework/XCTest") + .path + guard dlopen(xcTestPath, RTLD_NOW) != nil else { + throw LibraryLoadError( + #""" + ERROR: dlopen("\#(xcTestPath)") failed: \#(String(cString: dlerror())) + """#) + } + + // In versions of Xcode that have Testing.framework (Xcode 16 and above), + // libXCTestSwiftSupport.dylib links to it so we need to load the former first. We allow this to + // fail silently, however, to maintain compatibility with older versions of Xcode (where it + // doesn't exist, and thus the support library doesn't use it). + let testingPath = + platformDeveloperPath + .appendingPathComponent("Library/Frameworks/Testing.framework/Testing") + .path + _ = dlopen(testingPath, RTLD_NOW) + + let xcTestSwiftSupportPath = + platformDeveloperPath + .appendingPathComponent("usr/lib/libXCTestSwiftSupport.dylib") + .path + guard dlopen(xcTestSwiftSupportPath, RTLD_NOW) != nil else { + throw LibraryLoadError( + #""" + ERROR: dlopen("\#(xcTestSwiftSupportPath)") failed: \#(String(cString: dlerror())) + """#) + } + #endif +} + +/// An error that is thrown when a runtime library cannot be loaded. +struct LibraryLoadError: Swift.Error, CustomStringConvertible { + let description: String + + init(_ description: String) { + self.description = description + } +} + +/// We call this from the generated `main` so that we can declare it non-async (to make XCTest +/// happy) but then safely wait for an async task (swift-testing) to complete. This is part of the +/// concurrency ABI, so it can't realistically change much in the future. +@_silgen_name("swift_task_asyncMainDrainQueue") +public func _asyncMainDrainQueue() -> Swift.Never diff --git a/tools/test_observer/SwiftTestingRunner.swift b/tools/test_observer/SwiftTestingRunner.swift new file mode 100644 index 000000000..04d69c306 --- /dev/null +++ b/tools/test_observer/SwiftTestingRunner.swift @@ -0,0 +1,312 @@ +// Copyright 2024 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#else + #error("Unsupported platform") +#endif + +/// A test runner that runs tests discovered by the swift-testing framework. +public final class SwiftTestingRunner: Sendable { + /// A wrapper around a swift-testing test identifier used by the test collector. + private struct Test: Testable { + /// The identifier of the test. + let testIdentifier: String + } + + /// A test or suite discovered by the swift-testing framework. + private enum TestOrSuite { + case suite(String) + case test(Test) + } + + /// Discovers and runs the tests. + public static func run() async throws { + guard let entryPoint = SwiftTestingEntryPoint() else { + // The entry point wasn't found, meaning swift-testing wasn't linked in and there are + // no tests to run. This is not an error. + return + } + try await SwiftTestingRunner(entryPoint: entryPoint).run() + } + + /// The JSON ABI entry point of the swift-testing framework. + private let entryPoint: SwiftTestingEntryPoint + + /// A set consisting only of the test suites that are discovered. + private let discoveredSuites: Locked> = .init([]) + + /// Creates a new runner that will use the given entry point to communicate with the + /// swift-testing framework. + private init(entryPoint: SwiftTestingEntryPoint) { + self.entryPoint = entryPoint + } + + /// Discovers and runs the tests. + private func run() async throws { + var collector = try ShardingFilteringTestCollector() + let selectedTests: [Test]? + + // We have to do this even when we're not sharding to filtering because we need to know which of + // the tests are actually suites (so that we don't include them in our xUnit results). + for try await testOrSuite in try await listTests() { + switch testOrSuite { + case .suite(let suiteID): + discoveredSuites.withLock { $0.insert(suiteID) } + case .test(let test): + collector.addTest(test) + } + } + if collector.willShardOrFilter { + selectedTests = collector.testsInCurrentShard + } else { + selectedTests = nil + } + + // Run the tests in the current shard. + try await runTests(selectedTests: selectedTests) + } + + /// Returns an async stream of values representing the tests and suites discovered in the binary + /// by the swift-testing framework. + private func listTests() async throws -> AsyncThrowingStream { + let listTestsConfiguration: JSON = [ + "listTests": true, + "verbosity": .number(Int.min), // Don't print anything to stdout. + ] + + // All of this could really just be a simple `.compactMap` on the stream and we'd return an + // opaque type, but primary associated types on `AsyncSequence` aren't usable before the runtime + // included with macOS 15.0. See SE-0421 for details + // (https://github.com/swiftlang/swift-evolution/blob/main/proposals/0421-generalize-async-sequence.md). + var escapedContinuation: AsyncThrowingStream.Continuation? = nil + let stream = AsyncThrowingStream(TestOrSuite.self, bufferingPolicy: .unbounded) { + continuation in escapedContinuation = continuation + } + guard let escapedContinuation else { + preconditionFailure("Stream continuation was never set") + } + do { + for try await recordJSON in try await entryPoint(configuration: listTestsConfiguration) { + guard + case .object(let record) = recordJSON, + case .object(let payload) = record["payload"], + case .string(let id) = payload["id"] + else { + continue + } + switch payload["kind"] { + case .string("function"): + escapedContinuation.yield(.test(Test(testIdentifier: id))) + case .string("suite"): + escapedContinuation.yield(.suite(id)) + default: + continue + } + } + escapedContinuation.finish() + } catch { + escapedContinuation.finish(throwing: error) + } + return stream + } + + /// Runs the given list of tests (or all tests). + /// + /// - Parameter selectedTests: If this parameter is not nil, only the given list of tests will be + /// run by passing them as filters to the entry point. If nil, all tests will be run. + private func runTests(selectedTests: [Test]?) async throws { + var runTestsConfiguration: [String: JSON] = [:] + if let selectedTests { + runTestsConfiguration["filter"] = .array( + selectedTests.map { + JSON.string(NSRegularExpression.escapedPattern(for: $0.testIdentifier)) + }) + } + for try await recordJSON in try await entryPoint(configuration: .object(runTestsConfiguration)) + { + guard case .object(let record) = recordJSON, + case .string("event") = record["kind"], + case .object(let payload) = record["payload"] + else { + continue + } + recordEvent(payload) + } + } + + private func recordEvent(_ payload: [String: JSON]) { + // We only care about test events that have a test ID and an instant (when they occurred). + guard + case .string(let kind) = payload["kind"], + case .string(let testID) = payload["testID"], + // Ignore suites. The xUnit recorder reconstructs the hierarchy. + !discoveredSuites.withLock { $0.contains(testID) }, + case .object(let instantJSON) = payload["instant"], + case .number(let absolute) = instantJSON["absolute"] + else { + return + } + let instant = EncodedInstant(seconds: absolute.doubleValue) + let nameComponents = nameComponents(for: testID) + + switch kind { + case "testStarted": + XUnitTestRecorder.shared.recordTestStarted(nameComponents: nameComponents, time: instant) + + case "testEnded": + XUnitTestRecorder.shared.recordTestEnded(nameComponents: nameComponents, time: instant) + + case "issueRecorded": + guard + case .array(let messages) = payload["messages"], + // Don't record known issues. + case .object(let issue) = payload["issue"], + case .bool(false) = issue["isKnown"] + else { + return + } + // The issue may have multiple messages, some of which are extra details. Pick out the main + // message for the failure. + // TODO: b/301468828 - Handle the extra detail messages as well. + for case .object(let message) in messages { + guard + case .string("fail") = message["symbol"], + case .string(let text) = message["text"] + else { + return + } + XUnitTestRecorder.shared.recordTestIssue( + nameComponents: nameComponents, + issue: RecordedIssue(kind: .failure, reason: text)) + } + + default: + break + } + } + + /// Returns a list of name components by parsing the given test identifier. + private func nameComponents(for testID: String) -> [String] { + let components = testID.split(separator: "/") + // Some test IDs end with the source location of the test, which is not typically useful to show + // as part of the hierarchy. + if let last = components.last, last.firstMatch(of: /\.swift:\d+:\d+/) != nil { + return components[..<(components.count - 1)].map(String.init) + } + return components.map(String.init) + } +} + +/// Represents an instant in time that is encoded as part of a test event. +/// +/// The instant is encoded as a double representing the number of seconds retrieved from +/// `SuspendingClock` at the time the event occurred. We can't reconstitute that value back into a +/// `SuspendingClock.Instant`, but they're all relative to each other so we can provide our own +/// `InstantProtocol` implementation that is used to compute the duration between two events. +private struct EncodedInstant: Comparable, InstantProtocol { + /// The number of seconds since the test clock's basis. + var seconds: Double + + static func < (lhs: EncodedInstant, rhs: EncodedInstant) -> Bool { + return lhs.seconds < rhs.seconds + } + + func advanced(by duration: Swift.Duration) -> EncodedInstant { + let components = duration.components + return EncodedInstant( + seconds: seconds + Double(components.seconds) + Double(components.attoseconds) / 1e18) + } + + func duration(to other: EncodedInstant) -> Swift.Duration { + return .seconds(other.seconds - self.seconds) + } +} + +/// Represents the entry point of the swift-testing framework and handles the translation of +/// requests and responses between structured JSON and raw byte buffers. +private struct SwiftTestingEntryPoint { + private typealias ABIv0EntryPoint = @convention(thin) @Sendable ( + _ configurationJSON: UnsafeRawBufferPointer?, + _ recordHandler: @escaping @Sendable (_ recordJSON: UnsafeRawBufferPointer) -> Void + ) async throws -> Bool + + private let entryPoint: ABIv0EntryPoint + + /// Creates the entry point by looking it up by name in the current process, or fails if the + /// entry point is not found. + init?() { + guard let entryPointRaw = dlsym(rtldDefault, "swt_abiv0_getEntryPoint") else { + return nil + } + let abiv0_getEntryPoint = unsafeBitCast( + entryPointRaw, to: (@convention(c) () -> UnsafeRawPointer).self) + self.entryPoint = unsafeBitCast(abiv0_getEntryPoint(), to: ABIv0EntryPoint.self) + } + + /// Calls the entry point with the given configuration JSON and returns an asynchronous stream of + /// the JSON records that the framework produces as a response. + func callAsFunction( + configuration configurationJSON: JSON + ) async throws -> AsyncThrowingStream { + // Since `withUnsafeBytes` is not `async`, we have to copy the data out into a separate + // buffer and then invoke the entry point. + let configurationJSONBytes = try configurationJSON.encodedData.withUnsafeBytes { bytes in + let result = UnsafeMutableRawBufferPointer.allocate(byteCount: bytes.count, alignment: 1) + result.copyMemory(from: bytes) + return result + } + defer { configurationJSONBytes.deallocate() } + + // `Async(Throwing)Stream` is explicitly designed to allow its continuation to escape. We have + // to do this since the entry point function that we're calling is `async`, but the closure + // passed to the stream's initializer is not allowed to be `async`. + var escapedContinuation: AsyncThrowingStream.Continuation? = nil + let stream = AsyncThrowingStream(JSON.self, bufferingPolicy: .unbounded) { continuation in + escapedContinuation = continuation + } + guard let escapedContinuation else { + preconditionFailure("Stream continuation was never set") + } + do { + _ = try await self.entryPoint(UnsafeRawBufferPointer(configurationJSONBytes)) { + recordJSONBytes in + let data = Data(bytes: recordJSONBytes.baseAddress!, count: recordJSONBytes.count) + do { + let json = try JSON(byDecoding: data) + escapedContinuation.yield(json) + } catch { + escapedContinuation.finish(throwing: error) + } + } + escapedContinuation.finish() + } catch { + escapedContinuation.finish(throwing: error) + } + return stream + } +} + +// `RTLD_DEFAULT` is only defined on Linux when `_GNU_SOURCE` is defined. Just redefine it +// here for convenience. +#if os(Linux) + private nonisolated(unsafe) let rtldDefault = UnsafeMutableRawPointer(bitPattern: 0) +#else + private nonisolated(unsafe) let rtldDefault = UnsafeMutableRawPointer(bitPattern: -2) +#endif