diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index defd92a9bda..b0143c6da10 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -69,7 +69,7 @@ jobs: # Workaround https://github.com/nektos/act/issues/1875 uses: apple/swift-nio/.github/workflows/wasm_swift_sdk.yml@main with: - additional_command_arguments: "--target NIOCore" + additional_command_arguments: "--target _NIOWASIPlatformCompilationChecks" android-sdk: name: Android Swift SDK diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 4aae86aa173..fe298c934a0 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -109,7 +109,7 @@ jobs: # Workaround https://github.com/nektos/act/issues/1875 uses: apple/swift-nio/.github/workflows/wasm_swift_sdk.yml@main with: - additional_command_arguments: "--target NIOCore" + additional_command_arguments: "--target _NIOWASIPlatformCompilationChecks" android-sdk: name: Android Swift SDK diff --git a/.github/workflows/swift_test_matrix.yml b/.github/workflows/swift_test_matrix.yml index cf60dee1f94..6a04aee6d10 100644 --- a/.github/workflows/swift_test_matrix.yml +++ b/.github/workflows/swift_test_matrix.yml @@ -97,6 +97,13 @@ jobs: done < <(echo "$MATRIX_DOCKER_CAPABILITIES" | jq -r '.[]') fi + # Add Docker security options if specified + if [[ "$MATRIX_DOCKER_SECURITY_OPTS" != '[]' ]]; then + while IFS= read -r opt; do + docker_args+=("--security-opt" "$opt") + done < <(echo "$MATRIX_DOCKER_SECURITY_OPTS" | jq -r '.[]') + fi + if [[ "$MATRIX_ENV_JSON" != '{}' ]]; then while IFS="=" read -r key value; do if [[ -n "$key" && -n "$value" ]]; then @@ -118,6 +125,7 @@ jobs: MATRIX_COMMAND_ARGUMENTS: ${{ matrix.config.command_arguments }} MATRIX_ENV_JSON: ${{ toJson(matrix.config.env) }} MATRIX_DOCKER_CAPABILITIES: ${{ toJson(matrix.config.docker_capabilities) }} + MATRIX_DOCKER_SECURITY_OPTS: ${{ toJson(matrix.config.docker_security_opts) }} GITHUB_WORKSPACE: ${{ github.workspace }} - name: Run matrix job (Windows) if: ${{ matrix.config.platform == 'Windows' }} diff --git a/.github/workflows/wasm_swift_sdk.yml b/.github/workflows/wasm_swift_sdk.yml index 3fd2ee4cfd8..1344514f32a 100644 --- a/.github/workflows/wasm_swift_sdk.yml +++ b/.github/workflows/wasm_swift_sdk.yml @@ -6,6 +6,18 @@ permissions: on: workflow_call: inputs: + nightly_main_enabled: + type: boolean + description: "Boolean to enable the nightly-main WASM Swift SDK matrix job. Defaults to true." + default: true + nightly_next_enabled: + type: boolean + description: "Boolean to enable the nightly-next WASM Swift SDK matrix job. Defaults to true." + default: true + release_6_3_enabled: + type: boolean + description: "Boolean to enable the 6.3 release WASM Swift SDK matrix job. Defaults to true." + default: true env_vars: type: string description: "Environment variables for jobs as JSON (e.g., '{\"DEBUG\":\"1\",\"LOG_LEVEL\":\"info\"}')." @@ -28,36 +40,66 @@ jobs: persist-credentials: false - id: generate-matrix run: | - # Validate and use JSON environment variables directly + # Validate JSON inputs + if ! echo "$INPUT_ENV_VARS" | jq empty 2>/dev/null; then + echo "Error: env_vars is not valid JSON" + exit 1 + fi + env_vars_json="$INPUT_ENV_VARS" + entries="" - # Validate JSON format - if ! echo "$env_vars_json" | jq empty 2>/dev/null; then - echo "Error: env_vars is not valid JSON" + make_entry() { + local swift_version="$1" + local install_env="$2" + jq -n \ + --arg name "${swift_version} Jammy" \ + --arg swift_version "$swift_version" \ + --arg install_env "$install_env" \ + --arg command_arguments "$INPUT_ADDITIONAL_COMMAND_ARGUMENTS" \ + --argjson env "$env_vars_json" \ + '{ + "name": $name, + "swift_version": $swift_version, + "platform": "Linux", + "runner": "ubuntu-latest", + "image": "ubuntu:jammy", + "setup_command": ("apt update -q && apt install -y -q curl jq tar && curl -s --retry 3 https://raw.githubusercontent.com/apple/swift-nio/main/scripts/install_swift_prerequisites.sh | bash && curl -s --retry 3 https://raw.githubusercontent.com/apple/swift-nio/main/scripts/install_swift_sdk.sh | " + $install_env + " INSTALL_SWIFT_ARCH=x86_64 INSTALL_SWIFT_SDK=wasm-sdk bash && hash -r"), + "command": "curl -s --retry 3 https://raw.githubusercontent.com/apple/swift-nio/main/scripts/swift-build-with-wasm-sdk.sh | bash -s --", + "command_arguments": $command_arguments, + "env": $env + }' + } + + if [[ "$INPUT_NIGHTLY_MAIN_ENABLED" == "true" ]]; then + entry=$(make_entry "nightly-main" "INSTALL_SWIFT_BRANCH=main") + entries="${entries:+${entries},}${entry}" + fi + + if [[ "$INPUT_NIGHTLY_NEXT_ENABLED" == "true" ]]; then + entry=$(make_entry "$NIGHTLY_NEXT_VERSION" "INSTALL_SWIFT_BRANCH=$NIGHTLY_NEXT_BRANCH") + entries="${entries:+${entries},}${entry}" + fi + + if [[ "$INPUT_RELEASE_6_3_ENABLED" == "true" ]]; then + entry=$(make_entry "6.3" "INSTALL_SWIFT_VERSION=6.3") + entries="${entries:+${entries},}${entry}" + fi + + if [[ -z "$entries" ]]; then + echo "Error: No WASM Swift SDK matrix jobs enabled" exit 1 fi - # Generate matrix with parsed environment variables - cat >> "$GITHUB_OUTPUT" << EOM - wasm-swift-sdk-matrix=$(echo '{ - "config":[ - { - "name":"main Jammy", - "swift_version":"main", - "platform":"Linux", - "runner":"ubuntu-latest", - "image":"ubuntu:jammy", - "setup_command":"apt update -q && apt install -y -q curl jq tar && curl -s --retry 3 https://raw.githubusercontent.com/apple/swift-nio/main/scripts/install_swift_prerequisites.sh | bash && curl -s --retry 3 https://raw.githubusercontent.com/apple/swift-nio/main/scripts/install_swift_sdk.sh | INSTALL_SWIFT_BRANCH=main INSTALL_SWIFT_ARCH=x86_64 INSTALL_SWIFT_SDK=wasm-sdk bash && hash -r", - "command":"curl -s --retry 3 https://raw.githubusercontent.com/apple/swift-nio/main/scripts/swift-build-with-wasm-sdk.sh | bash -s --", - "command_arguments":"'"$INPUT_ADDITIONAL_COMMAND_ARGUMENTS"'", - "env":'"$env_vars_json"' - } - ] - }' | jq -c) - EOM + echo "wasm-swift-sdk-matrix=$(echo "{\"config\":[${entries}]}" | jq -c)" >> "$GITHUB_OUTPUT" env: + INPUT_NIGHTLY_MAIN_ENABLED: ${{ inputs.nightly_main_enabled }} + INPUT_NIGHTLY_NEXT_ENABLED: ${{ inputs.nightly_next_enabled }} + INPUT_RELEASE_6_3_ENABLED: ${{ inputs.release_6_3_enabled }} INPUT_ENV_VARS: ${{ inputs.env_vars }} INPUT_ADDITIONAL_COMMAND_ARGUMENTS: ${{ inputs.additional_command_arguments }} + NIGHTLY_NEXT_VERSION: nightly-6.3 + NIGHTLY_NEXT_BRANCH: swift-6.3-branch wasm-swift-sdk: name: WebAssembly Swift SDK diff --git a/Benchmarks/Benchmarks/NIOAsyncRuntimeBenchmarks/Benchmarks.swift b/Benchmarks/Benchmarks/NIOAsyncRuntimeBenchmarks/Benchmarks.swift new file mode 100644 index 00000000000..3843acc5c89 --- /dev/null +++ b/Benchmarks/Benchmarks/NIOAsyncRuntimeBenchmarks/Benchmarks.swift @@ -0,0 +1,127 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2026 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +// NOTE: By and large the benchmarks here were ported from swift-nio +// to allow side-by-side performance comparison +// +// See https://github.com/apple/swift-nio/blob/main/Benchmarks/Benchmarks/NIOPosixBenchmarks/Benchmarks.swift + +import Benchmark +import NIOAsyncRuntime +import NIOCore + +let benchmarks: @Sendable () -> Void = { + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) { + let eventLoop = AsyncEventLoopGroup.singleton.next() + + let defaultMetrics: [BenchmarkMetric] = [ + .mallocCountTotal, + .contextSwitches, + .wallClock, + ] + + Benchmark( + "MTELG.immediateTasksThroughput", + configuration: Benchmark.Configuration( + metrics: defaultMetrics, + scalingFactor: .mega, + maxDuration: .seconds(10_000_000), + maxIterations: 5 + ) + ) { benchmark in + @Sendable func noOp() {} + for _ in benchmark.scaledIterations { + eventLoop.execute { noOp() } + } + } + + Benchmark( + "MTELG.scheduleTask(in:_:)", + configuration: Benchmark.Configuration( + metrics: defaultMetrics, + scalingFactor: .kilo, + maxDuration: .seconds(10_000_000), + maxIterations: 5 + ) + ) { benchmark in + for _ in benchmark.scaledIterations { + eventLoop.scheduleTask(in: .hours(1), {}) + } + } + + Benchmark( + "MTELG.scheduleCallback(in:_:)", + configuration: Benchmark.Configuration( + metrics: defaultMetrics, + scalingFactor: .mega, + maxDuration: .seconds(10_000_000), + maxIterations: 5, + // NOTE: Jan 29 2026. This test crashes in CI, but not locally, making a fix difficult. + // Skipping the benchmark for now. + skip: true + ) + ) { benchmark in + final class Timer: NIOScheduledCallbackHandler, @unchecked Sendable { + func handleScheduledCallback(eventLoop: some EventLoop) {} + } + let timer = Timer() + + benchmark.startMeasurement() + for _ in benchmark.scaledIterations { + _ = try! eventLoop.scheduleCallback(in: .hours(1), handler: timer) + } + } + + Benchmark( + "Jump to EL and back using execute and unsafecontinuation", + configuration: .init( + metrics: defaultMetrics, + scalingFactor: .kilo + ) + ) { benchmark in + for _ in benchmark.scaledIterations { + await withUnsafeContinuation { (continuation: UnsafeContinuation) in + eventLoop.execute { + continuation.resume() + } + } + } + } + + final actor Foo { + nonisolated public let unownedExecutor: UnownedSerialExecutor + + init(eventLoop: any EventLoop) { + self.unownedExecutor = eventLoop.executor.asUnownedSerialExecutor() + } + + func foo() { + blackHole(Void()) + } + } + + Benchmark( + "Jump to EL and back using actor with EL executor", + configuration: .init( + metrics: defaultMetrics, + scalingFactor: .kilo + ) + ) { benchmark in + let actor = Foo(eventLoop: eventLoop) + for _ in benchmark.scaledIterations { + await actor.foo() + } + } + } +} diff --git a/Benchmarks/Package.swift b/Benchmarks/Package.swift index f3628b49b2a..e9ec30933c3 100644 --- a/Benchmarks/Package.swift +++ b/Benchmarks/Package.swift @@ -37,5 +37,17 @@ let package = Package( .plugin(name: "BenchmarkPlugin", package: "package-benchmark") ] ), + .executableTarget( + name: "NIOAsyncRuntimeBenchmarks", + dependencies: [ + .product(name: "Benchmark", package: "package-benchmark"), + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOAsyncRuntime", package: "swift-nio"), + ], + path: "Benchmarks/NIOAsyncRuntimeBenchmarks", + plugins: [ + .plugin(name: "BenchmarkPlugin", package: "package-benchmark") + ] + ), ] ) diff --git a/Package.swift b/Package.swift index 28c3bf6be3f..6e44d6ecf20 100644 --- a/Package.swift +++ b/Package.swift @@ -47,10 +47,12 @@ let package = Package( .library(name: "NIO", targets: ["NIO"]), .library(name: "NIOEmbedded", targets: ["NIOEmbedded"]), .library(name: "NIOPosix", targets: ["NIOPosix"]), + .library(name: "NIOAsyncRuntime", targets: ["NIOAsyncRuntime"]), .library(name: "_NIOConcurrency", targets: ["_NIOConcurrency"]), .library(name: "NIOTLS", targets: ["NIOTLS"]), .library(name: "NIOHTTP1", targets: ["NIOHTTP1"]), .library(name: "NIOConcurrencyHelpers", targets: ["NIOConcurrencyHelpers"]), + .library(name: "NIOFoundationEssentialsCompat", targets: ["NIOFoundationEssentialsCompat"]), .library(name: "NIOFoundationCompat", targets: ["NIOFoundationCompat"]), .library(name: "NIOWebSocket", targets: ["NIOWebSocket"]), .library(name: "NIOTestUtils", targets: ["NIOTestUtils"]), @@ -112,6 +114,14 @@ let package = Package( resources: includePrivacyManifest ? [.copy("PrivacyInfo.xcprivacy")] : [], swiftSettings: swiftSettings ), + .target( + name: "NIOAsyncRuntime", + dependencies: [ + "NIOCore" + ], + exclude: ["README.md"], + swiftSettings: swiftSettings + ), .target( name: "NIO", dependencies: [ @@ -129,11 +139,31 @@ let package = Package( ], swiftSettings: swiftSettings ), + // Sole purpose of this target is to check all modules + // currently expected to pass compilation for WASI platforms + .target( + name: "_NIOWASIPlatformCompilationChecks", + dependencies: [ + .target(name: "NIOAsyncRuntime", condition: .when(platforms: [.wasi])), + "NIOCore", + "NIOEmbedded", // This should be properly elided in source files for WASI platforms + "NIOPosix", // This should be properly elided in source files for WASI platforms + swiftAtomics, + ], + swiftSettings: swiftSettings + ), + .target( + name: "NIOFoundationEssentialsCompat", + dependencies: [ + "NIOCore" + ], + swiftSettings: swiftSettings + ), .target( name: "NIOFoundationCompat", dependencies: [ .target(name: "NIO", condition: .when(platforms: historicalNIOPosixDependencyRequired)), - "NIOCore", + "NIOFoundationEssentialsCompat", ], swiftSettings: swiftSettings ), @@ -259,7 +289,7 @@ let package = Package( name: "NIOFSFoundationCompat", dependencies: [ "NIOFS", - "NIOFoundationCompat", + "NIOFoundationEssentialsCompat", ], path: "Sources/NIOFSFoundationCompat", swiftSettings: swiftSettings @@ -287,7 +317,7 @@ let package = Package( name: "_NIOFileSystemFoundationCompat", dependencies: [ "_NIOFileSystem", - "NIOFoundationCompat", + "NIOFoundationEssentialsCompat", ], path: "Sources/_NIOFileSystemFoundationCompat", swiftSettings: swiftSettings @@ -501,6 +531,17 @@ let package = Package( ], swiftSettings: swiftSettings ), + .testTarget( + name: "NIOAsyncRuntimeTests", + dependencies: [ + "NIOAsyncRuntime", + "NIOCore", + "NIOConcurrencyHelpers", + "NIOFoundationCompat", + "NIOTestUtils", + ], + swiftSettings: swiftSettings + ), .testTarget( name: "NIOConcurrencyHelpersTests", dependencies: [ diff --git a/Sources/NIOAsyncRuntime/AsyncEventLoop.swift b/Sources/NIOAsyncRuntime/AsyncEventLoop.swift new file mode 100644 index 00000000000..69723f3c37f --- /dev/null +++ b/Sources/NIOAsyncRuntime/AsyncEventLoop.swift @@ -0,0 +1,423 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2026 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if os(WASI) || (canImport(Testing) && !os(Android)) + +import NIOConcurrencyHelpers +import NIOCore +import struct Synchronization.Atomic +import protocol Synchronization.AtomicRepresentable + +#if canImport(Dispatch) +import Dispatch +#endif + +// MARK: - AsyncEventLoop - + +/// An `EventLoop` implemented solely with Swift Concurrency. +/// +/// This event loop allows enqueing and scheduling tasks, which will be ran using +/// Swift Concurrency. This implementation fulfills a similar role as +/// +/// - note: This event loop is not intended to be used directly. Instead, +/// use `AsyncEventLoopGroup` to create and manage instances of +/// `AsyncEventLoop`. +/// - note: AsyncEventLoop and similar classes in NIOAsyncRuntime are not intended +/// to be used for I/O use cases. They are meant solely to provide an off-ramp +/// for code currently using only NIOPosix.MTELG to transition away from NIOPosix +/// and use Swift Concurrency instead. +/// `AsyncEventLoop`. +/// - note: If downstream packages are able to use the dependencies in NIOAsyncRuntime +/// without using NIOPosix, they have definitive proof that their package can transition +/// to Swift Concurrency and eliminate the swift-nio dependency altogether. NIOAsyncRuntime +/// provides a convenient stepping stone to that end. +@available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) +final class AsyncEventLoop: EventLoop, Sendable { + /// This global atomic ID is loaded for each new event loop instance, and incremented atomically + /// after being loaded into each new loop instance. While the usage of a global in not ideal, it is + /// thread-safe due to the Atomic usage, and currently guaranteed to increment sequentially and + /// therefore be unique. + /// + /// This approach is less heavy-handed in terms of dependencies than using something like + /// Foundation.UUID. + /// + /// The following three lines are the entire implementation and usage of _globalLoopID. + static private let _globalLoopID: Atomic = .init(0) + private let _id = _globalLoopID.wrappingAdd(1, ordering: .sequentiallyConsistent).oldValue // unique identifier + + private let executor: AsyncEventLoopExecutor + private enum ShutdownState: UInt8, AtomicRepresentable { + case running = 0 + case closing = 1 + case closed = 2 + } + private let shutdownState = Atomic(ShutdownState.running) + + /// Used to implement the behavior expected by testSelectableEventLoopHasPreSucceededFuturesOnlyOnTheEventLoop. + private var cachedSucceededVoidFuture: EventLoopFuture { + _cachedSucceededVoidFuture.withLockedValue { _cachedSucceededVoidFutureMutable in + if let _cachedSucceededVoidFutureMutable { + return _cachedSucceededVoidFutureMutable + } else { + let newFutureToBeCached = self.makeSucceededVoidFutureUncached() + _cachedSucceededVoidFutureMutable = newFutureToBeCached + return newFutureToBeCached + } + } + } + private let _cachedSucceededVoidFuture: NIOLockedValueBox?> = NIOLockedValueBox(nil) + + /// - Parameter __testOnly_manualTimeMode: When true, enables a manual time mode that allows for artificial + /// adjustments of time, outside of the real-world timeline. This should only be used for automated testing. + init(__testOnly_manualTimeMode: Bool = false) { + self.executor = AsyncEventLoopExecutor(loopID: _id, __testOnly_manualTimeMode: __testOnly_manualTimeMode) + } + + // MARK: - EventLoop basics - + + @inlinable + var inEventLoop: Bool { + _CurrentEventLoopKey.id == _id + } + + private func isAcceptingNewTasks() -> Bool { + shutdownState.load(ordering: .acquiring) == ShutdownState.running + } + + private func isFullyShutdown() -> Bool { + shutdownState.load(ordering: .acquiring) == ShutdownState.closed + } + + func execute(_ task: @escaping @Sendable () -> Void) { + guard self.isAcceptingNewTasks() || self._canAcceptExecuteDuringShutdown else { return } + executor.enqueue(task) + } + + private var _canAcceptExecuteDuringShutdown: Bool { + self.inEventLoop + || AsyncEventLoopGroup._GroupContextKey.isFromAsyncEventLoopGroup + } + + // MARK: - Promises / Futures - + + @inlinable + func makeSucceededFuture(_ value: T) -> EventLoopFuture { + if T.self == Void.self { + return self.makeSucceededVoidFuture() as! EventLoopFuture + } + let p = makePromise(of: T.self) + p.succeed(value) + return p.futureResult + } + + @inlinable + func makeFailedFuture(_ error: Error) -> EventLoopFuture { + let p = makePromise(of: T.self) + p.fail(error) + return p.futureResult + } + + @inlinable + func makeSucceededVoidFuture() -> EventLoopFuture { + if self.inEventLoop { + return self.cachedSucceededVoidFuture + } else { + return self.makeSucceededVoidFutureUncached() + } + } + + private func makeSucceededVoidFutureUncached() -> EventLoopFuture { + let promise = self.makePromise(of: Void.self) + promise.succeed(()) + return promise.futureResult + } + + // MARK: - Submitting work - + #if compiler(>=6.1) + @preconcurrency + @inlinable + func submit(_ task: @escaping @Sendable () throws -> T) -> EventLoopFuture { + self.submit { () throws -> _UncheckedSendable in + _UncheckedSendable(try task()) + }.map { $0.value } + } + #endif + + @inlinable + func submit(_ task: @escaping @Sendable () throws -> T) -> EventLoopFuture { + guard self.isAcceptingNewTasks() else { + return self.makeFailedFuture(EventLoopError.shutdown) + } + let promise = makePromise(of: T.self) + executor.enqueue { + do { + let value = try task() + promise.succeed(value) + } catch { promise.fail(error) } + } + return promise.futureResult + } + + @inlinable + func flatSubmit( + _ task: @escaping @Sendable () -> EventLoopFuture + ) + -> EventLoopFuture + { + guard self.isAcceptingNewTasks() else { + return self.makeFailedFuture(EventLoopError.shutdown) + } + let promise = makePromise(of: T.self) + executor.enqueue { + let future = task() + future.cascade(to: promise) + } + return promise.futureResult + } + + // MARK: - Scheduling - + + /// NOTE: + /// + /// Timing for execute vs submit vs schedule: + /// + /// Tasks scheduled via `execute` or `submit` are appended to the back of the event loop's task queue + /// and are executed serially in FIFO order. Scheduled tasks (e.g., via `schedule(deadline:)`) are + /// placed in a timing wheel and, when their deadline arrives, are enqueued at the back of the main + /// queue after any already-pending work. This means that if the event loop is backed up, a scheduled + /// task may execute slightly after its scheduled time, as it must wait for previously enqueued tasks + /// to finish. Scheduled tasks never preempt or jump ahead of already-queued immediate work. + @preconcurrency + @inlinable + func scheduleTask( + deadline: NIODeadline, + _ task: @escaping @Sendable () throws -> T + ) -> Scheduled { + let scheduled: Scheduled<_UncheckedSendable> = self._scheduleTask( + deadline: deadline, + task: { try _UncheckedSendable(task()) } + ) + return self._unsafelyRewrapScheduled(scheduled) + } + + #if compiler(>=6.1) + @inlinable + func scheduleTask( + deadline: NIODeadline, + _ task: @escaping @Sendable () throws -> T + ) -> Scheduled { + self._scheduleTask(deadline: deadline, task: task) + } + #endif + + @preconcurrency + @inlinable + func scheduleTask( + in delay: TimeAmount, + _ task: @escaping @Sendable () throws -> T + ) -> Scheduled { + let scheduled: Scheduled<_UncheckedSendable> = self._scheduleTask( + in: delay, + task: { try _UncheckedSendable(task()) } + ) + return self._unsafelyRewrapScheduled(scheduled) + } + + #if compiler(>=6.1) + @inlinable + func scheduleTask( + in delay: TimeAmount, + _ task: @escaping @Sendable () throws -> T + ) -> Scheduled { + self._scheduleTask(in: delay, task: task) + } + #endif + + private func _scheduleTask( + deadline: NIODeadline, + task: @escaping @Sendable () throws -> T + ) -> Scheduled { + let promise = makePromise(of: T.self) + guard self.isAcceptingNewTasks() else { + promise.fail(EventLoopError._shutdown) + return Scheduled(promise: promise) {} + } + + let jobID = executor.schedule( + at: deadline, + job: { + do { + promise.succeed(try task()) + } catch { + promise.fail(error) + } + }, + failFn: { error in + promise.fail(error) + } + ) + + return Scheduled(promise: promise) { + // NOTE: Documented cancellation procedure indicates + // cancellation is not guaranteed. As such, and to match existing Promise API's, + // using a Task here to avoid pushing async up the software stack. + self.executor.cancelScheduledJob(withID: jobID) + + // NOTE: NIO Core already fails the promise before calling the cancellation closure, + // so we do NOT try to fail the promise. Also cancellation is not guaranteed, so we + // allow cancellation to silently fail rather than re-negotiating to a throwing API. + } + } + + private func _scheduleTask( + in delay: TimeAmount, + task: @escaping @Sendable () throws -> T + ) -> Scheduled { + // NOTE: This is very similar to the `scheduleTask(deadline:)` implementation. However + // due to the nonisolated context here, we keep the implementations separate until they + // reach isolating mechanisms within the executor. + + let promise = makePromise(of: T.self) + guard self.isAcceptingNewTasks() else { + promise.fail(EventLoopError._shutdown) + return Scheduled(promise: promise) {} + } + + let jobID = executor.schedule( + after: delay, + job: { + do { + promise.succeed(try task()) + } catch { + promise.fail(error) + } + }, + failFn: { error in + promise.fail(error) + } + ) + + return Scheduled(promise: promise) { + // NOTE: Documented cancellation procedure indicates + // cancellation is not guaranteed. As such, and to match existing Promise API's, + // using a Task here to avoid pushing async up the software stack. + self.executor.cancelScheduledJob(withID: jobID) + + // NOTE: NIO Core already fails the promise before calling the cancellation closure, + // so we do NOT try to fail the promise. Also cancellation is not guaranteed, so we + // allow cancellation to silently fail rather than re-negotiating to a throwing API. + } + } + + func closeGracefully() async { + let previous = shutdownState.exchange(ShutdownState.closing, ordering: .acquiring) + guard previous != .closed else { return } + self._cachedSucceededVoidFuture.withLockedValue { _cachedSucceededVoidFutureMutable in + _cachedSucceededVoidFutureMutable = nil + } + await executor.clearQueue() + shutdownState.store(ShutdownState.closed, ordering: .releasing) + } + + @inlinable + func next() -> EventLoop { + self + } + func any() -> EventLoop { + self + } + + /// Moves time forward by specified increment, and runs event loop, causing + /// all pending events either from enqueing or scheduling requirements to run. + @inlinable + func __testOnly_advanceTime(by increment: TimeAmount) async throws { + try await executor.__testOnly_advanceTime(by: increment) + } + + @inlinable + func __testOnly_advanceTime(to deadline: NIODeadline) async throws { + try await executor.__testOnly_advanceTime(to: deadline) + } + + @inlinable + func run() async { + await executor.run() + } + + #if canImport(Dispatch) + func shutdownGracefully( + queue: DispatchQueue, + _ callback: @escaping @Sendable (Error?) -> Void + ) { + if AsyncEventLoopGroup._GroupContextKey.isFromAsyncEventLoopGroup { + Task { + await closeGracefully() + queue.async { callback(nil) } + } + } else { + // Bypassing the group shutdown and calling an event loop + // shutdown directly is considered api-misuse + callback(EventLoopError.unsupportedOperation) + } + } + #endif + + func syncShutdownGracefully() throws { + // The test AsyncEventLoopTests.testIllegalCloseOfEventLoopFails requires + // this implementation to throw an error, because uses should call shutdown on + // AsyncEventLoopGroup instead of calling it directly on the loop. + throw EventLoopError.unsupportedOperation + } + + func shutdownGracefully() async throws { + await self.closeGracefully() + } + + #if !canImport(Dispatch) + func _preconditionSafeToSyncShutdown(file: StaticString, line: UInt) { + assertionFailure("Synchronous shutdown API's are not currently supported by AsyncEventLoop") + } + #endif + + @preconcurrency + private func _unsafelyRewrapScheduled( + _ scheduled: Scheduled<_UncheckedSendable> + ) -> Scheduled { + let promise = self.makePromise(of: T.self) + scheduled.futureResult.whenComplete { result in + switch result { + case .success(let boxed): + promise.assumeIsolatedUnsafeUnchecked().succeed(boxed.value) + case .failure(let error): + promise.fail(error) + } + } + return Scheduled(promise: promise) { + scheduled.cancel() + } + } + + /// This is a shim used to support older protocol-required API's without compiler warnings, and provide more modern + /// concurrency-ready overloads. + @preconcurrency + private struct _UncheckedSendable: @unchecked Sendable { + let value: T + init(_ value: T) { self.value = value } + } +} + +@available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) +extension AsyncEventLoop: NIOSerialEventLoopExecutor {} + +#endif // os(WASI) || (canImport(Testing) && !os(Android)) diff --git a/Sources/NIOAsyncRuntime/AsyncEventLoopExecutor.swift b/Sources/NIOAsyncRuntime/AsyncEventLoopExecutor.swift new file mode 100644 index 00000000000..429dc2ef849 --- /dev/null +++ b/Sources/NIOAsyncRuntime/AsyncEventLoopExecutor.swift @@ -0,0 +1,591 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2026 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if os(WASI) || (canImport(Testing) && !os(Android)) + +import NIOCore +import _NIODataStructures +import struct Synchronization.Atomic + +/// Task‑local key that stores the event loop ID of the `AsyncEventLoop` currently +/// executing. Lets us answer `inEventLoop` without private APIs. +@available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) +enum _CurrentEventLoopKey { @TaskLocal static var id: UInt? } + +@available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) +private enum JobID { + /// The ID of the next job to be enqued or scheduled + static private let _globallyIncrementingJobID: Atomic = .init(0) + + static fileprivate func next() -> UInt { + _globallyIncrementingJobID.wrappingAdd(1, ordering: .sequentiallyConsistent).oldValue + } +} + +/// This is an actor designed to execute provided tasks in the order they enter the actor. +/// It also provides task scheduling, time manipulation, pool draining, and other mechanisms +/// required for fully supporting NIO event loop operations. +@available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) +actor AsyncEventLoopExecutor { + private let executor: _AsyncEventLoopExecutor + + /// - Parameter __testOnly_manualTimeMode: When true, enables a manual time mode that allows for artificial + /// adjustments of time, outside of the real-world timeline. This should only be used for automated testing. + init(loopID: UInt, __testOnly_manualTimeMode: Bool = false) { + self.executor = _AsyncEventLoopExecutor(loopID: loopID, __testOnly_manualTimeMode: __testOnly_manualTimeMode) + } + + // MARK: - nonisolated API's - + + // NOTE: IMPORTANT! ⚠️ + // + // The following API's provide non-isolated entry points + // + // It is VERY important that you call one and only one function inside each task block + // to preserve first-in ordering, and to avoid interleaving issues. + + /// Schedules a job to run at a specified deadline and returns an id (globally atomic incrementing integer) + /// for the job that can be used to cancel the job if needed + @discardableResult + @inlinable + nonisolated func schedule( + at deadline: NIODeadline, + job: @Sendable @escaping () -> Void, + failFn: @Sendable @escaping (Error) -> Void + ) -> UInt { + let id = JobID.next() + Task { @_AsyncEventLoopExecutor._IsolatingSerialEntryActor [job] in + // ^----- Ensures first-in entry from nonisolated contexts + await executor.schedule(at: deadline, id: id, job: job, failFn: failFn) + } + return id + } + + /// Schedules a job to run after a specified delay and returns a UUID for the job that can be used to cancel the job if needed + @discardableResult + @inlinable + nonisolated func schedule( + after delay: TimeAmount, + job: @Sendable @escaping () -> Void, + failFn: @Sendable @escaping (Error) -> Void + ) -> UInt { + let id = JobID.next() + Task { @_AsyncEventLoopExecutor._IsolatingSerialEntryActor [delay, job] in + // ^----- Ensures first-in entry from nonisolated contexts + await executor.schedule(after: delay, id: id, job: job, failFn: failFn) + } + return id + } + + @inlinable + nonisolated func enqueue(_ job: @Sendable @escaping () -> Void) { + Task { @_AsyncEventLoopExecutor._IsolatingSerialEntryActor [job] in + // ^----- Ensures first-in entry from nonisolated contexts + await executor.enqueue(job) + } + } + + @inlinable + nonisolated func cancelScheduledJob(withID id: UInt) { + Task { @_AsyncEventLoopExecutor._IsolatingSerialEntryActor [id] in + // ^----- Ensures first-in entry from nonisolated contexts + await executor.cancelScheduledJob(withID: id) + } + } + + // MARK: - async API's - + + // NOTE: The following are async api's and don't require special handling + + @inlinable + func clearQueue() async { + await executor.clearQueue() + } + + @inlinable + func __testOnly_advanceTime(by increment: TimeAmount) async throws { + try await executor.__testOnly_advanceTime(by: increment) + } + + @inlinable + func __testOnly_advanceTime(to deadline: NIODeadline) async throws { + try await executor.__testOnly_advanceTime(to: deadline) + } + + @inlinable + func run() async { + await executor.run() + } +} + +/// This class provides the private implementation details for ``AsyncEventLoopExecutor``. +/// +/// However, it defers the nonisolated and internal-facing API's to ``AsyncEventLoopExecutor`` which +/// helps make the isolation boundary very clear. +/// +/// For a detailed explanation of how the loop works, refer to the documentation for `runNextJobIfNeeded`. +@available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) +fileprivate actor _AsyncEventLoopExecutor { + /// Used in unit testing only to enable adjusting + /// the current time programmatically to test event scheduling and other + private var _now = NIODeadline.now() + + private var now: NIODeadline { + if __testOnly_manualTimeMode { + _now + } else { + NIODeadline.now() + } + } + + /// We use this actor to make serialized first-in entry + /// into the event loop. This is a shared instance between all + /// event loops, so it is important that we ONLY use it to enqueue + /// jobs that come from a non-isolated context. + @globalActor + fileprivate struct _IsolatingSerialEntryActor { + actor ActorType {} + static let shared = ActorType() + } + + fileprivate typealias OrderIntegerType = UInt64 + + fileprivate struct ScheduledJob { + let id: UInt + let deadline: NIODeadline + let order: OrderIntegerType + let job: @Sendable () -> Void + let failFn: @Sendable (Error) -> Void + + init( + id: UInt, + deadline: NIODeadline, + order: OrderIntegerType, + job: @Sendable @escaping () -> Void, + failFn: @Sendable @escaping (Error) -> Void + ) { + self.id = id + self.deadline = deadline + self.order = order + self.job = job + self.failFn = failFn + } + } + private var scheduledQueue = PriorityQueue() + private var nextScheduledItemOrder: OrderIntegerType = 0 + + private var currentlyRunningExecutorTask: Task? + private let __testOnly_manualTimeMode: Bool + private var wakeUpTask: Task? + private var jobQueue: [() -> Void] = [] + + let loopID: UInt + init(loopID: UInt, __testOnly_manualTimeMode: Bool = false) { + self.loopID = loopID + self.__testOnly_manualTimeMode = __testOnly_manualTimeMode + } + + fileprivate func schedule( + after delay: TimeAmount, + id: UInt, + job: @Sendable @escaping () -> Void, + failFn: @Sendable @escaping (Error) -> Void + ) { + let base = self.schedulingNow() + self.schedule(at: base + delay, id: id, job: job, failFn: failFn) + } + + fileprivate func schedule( + at deadline: NIODeadline, + id: UInt, + job: @Sendable @escaping () -> Void, + failFn: @Sendable @escaping (Error) -> Void + ) { + let order = nextScheduledItemOrder + nextScheduledItemOrder += 1 + scheduledQueue.push( + ScheduledJob(id: id, deadline: deadline, order: order, job: job, failFn: failFn) + ) + + runNextJobIfNeeded() + } + + fileprivate func enqueue(_ job: @escaping () -> Void) async { + jobQueue.append(job) + await run() + } + + /// Some operations in the serial executor need to wait until pending entry operations finish + /// enqueing themselves. + private func awaitPendingEntryOperations() async { + await Task { @_IsolatingSerialEntryActor [] in + // ^----- Ensures first-in entry from nonisolated contexts + await noOp() // We want to await for self here + }.value + } + + private func noOp() {} + + private func schedulingNow() -> NIODeadline { + if __testOnly_manualTimeMode { + return _now + } else { + let wallNow = NIODeadline.now() + _now = wallNow + return wallNow + } + } + + /// Moves time forward by specified increment, and runs event loop, causing + /// all pending events either from enqueing or scheduling requirements to run. + fileprivate func __testOnly_advanceTime(by increment: TimeAmount) async throws { + guard __testOnly_manualTimeMode else { + throw EventLoopError.unsupportedOperation + } + try await self.__testOnly_advanceTime(to: self._now + increment) + } + + fileprivate func __testOnly_advanceTime(to deadline: NIODeadline) async throws { + guard __testOnly_manualTimeMode else { + throw EventLoopError.unsupportedOperation + } + await awaitPendingEntryOperations() + + // Wait for any existing tasks to run before starting our time advancement + // (re-entrancy safeguard) + if let existingTask = currentlyRunningExecutorTask { + _ = await existingTask.value + } + + // ======================================================== + // ℹ️ℹ️ℹ️ℹ️ IMPORTANT: ℹ️ℹ️ℹ️ℹ️ + // ======================================================== + // + // This is non-obvious, but certain scheduled tasks can + // schedule or kick off other scheduled tasks. + // + // It is CRITICAL that we advance time progressively to + // the desired new deadline, by running the soonest + // scheduled task (or group of tasks, if multiple have the + // same deadline) first, sequentially until we ran all tasks + // up to and including the new deadline. + // + // This way, we simulate a true progression of time. It + // would be simpler and easier to simply jump to the new + // deadline and run all tasks with deadlines occuring before + // the new deadline. However, that simplistic approach + // does not account for situations where a task may have needed + // to generate multiple other tasks during the progression of time. + + // 1. Before we adjust time, we need to ensure we run a fresh loop + // run with the current time, to capture t = now in our time progression + // towards t = now + deadline. + await run() + await awaitPendingEntryOperations() + if let existingTask = currentlyRunningExecutorTask { + _ = await existingTask.value + } + + // Deadlines before _now are interpretted moved to _now + let finalDeadline = max(deadline, _now) + var lastRanDeadline: NIODeadline? + + repeat { + // 1. Get soonest task + // Note that scheduledQueue is sorted as tasks are added, so the first item in the queue + // should (must) always be the soonest in both deadline and priority terms. + + guard let nextSoonestTask = scheduledQueue.peek(), + nextSoonestTask.deadline <= finalDeadline + else { + // 4. Repeat until the soonest task is AFTER the new deadline. + break + } + + // 2. Update time + _now = max(nextSoonestTask.deadline, _now) + + // 3. Run all tasks through and up to the deadline of the soonest task + guard let runnerTask = runNextJobIfNeeded() else { + // Unknown how this case would happen. But if for whatever reason + // runNextJobIfNeeded determines there are no jobs to run, we would + // hit this condition, in which case we should stop iterating. + assertionFailure( + "Unexpected use case, tried to run scheduled tasks, but unable to run them." + ) + break + } + lastRanDeadline = nextSoonestTask.deadline + await runnerTask.value + } while !scheduledQueue.isEmpty + + // FINALLY, we update to the final deadline + _now = finalDeadline + + // Final run of loop after time adjustment for t = now + deadline, + // only if not already ran for this deadline. + if let lastRanDeadline, lastRanDeadline <= finalDeadline { + await run() + } + } + + fileprivate func run() async { + await awaitPendingEntryOperations() + if let runningTask = runNextJobIfNeeded() { + await runningTask.value + } + } + + /// This is the most important part of the AsyncEventLoopExecutor + /// + /// This is essentially a "run loop" that powers the AsyncEventLoop. + /// + /// Here is the basic flow: + /// + /// 1. A re-entrancy guard prevents starting up a new run if one is already pending, instead + /// re-entrant calls are joined to pending runs. + /// + /// 2. There are two queues. `jobQueue` holds pending jobs that aren't scheduled. + /// `scheduledQueue` contains pending scheduled jobs. Before proceeding further, + /// the loop checks if both queues are empty, and stops if they're both empty + /// + /// 3. Previous runs may scheduled a "wakeup" that calls `runNextJobIfNeeded`. + /// Once we reach a point where we're certain the loop will run, we cancel any pending wakeups + /// to ensure they don't wakeup a run while we're in the middle of already running + /// + /// 4. The loop starts by running all jobs in `jobQueue`. This is referred to in some places throughout + /// swift-nio as "pool draining". + /// + /// 5. Once `jobQueue` is finishes, we run all past-due jobs currently in scheduledQueue + /// + /// 6. Finally, we schedule a new call to runNextJobIfNeeded that will run when the next scheduled + /// job becomes due. + /// + /// Outside of `runNextJobIfNeeded`, we ensure a call is made to `runNextJobIfNeeded` any time + /// new jobs are scheduled or otherwise enqueued. In this way, we allow the loop to be completely + /// dead when the queues are empty, but also ensure tasks + /// run in the expected order once enqueued. + /// + /// This behavior is thoroughly tested to match the behavior of ``SelectableEventLoop`` from ``NIOPosix``, + /// as found in ``AsyncEventLoopTests`` and a few other tests in the ``NIOAsyncRuntime`` module. + @discardableResult + private func runNextJobIfNeeded() -> Task? { + // 1. No need to start if a task is already running + if let existingTask = currentlyRunningExecutorTask { + return existingTask + } + + // 2. Stop if both queues are empty. + if jobQueue.isEmpty && scheduledQueue.isEmpty { + // no more tasks to run + return nil + } + + // 3. If we reach this point, we're going to run a new loop series, and + // we'll also set up wakeups if needed after the loop runs complete. We + // should cancel any outstanding scheduled wakeups so they don't + // inject themselves in the middle of a clean run. + cancelScheduledWakeUp() + + let newTask: Task = Task { + defer { + // When we finish, clear the handle to the existing runner task + currentlyRunningExecutorTask = nil + } + await _CurrentEventLoopKey.$id.withValue(loopID) { + // 4. Run all jobs currently in taskQueue + runEnqueuedJobs() + + // 5. Run all jobs in scheduledQueue past the due date + let snapshot = await runPastDueScheduledJobs(nowSnapshot: captureNowSnapshot()) + + // 6. Schedule next run or wake‑up if needed. + scheduleNextRunIfNeeded(latestSnapshot: snapshot) + } + } + currentlyRunningExecutorTask = newTask + return newTask + } + + private func captureNowSnapshot() -> NIODeadline { + if __testOnly_manualTimeMode { + return self.now + } else { + _now = max(_now, NIODeadline.now()) + return self.now + } + } + + /// Runs all jobs currently in taskQueue + private func runEnqueuedJobs() { + while !jobQueue.isEmpty { + // Run the job + let job = jobQueue.removeFirst() + job() + } + } + + /// Runs all jobs in scheduledQueue past the due date + private func runPastDueScheduledJobs(nowSnapshot: NIODeadline) async -> NIODeadline { + var lastCapturedSnapshot = nowSnapshot + while true { + // An expected edge case is that if an imminently scheduled task + // is cancelled literally right after being scheduled, it should + // be cancelled and not run. This behavior is asserted by the + // test named testRepeatedTaskThatIsImmediatelyCancelledNeverFires. + // + // To guarantee this behavior, we do the following: + // + // - Ensure entry cancelScheduledJob is guarded by _IsolatingSerialEntryActor + // - Await here for re-entry into _IsolatingSerialEntryActor using awaitPendingEntryOperations() + await awaitPendingEntryOperations() + guard let scheduled = scheduledQueue.peek() else { + break + } + + guard lastCapturedSnapshot >= scheduled.deadline else { + break + } + + // Run scheduled job + scheduled.job() + + // Remove scheduled job + _ = scheduledQueue.pop() + + lastCapturedSnapshot = captureNowSnapshot() + } + + return lastCapturedSnapshot + } + + private func scheduleNextRunIfNeeded(latestSnapshot: NIODeadline) { + // It is important to run this as a separate task + // to allow any tasks calling this to completely close out + Task { + await awaitPendingEntryOperations() + + if !jobQueue.isEmpty { + // If there are items in the job queue, we need to run now + runNextJobIfNeeded() + } else if __testOnly_manualTimeMode && !scheduledQueue.isEmpty { + // Under manual time we progress immediately instead of waiting for a wake‑up. + runNextJobIfNeeded() + } else if !scheduledQueue.isEmpty { + // Schedule a wake-up at the next scheduled job time. + scheduleWakeUp(nowSnapshot: latestSnapshot) + } else { + cancelScheduledWakeUp() + } + } + } + + /// Schedules next run of jobs at or near the expected due date time for the next job. + private func scheduleWakeUp(nowSnapshot: NIODeadline) { + let shouldScheduleWakeUp = !__testOnly_manualTimeMode + if shouldScheduleWakeUp, let nextScheduledTask = scheduledQueue.peek() { + let interval = nextScheduledTask.deadline - nowSnapshot + let nanoseconds = max(interval.nanoseconds, 0) + // NOTE: Using weak self here to avoid potential memory leaks due + // to reference cycles, since the task is stored to a member variable. + wakeUpTask = Task { [weak self] in + guard let self else { return } + if nanoseconds > 0 { + do { + try await Task.sleep(nanoseconds: UInt64(nanoseconds)) + } catch { + return + } + } + guard !Task.isCancelled else { return } + await self.run() + } + } else { + cancelScheduledWakeUp() + } + } + + private func cancelScheduledWakeUp() { + wakeUpTask?.cancel() + wakeUpTask = nil + } + + fileprivate func cancelScheduledJob(withID id: UInt) { + scheduledQueue.removeFirst(where: { $0.id == id }) + } + + fileprivate func clearQueue() async { + await awaitPendingEntryOperations() + cancelScheduledWakeUp() + await self.drainJobQueue() + + assert(jobQueue.isEmpty, "Job queue should become empty by this point") + jobQueue.removeAll() + + // NOTE: Behavior in NIOPosix is to run all previously scheduled tasks as part + // Refer to the `defer` block inside NIOPosix.SelectableEventLoop.run to find this behavior + // The point in that code that calls failFn(EventLoopError._shutdown) calls fail + // on the pending promises that are scheduled in the future. + + let finalDeadline = now + while let scheduledJob = scheduledQueue.pop() { + assert(scheduledJob.deadline > finalDeadline, "All remaining jobs should be in the future") + scheduledJob.failFn(EventLoopError._shutdown) + } + + await self.drainJobQueue() + + assert(jobQueue.isEmpty, "Job queue should become empty by this point") + jobQueue.removeAll() + cancelScheduledWakeUp() + } + + private func drainJobQueue() async { + while !jobQueue.isEmpty || currentlyRunningExecutorTask != nil { + await run() + } + } + + private static func flooringSubtraction(_ lhs: UInt64, _ rhs: UInt64) -> UInt64 { + let (partial, overflow) = lhs.subtractingReportingOverflow(rhs) + guard !overflow else { return UInt64.min } + return partial + } +} + +extension EventLoopError { + static let _shutdown: any Error = EventLoopError.shutdown +} + +@available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) +extension _AsyncEventLoopExecutor.ScheduledJob: Comparable { + static func < ( + lhs: _AsyncEventLoopExecutor.ScheduledJob, + rhs: _AsyncEventLoopExecutor.ScheduledJob + ) -> Bool { + if lhs.deadline == rhs.deadline { + return lhs.order < rhs.order + } + return lhs.deadline < rhs.deadline + } + + static func == ( + lhs: _AsyncEventLoopExecutor.ScheduledJob, + rhs: _AsyncEventLoopExecutor.ScheduledJob + ) -> Bool { + lhs.id == rhs.id + } +} + +#endif // os(WASI) || (canImport(Testing) && !os(Android)) diff --git a/Sources/NIOAsyncRuntime/AsyncEventLoopGroup.swift b/Sources/NIOAsyncRuntime/AsyncEventLoopGroup.swift new file mode 100644 index 00000000000..b76fb9e8dc3 --- /dev/null +++ b/Sources/NIOAsyncRuntime/AsyncEventLoopGroup.swift @@ -0,0 +1,107 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2026 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if os(WASI) || (canImport(Testing) && !os(Android)) + +import struct Synchronization.Atomic +import protocol NIOCore.EventLoop +import protocol NIOCore.EventLoopGroup +import struct NIOCore.EventLoopIterator +import enum NIOCore.System + +#if canImport(Dispatch) +import Dispatch +#endif + +/// An `EventLoopGroup` which will create multiple `EventLoop`s, each tied to its own task pool. +/// +/// This implementation relies on SwiftConcurrency and does not directly instantiate any actual threads. +/// This reduces risk and fallout if the event loop group is not shutdown gracefully, compared to the NIOPosix +/// `MultiThreadedEventLoopGroup` implementation. +/// +/// - note: AsyncEventLoopGroup and similar classes in NIOAsyncRuntime are not intended +/// to be used for I/O use cases. They are meant solely to provide an off-ramp +/// for code currently using only NIOPosix.MTELG to transition away from NIOPosix +/// and use Swift Concurrency instead. +/// - note: If downstream packages are able to use the dependencies in NIOAsyncRuntime +/// without using NIOPosix, they have definitive proof that their package can transition +/// to Swift Concurrency and eliminate the swift-nio dependency altogether. NIOAsyncRuntime +/// provides a convenient stepping stone to that end. +@available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) +public final class AsyncEventLoopGroup: EventLoopGroup, Sendable { + /// Task‑local key that stores a boolean that helps AsyncEventLoop know + /// if shutdown calls are being made from this event loop group, or external + /// + /// Safety mechanisms prevent calling shutdown direclty on a loop. + enum _GroupContextKey { @TaskLocal static var isFromAsyncEventLoopGroup: Bool = false } + + private let loops: [AsyncEventLoop] + private let counter = Atomic(0) + + public init(numberOfThreads: Int = System.coreCount) { + precondition(numberOfThreads > 0, "thread count must be positive") + self.loops = (0.. EventLoop { + loops[counter.wrappingAdd(1, ordering: .sequentiallyConsistent).oldValue % loops.count] + } + + public func any() -> EventLoop { loops[0] } + + public func makeIterator() -> NIOCore.EventLoopIterator { + .init(self.loops.map { $0 as EventLoop }) + } + + #if canImport(Dispatch) + public func shutdownGracefully( + queue: DispatchQueue, + _ onCompletion: @escaping @Sendable (Error?) -> Void + ) { + Task { + do { + try await shutdownGracefully() + queue.async { + onCompletion(nil) + } + } catch { + queue.async { + onCompletion(error) + } + } + } + } + #endif // canImport(Dispatch) + + public func shutdownGracefully() async throws { + await _GroupContextKey.$isFromAsyncEventLoopGroup.withValue(true) { + for loop in loops { await loop.closeGracefully() } + } + } + + public static let singleton = AsyncEventLoopGroup() + + #if !canImport(Dispatch) + public func _preconditionSafeToSyncShutdown(file: StaticString, line: UInt) { + assertionFailure( + "Synchronous shutdown API's are not currently supported by AsyncEventLoopGroup" + ) + } + #endif +} + +#endif // os(WASI) || (canImport(Testing) && !os(Android)) diff --git a/Sources/NIOAsyncRuntime/AsyncThreadPool.swift b/Sources/NIOAsyncRuntime/AsyncThreadPool.swift new file mode 100644 index 00000000000..2904ff572e8 --- /dev/null +++ b/Sources/NIOAsyncRuntime/AsyncThreadPool.swift @@ -0,0 +1,303 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2026 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if os(WASI) || (canImport(Testing) && !os(Android)) + +import DequeModule +import NIOConcurrencyHelpers + +import struct Synchronization.Atomic +import protocol NIOCore.EventLoop +import class NIOCore.EventLoopFuture +import enum NIOCore.System + +/// Errors that may be thrown when executing work on a `AsyncThreadPool`. +public enum AsyncThreadPoolError: Sendable { + public struct ThreadPoolInactive: Error { + public init() {} + } + + public struct UnsupportedOperation: Error { + public init() {} + } +} + +/// Drop‑in for `NIOThreadPool` from NIOPosix, powered by Swift Concurrency. +/// +/// - note: AsyncThreadPool and similar classes in NIOAsyncRuntime are not intended +/// to be used for I/O use cases. They are meant solely to provide an off-ramp +/// for code currently using only NIOPosix.MTELG to transition away from NIOPosix +/// and use Swift Concurrency instead. +/// - note: If downstream packages are able to use the dependencies in NIOAsyncRuntime +/// without using NIOPosix, they have definitive proof that their package can transition +/// to Swift Concurrency and eliminate the swift-nio dependency altogether. NIOAsyncRuntime +/// provides a convenient stepping stone to that end. +@available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) +public final class AsyncThreadPool: Sendable { + /// The state of the `WorkItem`. + public enum WorkItemState: Sendable { + /// The work item is currently being executed. + case active + /// The work item has been cancelled and will not run. + case cancelled + } + + /// The work that should be done by the thread pool. + public typealias WorkItem = @Sendable (WorkItemState) -> Void + + @usableFromInline + struct IdentifiableWorkItem: Sendable { + @usableFromInline var workItem: WorkItem + @usableFromInline var id: Int? + } + + private let shutdownFlag = Atomic(false) + private let started = Atomic(false) + private let numberOfThreads: Int + private let workQueue = WorkQueue() + private let workerTasks: NIOLockedValueBox<[Task]> = NIOLockedValueBox([]) + + public init(numberOfThreads: Int? = nil) { + let threads = numberOfThreads ?? System.coreCount + self.numberOfThreads = max(1, threads) + } + + public func start() { + startWorkersIfNeeded() + } + + private var isActive: Bool { + self.started.load(ordering: .acquiring) && !self.shutdownFlag.load(ordering: .acquiring) + } + + // MARK: - Public API - + + public func submit(_ body: @escaping WorkItem) { + guard self.isActive else { + body(.cancelled) + return + } + + startWorkersIfNeeded() + + Task { + await workQueue.enqueue(IdentifiableWorkItem(workItem: body, id: nil)) + } + } + + @preconcurrency + public func submit( + on eventLoop: EventLoop, + _ fn: @escaping @Sendable () throws -> T + ) + -> EventLoopFuture + { + self.submit(on: eventLoop) { () throws -> _UncheckedSendable in + _UncheckedSendable(try fn()) + }.map { $0.value } + } + + public func submit( + on eventLoop: EventLoop, + _ fn: @escaping @Sendable () throws -> T + ) -> EventLoopFuture { + self.makeFutureByRunningOnPool(eventLoop: eventLoop, fn) + } + + /// Async helper mirroring `runIfActive` without an EventLoop context. + public func runIfActive(_ body: @escaping @Sendable () throws -> T) async throws -> T { + try Task.checkCancellation() + guard self.isActive else { throw CancellationError() } + + return try await Task { + try Task.checkCancellation() + guard self.isActive else { throw CancellationError() } + return try body() + }.value + } + + /// Event‑loop variant returning only the future. + @preconcurrency + public func runIfActive( + eventLoop: EventLoop, + _ body: @escaping @Sendable () throws -> T + ) + -> EventLoopFuture + { + self.runIfActive(eventLoop: eventLoop) { () throws -> _UncheckedSendable in + _UncheckedSendable(try body()) + }.map { $0.value } + } + + public func runIfActive( + eventLoop: EventLoop, + _ body: @escaping @Sendable () throws -> T + ) -> EventLoopFuture { + self.makeFutureByRunningOnPool(eventLoop: eventLoop, body) + } + + private func makeFutureByRunningOnPool( + eventLoop: EventLoop, + _ body: @escaping @Sendable () throws -> T + ) -> EventLoopFuture { + guard self.isActive else { + return eventLoop.makeFailedFuture(AsyncThreadPoolError.ThreadPoolInactive()) + } + + let promise = eventLoop.makePromise(of: T.self) + self.submit { state in + switch state { + case .active: + do { + let value = try body() + promise.succeed(value) + } catch { + promise.fail(error) + } + case .cancelled: + promise.fail(AsyncThreadPoolError.ThreadPoolInactive()) + } + } + return promise.futureResult + } + + // Lifecycle -------------------------------------------------------------- + + public static let singleton: AsyncThreadPool = { + let pool = AsyncThreadPool() + pool.start() + return pool + }() + + @preconcurrency + public func shutdownGracefully(_ callback: @escaping @Sendable (Error?) -> Void) { + _shutdownGracefully { + callback(nil) + } + } + + public func shutdownGracefully() async throws { + try await withCheckedThrowingContinuation { continuation in + _shutdownGracefully { + continuation.resume(returning: ()) + } + } + } + + private func _shutdownGracefully(completion: (@Sendable () -> Void)? = nil) { + if shutdownFlag.exchange(true, ordering: .acquiring) { + completion?() + return + } + + Task { + let remaining = await workQueue.shutdown() + for item in remaining { + item.workItem(.cancelled) + } + + workerTasks.withLockedValue { mutableWorkerTasks in + for worker in mutableWorkerTasks { + worker.cancel() + } + mutableWorkerTasks.removeAll() + } + + started.store(false, ordering: .releasing) + completion?() + } + } + + // MARK: - Worker infrastructure + + private func startWorkersIfNeeded() { + if self.shutdownFlag.load(ordering: .acquiring) { + return + } + + if self.started.compareExchange(expected: false, desired: true, ordering: .acquiring).exchanged { + spawnWorkers() + } + } + + private func spawnWorkers() { + workerTasks.withLockedValue { mutableWorkerTasks in + guard mutableWorkerTasks.isEmpty else { return } + for index in 0..() + private var waiters: [CheckedContinuation] = [] + private var isShuttingDown = false + + func enqueue(_ item: IdentifiableWorkItem) { + if let continuation = waiters.popLast() { + continuation.resume(returning: item) + } else { + queue.append(item) + } + } + + func nextWorkItem(shutdownFlag: borrowing Atomic) async -> IdentifiableWorkItem? { + if !queue.isEmpty { + return queue.removeFirst() + } + + if isShuttingDown || shutdownFlag.load(ordering: .acquiring) { + return nil + } + + return await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func shutdown() -> [IdentifiableWorkItem] { + isShuttingDown = true + let remaining = Array(queue) + queue.removeAll() + while let waiter = waiters.popLast() { + waiter.resume(returning: nil) + } + return remaining + } + } + + private struct _UncheckedSendable: @unchecked Sendable { + let value: T + init(_ value: T) { self.value = value } + } +} + +#endif // os(WASI) || (canImport(Testing) && !os(Android)) diff --git a/Sources/NIOAsyncRuntime/README.md b/Sources/NIOAsyncRuntime/README.md new file mode 100644 index 00000000000..95b700cfe44 --- /dev/null +++ b/Sources/NIOAsyncRuntime/README.md @@ -0,0 +1,184 @@ +# NIOAsyncRuntime + +NIOAsyncRuntime provides a lightweight implementation of `NIOPosix.MultiThreadedEventLoopGroup` (ie. AsyncEventLoopGroup) +and `NIOPosix.NIOThreadPool` (ie. AsyncThreadPool)that can be used as a drop-in +replacement for the original implementations in NIOPosix, for platforms such as WASI that NIOPosix doesn't support. + +NIOAsyncRuntime is powered by Swift Concurrency and avoids low-level operating system C API calls. This enables +compiling to WebAssembly using the [Swift SDK for WebAssembly](https://www.swift.org/documentation/articles/wasm-getting-started.html) + +## Highlights + +- Drop-in `MultiThreadedEventLoopGroup` and `NIOThreadPool` implementations that enable avoiding `NIOPosix` dependencies. +- Uses Swift Concurrency tasks under the hood. +- Matches the existing NIOPosix APIs, making adoption straightforward. + +## Known Limitations + +- NIOPosix currently provides significantly faster performance in benchmarks for heavy-load event enqueuing. See the benchmarks below for details. +- `AsyncEventLoop` has a scalability limit when a single process enqueues millions of long-delay `scheduleTask(in:)` calls under memory-constrained Linux CI environments. This is not representative of normal workloads, but can manifest in benchmarks. See the benchmarks section below for details. + +# Getting Started + +## Requirements + +- Swift 6.0 or later toolchain +- Any platform supporting Swift Concurrency +- Minimum supported platforms: macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, WASI 0.1 + +## Swift Package Manager + +### Using NIOAsyncRuntime + NIOPosix side-by-side + +Use NIOAsyncRuntime only for platforms where NIOPosix is unsupported. + +Add the package to your `Package.swift`: + +```swift +targets: [ + .target( + name: "YourTarget", + dependencies: [ + // WASI targets use NIOAsyncRuntime + .product( + name: "NIOAsyncRuntime", + package: "swift-nio", + condition: .when(platforms: [.wasi]) + ), + + // NIOPosix is automatically elided for WASI platforms + .product( + name: "NIOPosix", + package: "swift-nio", + ), + ] + ), +] +``` + +## Importing + +You can opt in to the async runtime or fall back to `NIOPosix` with a simple conditional import and type aliases. + +```swift +#if canImport(NIOAsyncRuntime) +import NIOAsyncRuntime // Empty for non-WASI +typealias MultiThreadedEventLoopGroup = AsyncEventLoopGroup // If needed +typealias NIOThreadPool = AsyncThreadPool // If needed +#endif +import NIOPosix // <- Empty for WASI + +let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + +``` + +# Usage Examples + +## Event loops with `AsyncEventLoopGroup` + +```swift +import protocol NIOCore.EventLoopGroup +import class NIOAsyncRuntime.AsyncEventLoopGroup + +let group = AsyncEventLoopGroup() + +let loop = group.next() +let future = loop.submit { + "Hello World!" +} + +future.whenSuccess { value in + print(value) +} + +// Shutdown when done +do { + try await group.shutdownGracefully() + print("Shutdown status: OK") +} catch { + print("Shutdown status:", error) +} +``` + +## Thread pool work with `AsyncThreadPool` + +```swift +import protocol NIOCore.EventLoopGroup +import class NIOAsyncRuntime.AsyncEventLoopGroup +import class NIOAsyncRuntime.AsyncThreadPool + +let pool = AsyncThreadPool() +pool.start() + +let loop = AsyncEventLoop() +let future = pool.runIfActive(eventLoop: loop) { + return "Welcome to the Future!" +} + +let result = try await future.get() +print("Result:", result) + +// Clean up +do { + try await loop.shutdownGracefully() + try await pool.shutdownGracefully() + print("Shutdown status: OK") +} catch { + print("Shutdown status:", error) +} +``` + +# Benchmarks + +## Performance vs NIOPosix + +NIOAsyncRuntime is currently significantly less performant than NIOPosix. Below are benchmark results run against both frameworks. + +| Benchmark | NIOPosix | NIOAsyncRuntime | +| -------------------------------------------------------- | ----------------: | --------------: | +| Jump to EL and back using actor with EL executor | **1.44x faster** | 1.00x | +| Jump to EL and back using execute and unsafecontinuation | **1.31x faster** | 1.00x | +| MTELG.scheduleCallback(in:) | **11.71x faster** | 1.00x | +| MTELG.scheduleTask(in:) | **4.06x faster** | 1.00x | +| MTELG.immediateTasksThroughput | **4.92x faster** | 1.00x | + +## Scalability limitations in `MTELG.scheduleTask(in:_:)` + +The benchmark case `NIOAsyncRuntimeBenchmarks:MTELG.scheduleTask(in:_:)` creates a synthetic stress profile by repeatedly scheduling far-future tasks (`.hours(1)`) at high volume. + +At `.mega` scale with `maxIterations: 5`, this benchmark can enqueue millions of scheduled tasks in a single run. In constrained Linux CI environments (for example, 2 GB container memory), the benchmark process may terminate (`WaitPIDError`, `error code [9]`). This indicates a scalability limit for this specific synthetic pattern. The behavior has been observed on Linux with Swift 6.1 and Swift 6.2. In local macOS arm64 testing, including `.mega` runs, this crash was not reproduced. + +Expected operating envelope: + +- This limitation is primarily relevant to synthetic stress levels in the millions of scheduled operations per run. +- The `.kilo` scale setting keeps this benchmark in a stable range for CI while still exercising feature parity behavior. +- Based on current validation, this issue appears specific to constrained Linux benchmark environments and was not reproducible on macOS. +- Normal service workloads are expected to remain well below this stress level and are not expected to encounter this failure mode. + +This scalability target is currently out of scope and would likely require significant implementation changes. To keep CI reliable while preserving parity coverage, `NIOAsyncRuntimeBenchmarks.MTELG.scheduleTask(in:_:)` uses `.kilo`, while `NIOPosix` remains at `.mega`. + +To reproduce locally in a 2 GB constrained Linux container (Swift 6.1), use the following command: + +```bash +# First, temporarily change: +# Benchmarks/Benchmarks/NIOAsyncRuntimeBenchmarks/Benchmarks.swift +# benchmark "MTELG.scheduleTask(in:_:)" +# from: scalingFactor: .kilo +# to: scalingFactor: .mega +# +# Then run the following command: + +docker run --rm --memory=2g --memory-swap=2g -v "$PWD":/swift-nio -w /swift-nio swift:6.1-jammy bash -lc ' +set -uo pipefail +export HOME=/tmp/home +mkdir -p "$HOME" + +apt-get update -y -q +apt-get install -y -q libjemalloc-dev + +swift package --package-path Benchmarks --disable-sandbox benchmark thresholds check \ + --target NIOAsyncRuntimeBenchmarks \ + --filter "MTELG\\.scheduleTask\\(in:_:\\)" \ + --format metricP90AbsoluteThresholds +' +``` diff --git a/Sources/NIOFSFoundationCompat/Data+FileSystem.swift b/Sources/NIOFSFoundationCompat/Data+FileSystem.swift index 2ddcddbce9c..ab71a05bf67 100644 --- a/Sources/NIOFSFoundationCompat/Data+FileSystem.swift +++ b/Sources/NIOFSFoundationCompat/Data+FileSystem.swift @@ -15,8 +15,12 @@ #if canImport(Darwin) || os(Linux) || os(Android) import NIOFS import NIOCore -import NIOFoundationCompat +import NIOFoundationEssentialsCompat +#if canImport(FoundationEssentials) +import struct FoundationEssentials.Data +#else import struct Foundation.Data +#endif @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) extension Data { diff --git a/Sources/NIOFSFoundationCompat/Date+FileInfo.swift b/Sources/NIOFSFoundationCompat/Date+FileInfo.swift index 5b4733cc2c9..1d07078ecc2 100644 --- a/Sources/NIOFSFoundationCompat/Date+FileInfo.swift +++ b/Sources/NIOFSFoundationCompat/Date+FileInfo.swift @@ -14,7 +14,11 @@ import NIOFS +#if canImport(FoundationEssentials) +import struct FoundationEssentials.Date +#else import struct Foundation.Date +#endif extension Date { public init(timespec: FileInfo.Timespec) { diff --git a/Sources/NIOFoundationCompat/Exports.swift b/Sources/NIOFoundationCompat/Exports.swift new file mode 100644 index 00000000000..314a3d93531 --- /dev/null +++ b/Sources/NIOFoundationCompat/Exports.swift @@ -0,0 +1,15 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2026 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +@_exported import NIOFoundationEssentialsCompat diff --git a/Sources/NIOFoundationCompat/ByteBuffer-foundation.swift b/Sources/NIOFoundationEssentialsCompat/ByteBuffer-foundation.swift similarity index 99% rename from Sources/NIOFoundationCompat/ByteBuffer-foundation.swift rename to Sources/NIOFoundationEssentialsCompat/ByteBuffer-foundation.swift index bdc90cd1262..4fbc116d54d 100644 --- a/Sources/NIOFoundationCompat/ByteBuffer-foundation.swift +++ b/Sources/NIOFoundationEssentialsCompat/ByteBuffer-foundation.swift @@ -12,9 +12,14 @@ // //===----------------------------------------------------------------------===// -import Foundation import NIOCore +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + /// Errors that may be thrown by ByteBuffer methods that call into Foundation. public enum ByteBufferFoundationError: Error { /// Attempting to encode the given string failed. diff --git a/Sources/NIOFoundationCompat/Codable+ByteBuffer.swift b/Sources/NIOFoundationEssentialsCompat/Codable+ByteBuffer.swift similarity index 98% rename from Sources/NIOFoundationCompat/Codable+ByteBuffer.swift rename to Sources/NIOFoundationEssentialsCompat/Codable+ByteBuffer.swift index 5e22a414f40..e0cdcb9bd1d 100644 --- a/Sources/NIOFoundationCompat/Codable+ByteBuffer.swift +++ b/Sources/NIOFoundationEssentialsCompat/Codable+ByteBuffer.swift @@ -12,9 +12,14 @@ // //===----------------------------------------------------------------------===// -import Foundation import NIOCore +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + extension ByteBuffer { /// Attempts to decode the `length` bytes from `index` using the `JSONDecoder` `decoder` as `T`. /// diff --git a/Sources/_NIOFileSystemFoundationCompat/Data+FileSystem.swift b/Sources/_NIOFileSystemFoundationCompat/Data+FileSystem.swift index 069a450a693..474a79c2d19 100644 --- a/Sources/_NIOFileSystemFoundationCompat/Data+FileSystem.swift +++ b/Sources/_NIOFileSystemFoundationCompat/Data+FileSystem.swift @@ -15,8 +15,12 @@ #if canImport(Darwin) || os(Linux) || os(Android) import _NIOFileSystem import NIOCore -import NIOFoundationCompat +import NIOFoundationEssentialsCompat +#if canImport(FoundationEssentials) +import struct FoundationEssentials.Data +#else import struct Foundation.Data +#endif @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) extension Data { diff --git a/Sources/_NIOFileSystemFoundationCompat/Date+FileInfo.swift b/Sources/_NIOFileSystemFoundationCompat/Date+FileInfo.swift index 9acb95a3517..678a28ecc7f 100644 --- a/Sources/_NIOFileSystemFoundationCompat/Date+FileInfo.swift +++ b/Sources/_NIOFileSystemFoundationCompat/Date+FileInfo.swift @@ -14,7 +14,11 @@ import _NIOFileSystem +#if canImport(FoundationEssentials) +import struct FoundationEssentials.Date +#else import struct Foundation.Date +#endif extension Date { public init(timespec: FileInfo.Timespec) { diff --git a/Sources/_NIOWASIPlatformCompilationChecks/Empty.swift b/Sources/_NIOWASIPlatformCompilationChecks/Empty.swift new file mode 100644 index 00000000000..971b545070c --- /dev/null +++ b/Sources/_NIOWASIPlatformCompilationChecks/Empty.swift @@ -0,0 +1,19 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2026 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +// NOTE: This file is intentionally empty. The purpose of _NIOWASIPlatformCompilationChecks is +// to consume all modules expected to pass compilation for WASI platforms. +// +// The target is not exposed as a public target in the swift-nio package, and is not +// intended for public consumption as a dependency. diff --git a/Tests/NIOAsyncRuntimeTests/AsyncEventLoopTests.swift b/Tests/NIOAsyncRuntimeTests/AsyncEventLoopTests.swift new file mode 100644 index 00000000000..fc7c7b78405 --- /dev/null +++ b/Tests/NIOAsyncRuntimeTests/AsyncEventLoopTests.swift @@ -0,0 +1,1947 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2017-2026 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Atomics +import Dispatch +import Foundation +import NIOConcurrencyHelpers +import Testing + +@testable import NIOAsyncRuntime +@testable import NIOCore + +@Suite("AsyncEventLoopGroupTests", .serialized, .timeLimit(.minutes(1))) +final class AsyncEventLoopGroupTests { + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + private func makeEventLoop() -> AsyncEventLoop { + AsyncEventLoop(__testOnly_manualTimeMode: true) + } + + private func assertThat( + future: EventLoopFuture, + isFulfilled: Bool, + sourceLocation: SourceLocation = #_sourceLocation + ) async { + let isFutureFulfilled = future.isFulfilled + #expect(isFutureFulfilled == isFulfilled, sourceLocation: sourceLocation) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testSchedule() async throws { + let eventLoop = makeEventLoop() + + let scheduled = eventLoop.scheduleTask(in: .seconds(1)) { true } + + let result: ManagedAtomic = ManagedAtomic(false) + scheduled.futureResult.whenSuccess { + result.store($0, ordering: .sequentiallyConsistent) + } + await eventLoop.run() // run without time advancing should do nothing + await assertThat(future: scheduled.futureResult, isFulfilled: false) + let result2 = result.load(ordering: .sequentiallyConsistent) + #expect(!result2) + + try await eventLoop.__testOnly_advanceTime(by: .seconds(1)) // should fire now + + await assertThat(future: scheduled.futureResult, isFulfilled: true) + let result3 = result.load(ordering: .sequentiallyConsistent) + #expect(result3 == true) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testFlatSchedule() async throws { + let eventLoop = makeEventLoop() + + let scheduled = eventLoop.flatScheduleTask(in: .seconds(1)) { + eventLoop.makeSucceededFuture(true) + } + + let result: ManagedAtomic = ManagedAtomic(false) + scheduled.futureResult.whenSuccess { result.store($0, ordering: .sequentiallyConsistent) } + + await eventLoop.run() // run without time advancing should do nothing + await assertThat(future: scheduled.futureResult, isFulfilled: false) + let result2 = result.load(ordering: .sequentiallyConsistent) + #expect(!result2) + + try await eventLoop.__testOnly_advanceTime(by: .seconds(2)) // should fire now + await assertThat(future: scheduled.futureResult, isFulfilled: true) + + let result3 = result.load(ordering: .sequentiallyConsistent) + #expect(result3) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testScheduledTaskWakesEventLoopFromIdle() async throws { + let eventLoop = AsyncEventLoop(__testOnly_manualTimeMode: false) + + let promise = eventLoop.makePromise(of: Void.self) + + eventLoop.execute { + _ = eventLoop.scheduleTask(in: .milliseconds(50)) { + promise.succeed(()) + } + } + + try await waitForFuture(promise.futureResult, timeout: .milliseconds(500)) + + await #expect(throws: Never.self) { + try await eventLoop.shutdownGracefully() + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testCancellingScheduledTaskPromiseIsFailed() async throws { + let eventLoop = makeEventLoop() + + let executed = ManagedAtomic(false) + let sawCancellation = ManagedAtomic(false) + + let scheduled = eventLoop.scheduleTask(deadline: .now() + .seconds(1)) { + executed.store(true, ordering: .sequentiallyConsistent) + return true + } + + scheduled.futureResult.whenFailure { error in + sawCancellation.store( + error as? EventLoopError == .cancelled, + ordering: .sequentiallyConsistent + ) + } + + scheduled.cancel() + + try await eventLoop.__testOnly_advanceTime(by: .seconds(2)) + + await assertThat(future: scheduled.futureResult, isFulfilled: true) + await #expect(throws: EventLoopError.cancelled) { + try await scheduled.futureResult.get() + } + let executedValue = executed.load(ordering: .sequentiallyConsistent) + let sawCancellationValue = sawCancellation.load(ordering: .sequentiallyConsistent) + #expect(!executedValue) + #expect(sawCancellationValue) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testScheduleCancelled() async throws { + let eventLoop = makeEventLoop() + + let scheduled = eventLoop.scheduleTask(in: .seconds(1)) { true } + + do { + try await eventLoop.__testOnly_advanceTime(by: .milliseconds(500)) // advance halfway to firing time + scheduled.cancel() + try await eventLoop.__testOnly_advanceTime(by: .milliseconds(500)) // advance the rest of the way + _ = try await scheduled.futureResult.get() + Issue.record("We should never reach this point. Cancel should route to catch block") + } catch { + await assertThat(future: scheduled.futureResult, isFulfilled: true) + #expect(error as? EventLoopError == .cancelled) + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testFlatScheduleCancelled() async throws { + let eventLoop = makeEventLoop() + + let scheduled = eventLoop.flatScheduleTask(in: .seconds(1)) { + eventLoop.makeSucceededFuture(true) + } + + do { + try await eventLoop.__testOnly_advanceTime(by: .milliseconds(500)) // advance halfway to firing time + scheduled.cancel() + try await eventLoop.__testOnly_advanceTime(by: .milliseconds(500)) // advance the rest of the way + _ = try await scheduled.futureResult.get() + Issue.record("We should never reach this point. Cancel should route to catch block") + } catch { + await assertThat(future: scheduled.futureResult, isFulfilled: true) + #expect(error as? EventLoopError == .cancelled) + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testScheduleRepeatedTask() throws { + let nanos: NIODeadline = .now() + let initialDelay: TimeAmount = .milliseconds(5) + let delay: TimeAmount = .milliseconds(10) + let count = 5 + let eventLoopGroup = AsyncEventLoopGroup(numberOfThreads: 1) + defer { + #expect(throws: Never.self) { + try eventLoopGroup.syncShutdownGracefully() + } + } + + let counter = ManagedAtomic(0) + let loop = eventLoopGroup.next() + let allDone = DispatchGroup() + allDone.enter() + loop.scheduleRepeatedTask(initialDelay: initialDelay, delay: delay) { repeatedTask -> Void in + #expect(loop.inEventLoop) + let initialValue = counter.load(ordering: .relaxed) + counter.wrappingIncrement(ordering: .relaxed) + if initialValue == 0 { + #expect(NIODeadline.now() - nanos >= initialDelay) + } else if initialValue == count { + repeatedTask.cancel() + allDone.leave() + } + } + + allDone.wait() + + #expect(counter.load(ordering: .relaxed) == count + 1) + #expect(NIODeadline.now() - nanos >= initialDelay + Int64(count) * delay) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testScheduledTaskThatIsImmediatelyCancelledNeverFires() async throws { + let eventLoop = makeEventLoop() + let scheduled = eventLoop.scheduleTask(in: .seconds(1)) { true } + + do { + scheduled.cancel() + try await eventLoop.__testOnly_advanceTime(by: .seconds(1)) + _ = try await scheduled.futureResult.get() + Issue.record("We should never reach this point. Cancel should route to catch block") + } catch { + await assertThat(future: scheduled.futureResult, isFulfilled: true) + #expect(error as? EventLoopError == .cancelled) + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testScheduledTasksAreOrdered() async throws { + let eventLoopGroup = AsyncEventLoopGroup(numberOfThreads: 1) + defer { + #expect(throws: Never.self) { + try eventLoopGroup.syncShutdownGracefully() + } + } + + let eventLoop = eventLoopGroup.next() + let now = NIODeadline.now() + + let result = NIOLockedValueBox([Int]()) + var lastScheduled: Scheduled? + for i in 0...100 { + lastScheduled = eventLoop.scheduleTask(deadline: now) { + result.withLockedValue { $0.append(i) } + } + } + try await lastScheduled?.futureResult.get() + #expect(result.withLockedValue { $0 } == Array(0...100)) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testFlatScheduledTaskThatIsImmediatelyCancelledNeverFires() async throws { + let eventLoop = makeEventLoop() + let scheduled = eventLoop.flatScheduleTask(in: .seconds(1)) { + eventLoop.makeSucceededFuture(true) + } + + do { + scheduled.cancel() + try await eventLoop.__testOnly_advanceTime(by: .seconds(1)) + _ = try await scheduled.futureResult.get() + Issue.record("We should never reach this point. Cancel should route to catch block") + } catch { + await assertThat(future: scheduled.futureResult, isFulfilled: true) + #expect(error as? EventLoopError == .cancelled) + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testRepeatedTaskThatIsImmediatelyCancelledNeverFires() async throws { + let eventLoopGroup = AsyncEventLoopGroup(numberOfThreads: 1) + defer { + #expect(throws: Never.self) { + try eventLoopGroup.syncShutdownGracefully() + } + } + + let loop = eventLoopGroup.next() + loop.execute { + let task = loop.scheduleRepeatedTask(initialDelay: .milliseconds(0), delay: .milliseconds(0)) { task in + Issue.record() + } + task.cancel() + } + try await Task.sleep(for: .milliseconds(100)) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testScheduleRepeatedTaskCancelFromDifferentThread() throws { + let nanos: NIODeadline = .now() + let initialDelay: TimeAmount = .milliseconds(5) + // this will actually force the race from issue #554 to happen frequently + let delay: TimeAmount = .milliseconds(0) + let eventLoopGroup = AsyncEventLoopGroup(numberOfThreads: 1) + defer { + #expect(throws: Never.self) { + try eventLoopGroup.syncShutdownGracefully() + } + } + + let hasFiredGroup = DispatchGroup() + let isCancelledGroup = DispatchGroup() + let loop = eventLoopGroup.next() + hasFiredGroup.enter() + isCancelledGroup.enter() + + let (isAllowedToFire, hasFired) = try! loop.submit { + let isAllowedToFire = NIOLoopBoundBox(true, eventLoop: loop) + let hasFired = NIOLoopBoundBox(false, eventLoop: loop) + return (isAllowedToFire, hasFired) + }.wait() + + let repeatedTask = loop.scheduleRepeatedTask(initialDelay: initialDelay, delay: delay) { + (_: RepeatedTask) -> Void in + #expect(loop.inEventLoop) + if !hasFired.value { + // we can only do this once as we can only leave the DispatchGroup once but we might lose a race and + // the timer might fire more than once (until `shouldNoLongerFire` becomes true). + hasFired.value = true + hasFiredGroup.leave() + } + #expect(isAllowedToFire.value) + } + hasFiredGroup.notify(queue: DispatchQueue.global()) { + repeatedTask.cancel() + loop.execute { + // only now do we know that the `cancel` must have gone through + isAllowedToFire.value = false + isCancelledGroup.leave() + } + } + + hasFiredGroup.wait() + #expect(NIODeadline.now() - nanos >= initialDelay) + isCancelledGroup.wait() + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testScheduleRepeatedTaskToNotRetainRepeatedTask() throws { + let initialDelay: TimeAmount = .milliseconds(5) + let delay: TimeAmount = .milliseconds(10) + let eventLoopGroup = AsyncEventLoopGroup(numberOfThreads: 1) + + weak var weakRepeated: RepeatedTask? + let repeated = eventLoopGroup.next().scheduleRepeatedTask( + initialDelay: initialDelay, + delay: delay + ) { + (_: RepeatedTask) -> Void in + } + weakRepeated = repeated + #expect(weakRepeated != nil) + repeated.cancel() + #expect(throws: Never.self) { + try eventLoopGroup.syncShutdownGracefully() + } + assert(weakRepeated == nil, within: .seconds(1)) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testScheduleRepeatedTaskToNotRetainEventLoop() throws { + weak var weakEventLoop: EventLoop? = nil + let initialDelay: TimeAmount = .milliseconds(5) + let delay: TimeAmount = .milliseconds(10) + let eventLoopGroup = AsyncEventLoopGroup(numberOfThreads: 1) + weakEventLoop = eventLoopGroup.next() + #expect(weakEventLoop != nil) + + eventLoopGroup.next().scheduleRepeatedTask(initialDelay: initialDelay, delay: delay) { + (_: RepeatedTask) -> Void in + } + + #expect(throws: Never.self) { + try eventLoopGroup.syncShutdownGracefully() + } + assert(weakEventLoop == nil, within: .seconds(1)) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testScheduledRepeatedAsyncTask() async throws { + let eventLoop = makeEventLoop() + nonisolated(unsafe) var counter: Int = 0 + + let repeatedTask = eventLoop.scheduleRepeatedAsyncTask( + initialDelay: .milliseconds(10), + delay: .milliseconds(10) + ) { (_: RepeatedTask) in + counter += 1 + let p = eventLoop.makePromise(of: Void.self) + _ = eventLoop.scheduleTask(in: .milliseconds(10)) { + + p.succeed(()) + } + return p.futureResult + } + for _ in 0..<10 { + // just running shouldn't do anything + await eventLoop.run() + } + + // At t == 0, counter == 0 + #expect(0 == counter) + + // At t == 5, counter == 0 + try await eventLoop.__testOnly_advanceTime(by: .milliseconds(5)) + await eventLoop.run() + #expect(0 == counter) + + // At == 10ms, counter == 1 + try await eventLoop.__testOnly_advanceTime(by: .milliseconds(5)) + await eventLoop.run() + #expect(1 == counter) + + // At t == 15ms, counter == 1 + try await eventLoop.__testOnly_advanceTime(by: .milliseconds(5)) + await eventLoop.run() + #expect(1 == counter) + + // At t == 20, counter == 1 (because the task takes 10ms to execute) + try await eventLoop.__testOnly_advanceTime(by: .milliseconds(5)) + #expect(1 == counter) + + // At t == 25, counter == 1 (because the task takes 10ms to execute) + try await eventLoop.__testOnly_advanceTime(by: .milliseconds(5)) + #expect(1 == counter) + + // At t == 30ms, counter == 2 + try await eventLoop.__testOnly_advanceTime(by: .milliseconds(5)) + #expect(2 == counter) + + // At t == 40ms, counter == 2 + try await eventLoop.__testOnly_advanceTime(by: .milliseconds(10)) + #expect(2 == counter) + + // At t == 50ms, counter == 3 + try await eventLoop.__testOnly_advanceTime(by: .milliseconds(10)) + #expect(3 == counter) + + // At t == 60ms, counter == 3 + try await eventLoop.__testOnly_advanceTime(by: .milliseconds(10)) + #expect(3 == counter) + + // At t == 70ms, counter == 4 (not testing to allow a large jump in time advancement) + // At t == 80ms, counter == 4 (not testing to allow a large jump in time advancement) + + // At t == 89ms, counter == 4 + // NOTE: The jump by 29 seconds here covers edge cases + // to ensure the scheduling properly re-triggers every 20 seconds, even + // when the time advancement exceeds 20 seconds. + try await eventLoop.__testOnly_advanceTime(by: .milliseconds(29)) + #expect(4 == counter) + + // At t == 90ms, counter == 5 + try await eventLoop.__testOnly_advanceTime(by: .milliseconds(1)) + #expect(5 == counter) + + // Stop repeating. + repeatedTask.cancel() + + // At t > 90ms, counter stays at 5 because repeating is stopped + await eventLoop.run() + #expect(5 == counter) + + // Event after 10 hours, counter stays at 5, because repeating is stopped + try await eventLoop.__testOnly_advanceTime(by: .hours(10)) + #expect(5 == counter) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testScheduledRepeatedAsyncTaskIsJittered() async throws { + let initialDelay = TimeAmount.minutes(5) + let delay = TimeAmount.minutes(5) + let maximumAllowableJitter = TimeAmount.minutes(1) + let counter = ManagedAtomic(0) + let loop = makeEventLoop() + let completionDelay = TimeAmount.milliseconds(10) + + _ = loop.scheduleRepeatedAsyncTask( + initialDelay: initialDelay, + delay: delay, + maximumAllowableJitter: maximumAllowableJitter, + { _ in + counter.wrappingIncrement(ordering: .relaxed) + let p = loop.makePromise(of: Void.self) + _ = loop.scheduleTask(in: completionDelay) { + p.succeed(()) + } + return p.futureResult + } + ) + + for _ in 0..<10 { + // just running shouldn't do anything + await loop.run() + } + + try await loop.__testOnly_advanceTime(by: initialDelay - .nanoseconds(1)) + #expect(counter.load(ordering: .relaxed) == 0) + try await loop.__testOnly_advanceTime(by: maximumAllowableJitter + .nanoseconds(1)) + #expect(counter.load(ordering: .relaxed) == 1) + + var uncertainty = maximumAllowableJitter + for expectedExecutionCount in 2...4 { + try await loop.__testOnly_advanceTime( + by: delay + completionDelay - uncertainty - .nanoseconds(1) + ) + #expect(counter.load(ordering: .relaxed) == Int64(expectedExecutionCount - 1)) + try await loop.__testOnly_advanceTime(by: uncertainty + maximumAllowableJitter + .nanoseconds(1)) + #expect(counter.load(ordering: .relaxed) == Int64(expectedExecutionCount)) + uncertainty += maximumAllowableJitter + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testEventLoopGroupMakeIterator() throws { + let eventLoopGroup = AsyncEventLoopGroup(numberOfThreads: System.coreCount) + defer { + #expect(throws: Never.self) { + try eventLoopGroup.syncShutdownGracefully() + } + } + + var counter = 0 + var innerCounter = 0 + for loop in eventLoopGroup.makeIterator() { + counter += 1 + for _ in loop.makeIterator() { + innerCounter += 1 + } + } + + #expect(counter == System.coreCount) + #expect(innerCounter == System.coreCount) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testEventLoopMakeIterator() async throws { + let eventLoop = makeEventLoop() + let iterator = eventLoop.makeIterator() + + var counter = 0 + for loop in iterator { + #expect(loop === eventLoop) + counter += 1 + } + + #expect(counter == 1) + + try await eventLoop.shutdownGracefully() + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testShutdownWhileScheduledTasksNotReady() throws { + let group = AsyncEventLoopGroup(numberOfThreads: 1) + let eventLoop = group.next() + _ = eventLoop.scheduleTask(in: .hours(1)) {} + try group.syncShutdownGracefully() + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testScheduleMultipleTasks() async throws { + let eventLoopGroup = AsyncEventLoopGroup(numberOfThreads: 1) + defer { + #expect(throws: Never.self) { + try eventLoopGroup.syncShutdownGracefully() + } + } + + let eventLoop = eventLoopGroup.next() + let array = try! await eventLoop.submit { + NIOLoopBoundBox([(Int, NIODeadline)](), eventLoop: eventLoop) + }.get() + let scheduled1 = eventLoop.scheduleTask(in: .milliseconds(500)) { + array.value.append((1, .now())) + } + + let scheduled2 = eventLoop.scheduleTask(in: .milliseconds(100)) { + array.value.append((2, .now())) + } + + let scheduled3 = eventLoop.scheduleTask(in: .milliseconds(1000)) { + array.value.append((3, .now())) + } + + var result = try await eventLoop.scheduleTask(in: .milliseconds(1000)) { + array.value + }.futureResult.get() + + await assertThat(future: scheduled1.futureResult, isFulfilled: true) + await assertThat(future: scheduled2.futureResult, isFulfilled: true) + await assertThat(future: scheduled3.futureResult, isFulfilled: true) + + let first = result.removeFirst() + #expect(2 == first.0) + let second = result.removeFirst() + #expect(1 == second.0) + let third = result.removeFirst() + #expect(3 == third.0) + + #expect(first.1 < second.1) + #expect(second.1 < third.1) + + #expect(result.isEmpty) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testRepeatedTaskThatIsImmediatelyCancelledNotifies() async throws { + let eventLoopGroup = AsyncEventLoopGroup(numberOfThreads: 1) + defer { + #expect(throws: Never.self) { + try eventLoopGroup.syncShutdownGracefully() + } + } + + let loop = eventLoopGroup.next() + let promise1: EventLoopPromise = loop.makePromise() + let promise2: EventLoopPromise = loop.makePromise() + try await confirmation(expectedCount: 2) { confirmation in + promise1.futureResult.whenSuccess { confirmation() } + promise2.futureResult.whenSuccess { confirmation() } + loop.execute { + let task = loop.scheduleRepeatedTask( + initialDelay: .milliseconds(0), + delay: .milliseconds(0), + notifying: promise1 + ) { task in + Issue.record() + } + task.cancel(promise: promise2) + } + + // NOTE: Must allow a few cycles for executor to run, same as in + // testRepeatedTaskThatIsImmediatelyCancelledNotifies test for NIOPosix + try await Task.sleep(for: .milliseconds(100)) + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testRepeatedTaskThatIsCancelledAfterRunningAtLeastTwiceNotifies() async throws { + let eventLoopGroup = AsyncEventLoopGroup(numberOfThreads: 1) + defer { + #expect(throws: Never.self) { + try eventLoopGroup.syncShutdownGracefully() + } + } + + let loop = eventLoopGroup.next() + let promise1: EventLoopPromise = loop.makePromise() + let promise2: EventLoopPromise = loop.makePromise() + + // Wait for task to notify twice + var task: RepeatedTask? + nonisolated(unsafe) var confirmCount = 0 + let minimumExpectedCount = 2 + try await confirmation(expectedCount: minimumExpectedCount) { confirmation in + task = loop.scheduleRepeatedTask( + initialDelay: .milliseconds(0), + delay: .milliseconds(10), + notifying: promise1 + ) { task in + // We need to confirm two or more occur + if confirmCount < minimumExpectedCount { + confirmation() + confirmCount += 1 + } + } + try await Task.sleep(for: .seconds(1)) + } + let cancellationHandle = try #require(task) + + try await confirmation(expectedCount: 2) { confirmation in + promise1.futureResult.whenSuccess { confirmation() } + promise2.futureResult.whenSuccess { confirmation() } + cancellationHandle.cancel(promise: promise2) + try await Task.sleep(for: .seconds(1)) + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testRepeatedTaskThatCancelsItselfNotifiesOnlyWhenFinished() async throws { + let eventLoopGroup = AsyncEventLoopGroup(numberOfThreads: 1) + defer { + #expect(throws: Never.self) { + try eventLoopGroup.syncShutdownGracefully() + } + } + + let loop = eventLoopGroup.next() + let promise1: EventLoopPromise = loop.makePromise() + let promise2: EventLoopPromise = loop.makePromise() + let semaphore = DispatchSemaphore(value: 0) + loop.scheduleRepeatedTask( + initialDelay: .milliseconds(0), + delay: .milliseconds(0), + notifying: promise1 + ) { + task -> Void in + task.cancel(promise: promise2) + semaphore.wait() + } + + nonisolated(unsafe) var expectFail1 = false + nonisolated(unsafe) var expectFail2 = false + nonisolated(unsafe) var expect1 = false + nonisolated(unsafe) var expect2 = false + promise1.futureResult.whenSuccess { + expectFail1 = true + expect1 = true + } + promise2.futureResult.whenSuccess { + expectFail2 = true + expect2 = true + } + try await Task.sleep(for: .milliseconds(500)) + #expect(!expectFail1) + #expect(!expectFail2) + semaphore.signal() + try await Task.sleep(for: .milliseconds(500)) + #expect(expect1) + #expect(expect2) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testRepeatedTaskIsJittered() async throws { + let initialDelay = TimeAmount.minutes(5) + let delay = TimeAmount.minutes(5) + let maximumAllowableJitter = TimeAmount.minutes(1) + let counter = ManagedAtomic(0) + let loop = makeEventLoop() + + _ = loop.scheduleRepeatedTask( + initialDelay: initialDelay, + delay: delay, + maximumAllowableJitter: maximumAllowableJitter, + { _ in + counter.wrappingIncrement(ordering: .relaxed) + } + ) + + try await loop.__testOnly_advanceTime(by: initialDelay - .nanoseconds(1)) + #expect(counter.load(ordering: .relaxed) == 0) + try await loop.__testOnly_advanceTime(by: maximumAllowableJitter + .nanoseconds(1)) + #expect(counter.load(ordering: .relaxed) == 1) + + var uncertainty = maximumAllowableJitter + for expectedExecutionCount in 2...4 { + try await loop.__testOnly_advanceTime(by: delay - uncertainty - .nanoseconds(1)) + #expect(counter.load(ordering: .relaxed) == Int64(expectedExecutionCount - 1)) + try await loop.__testOnly_advanceTime(by: uncertainty + maximumAllowableJitter + .nanoseconds(1)) + #expect(counter.load(ordering: .relaxed) == Int64(expectedExecutionCount)) + uncertainty += maximumAllowableJitter + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testCancelledScheduledTasksDoNotHoldOnToRunClosure() async throws { + let group = AsyncEventLoopGroup(numberOfThreads: 1) + defer { + #expect(throws: Never.self) { + try group.syncShutdownGracefully() + } + } + + class Thing: @unchecked Sendable { + private let deallocated: ConditionLock + + init(_ deallocated: ConditionLock) { + self.deallocated = deallocated + } + + deinit { + self.deallocated.lock() + self.deallocated.unlock(withValue: 1) + } + } + + func make(deallocated: ConditionLock) -> Scheduled { + let aThing = Thing(deallocated) + return group.next().scheduleTask(in: .hours(1)) { + preconditionFailure("this should definitely not run: \(aThing)") + } + } + + let deallocated = ConditionLock(value: 0) + let scheduled = make(deallocated: deallocated) + scheduled.cancel() + if deallocated.lock(whenValue: 1, timeoutSeconds: 60) { + deallocated.unlock() + } else { + Issue.record("Timed out waiting for lock") + } + + await #expect(throws: EventLoopError.cancelled) { + try await scheduled.futureResult.get() + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testCancelledScheduledTasksDoNotHoldOnToRunClosureEvenIfTheyWereTheNextTaskToExecute() + async throws + { + let group = AsyncEventLoopGroup(numberOfThreads: 1) + defer { + #expect(throws: Never.self) { + try group.syncShutdownGracefully() + } + } + + final class Thing: Sendable { + private let deallocated: ConditionLock + + init(_ deallocated: ConditionLock) { + self.deallocated = deallocated + } + + deinit { + self.deallocated.lock() + self.deallocated.unlock(withValue: 1) + } + } + + func make(deallocated: ConditionLock) -> Scheduled { + let aThing = Thing(deallocated) + return group.next().scheduleTask(in: .hours(1)) { + preconditionFailure("this should definitely not run: \(aThing)") + } + } + + // What are we doing here? + // + // Our goal is to arrange for our scheduled task to become "nextReadyTask" in SelectableEventLoop, so that + // when we cancel it there is still a copy aliasing it. This reproduces a subtle correctness bug that + // existed in NIO 2.48.0 and earlier. + // + // This will happen if: + // + // 1. We schedule a task for the future + // 2. The event loop begins a tick. + // 3. The event loop finds our scheduled task in the future. + // + // We can make that happen by scheduling our task and then waiting for a tick to pass, which we can + // achieve using `submit`. + // + // However, if there are no _other_, _even later_ tasks, we'll free the reference. This is + // because the nextReadyTask is cleared if the list of scheduled tasks ends up empty, so we don't want that to happen. + // + // So the order of operations is: + // + // 1. Schedule the task for the future. + // 2. Schedule another, even later, task. + // 3. Wait for a tick to pass. + // 4. Cancel our scheduled. + // + // In the correct code, this should invoke deinit. In the buggy code, it does not. + // + // Unfortunately, this window is very hard to hit. Cancelling the scheduled task wakes the loop up, and if it is + // still awake by the time we run the cancellation handler it'll notice the change. So we have to tolerate + // a somewhat flaky test. + let deallocated = ConditionLock(value: 0) + let scheduled = make(deallocated: deallocated) + scheduled.futureResult.eventLoop.scheduleTask(in: .hours(2)) {} + try! await scheduled.futureResult.eventLoop.submit {}.get() + scheduled.cancel() + if deallocated.lock(whenValue: 1, timeoutSeconds: 60) { + deallocated.unlock() + } else { + Issue.record("Timed out waiting for lock") + } + + await #expect(throws: EventLoopError.cancelled) { + try await scheduled.futureResult.get() + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testIllegalCloseOfEventLoopFails() { + // Vapor 3 closes EventLoops directly which is illegal and makes the `shutdownGracefully` of the owning + // MultiThreadedEventLoopGroup never succeed. + let group = AsyncEventLoopGroup(numberOfThreads: 1) + defer { + #expect(throws: Never.self) { + try group.syncShutdownGracefully() + } + } + + #expect(throws: EventLoopError.unsupportedOperation) { + try group.next().syncShutdownGracefully() + } + } + + @Test + func testSubtractingDeadlineFromPastAndFuturesDeadlinesWorks() async throws { + let older = NIODeadline.now() + try await Task.sleep(for: .milliseconds(20)) + let newer = NIODeadline.now() + + #expect(older - newer < .nanoseconds(0)) + #expect(newer - older > .nanoseconds(0)) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testCallingSyncShutdownGracefullyMultipleTimesShouldNotHang() throws { + let elg = AsyncEventLoopGroup(numberOfThreads: 4) + try elg.syncShutdownGracefully() + try elg.syncShutdownGracefully() + try elg.syncShutdownGracefully() + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testCallingShutdownGracefullyMultipleTimesShouldExecuteAllCallbacks() throws { + let elg = AsyncEventLoopGroup(numberOfThreads: 4) + let condition: ConditionLock = ConditionLock(value: 0) + elg.shutdownGracefully { _ in + if condition.lock(whenValue: 0, timeoutSeconds: 1) { + condition.unlock(withValue: 1) + } + } + elg.shutdownGracefully { _ in + if condition.lock(whenValue: 1, timeoutSeconds: 1) { + condition.unlock(withValue: 2) + } + } + elg.shutdownGracefully { _ in + if condition.lock(whenValue: 2, timeoutSeconds: 1) { + condition.unlock(withValue: 3) + } + } + + guard condition.lock(whenValue: 3, timeoutSeconds: 1) else { + Issue.record("Not all shutdown callbacks have been executed") + return + } + condition.unlock() + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testEdgeCasesNIODeadlineMinusNIODeadline() { + let smallestPossibleDeadline = NIODeadline.uptimeNanoseconds(.min) + let largestPossibleDeadline = NIODeadline.uptimeNanoseconds(.max) + let distantFuture = NIODeadline.distantFuture + let distantPast = NIODeadline.distantPast + let zeroDeadline = NIODeadline.uptimeNanoseconds(0) + let nowDeadline = NIODeadline.now() + + let allDeadlines = [ + smallestPossibleDeadline, largestPossibleDeadline, distantPast, distantFuture, + zeroDeadline, nowDeadline, + ] + + for deadline1 in allDeadlines { + for deadline2 in allDeadlines { + if deadline1 > deadline2 { + #expect(deadline1 - deadline2 > TimeAmount.nanoseconds(0)) + } else if deadline1 < deadline2 { + #expect(deadline1 - deadline2 < TimeAmount.nanoseconds(0)) + } else { + // they're equal. + #expect(deadline1 - deadline2 == TimeAmount.nanoseconds(0)) + } + } + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testEdgeCasesNIODeadlinePlusTimeAmount() { + let smallestPossibleTimeAmount = TimeAmount.nanoseconds(.min) + let largestPossibleTimeAmount = TimeAmount.nanoseconds(.max) + let zeroTimeAmount = TimeAmount.nanoseconds(0) + + let smallestPossibleDeadline = NIODeadline.uptimeNanoseconds(.min) + let largestPossibleDeadline = NIODeadline.uptimeNanoseconds(.max) + let distantFuture = NIODeadline.distantFuture + let distantPast = NIODeadline.distantPast + let zeroDeadline = NIODeadline.uptimeNanoseconds(0) + let nowDeadline = NIODeadline.now() + + for timeAmount in [smallestPossibleTimeAmount, largestPossibleTimeAmount, zeroTimeAmount] { + for deadline in [ + smallestPossibleDeadline, largestPossibleDeadline, distantPast, distantFuture, + zeroDeadline, nowDeadline, + ] { + let (partial, overflow) = Int64(deadline.uptimeNanoseconds).addingReportingOverflow( + timeAmount.nanoseconds + ) + let expectedValue: UInt64 + if overflow { + #expect(timeAmount.nanoseconds > 0) + #expect(deadline.uptimeNanoseconds > 0) + // we cap at distantFuture towards +inf + expectedValue = NIODeadline.distantFuture.uptimeNanoseconds + } else if partial < 0 { + // we cap at 0 towards -inf + expectedValue = 0 + } else { + // otherwise we have a result + expectedValue = .init(partial) + } + #expect((deadline + timeAmount).uptimeNanoseconds == expectedValue) + } + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testEdgeCasesNIODeadlineMinusTimeAmount() { + let smallestPossibleTimeAmount = TimeAmount.nanoseconds(.min) + let largestPossibleTimeAmount = TimeAmount.nanoseconds(.max) + let zeroTimeAmount = TimeAmount.nanoseconds(0) + + let smallestPossibleDeadline = NIODeadline.uptimeNanoseconds(.min) + let largestPossibleDeadline = NIODeadline.uptimeNanoseconds(.max) + let distantFuture = NIODeadline.distantFuture + let distantPast = NIODeadline.distantPast + let zeroDeadline = NIODeadline.uptimeNanoseconds(0) + let nowDeadline = NIODeadline.now() + + for timeAmount in [smallestPossibleTimeAmount, largestPossibleTimeAmount, zeroTimeAmount] { + for deadline in [ + smallestPossibleDeadline, largestPossibleDeadline, distantPast, distantFuture, + zeroDeadline, nowDeadline, + ] { + let (partial, overflow) = Int64(deadline.uptimeNanoseconds).subtractingReportingOverflow( + timeAmount.nanoseconds + ) + let expectedValue: UInt64 + if overflow { + #expect(timeAmount.nanoseconds < 0) + #expect(deadline.uptimeNanoseconds >= 0) + // we cap at distantFuture towards +inf + expectedValue = NIODeadline.distantFuture.uptimeNanoseconds + } else if partial < 0 { + // we cap at 0 towards -inf + expectedValue = 0 + } else { + // otherwise we have a result + expectedValue = .init(partial) + } + #expect((deadline - timeAmount).uptimeNanoseconds == expectedValue) + } + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testSuccessfulFlatSubmit() async throws { + let eventLoop = makeEventLoop() + let future = eventLoop.flatSubmit { + eventLoop.makeSucceededFuture(1) + } + await eventLoop.run() + #expect(throws: Never.self) { + let result = try future.wait() + #expect(result == 1) + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testFailingFlatSubmit() async throws { + enum TestError: Error { case failed } + + let eventLoop = makeEventLoop() + let future = eventLoop.flatSubmit { () -> EventLoopFuture in + eventLoop.makeFailedFuture(TestError.failed) + } + await eventLoop.run() + await #expect(throws: TestError.failed) { + try await future.get() + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testSchedulingTaskOnTheEventLoopWithinTheEventLoopsOnlyTask() throws { + let elg = AsyncEventLoopGroup(numberOfThreads: 1) + defer { + #expect(throws: Never.self) { + try elg.syncShutdownGracefully() + } + } + + let el = elg.next() + let g = DispatchGroup() + g.enter() + el.execute { + // We're the last and only task running, scheduling another task here makes sure that despite not waking + // up the selector, we will still run this task. + el.execute { + g.leave() + } + } + g.wait() + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testCancellingTheLastOutstandingTask() async throws { + let elg = AsyncEventLoopGroup(numberOfThreads: 1) + defer { + #expect(throws: Never.self) { + try elg.syncShutdownGracefully() + } + } + + let el = elg.next() + let task = el.scheduleTask(in: .milliseconds(10)) {} + task.cancel() + // sleep for 15ms which should have the above scheduled (and cancelled) task have caused an unnecessary wakeup. + try await Task.sleep(for: .milliseconds(15)) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testSchedulingTaskOnTheEventLoopWithinTheEventLoopsOnlyScheduledTask() throws { + let elg = AsyncEventLoopGroup(numberOfThreads: 1) + defer { + #expect(throws: Never.self) { + try elg.syncShutdownGracefully() + } + } + + let el = elg.next() + let g = DispatchGroup() + g.enter() + el.scheduleTask(in: .nanoseconds(10)) { // something non-0 + el.execute { + g.leave() + } + } + g.wait() + } + + @Test(.disabled("Description doesn't currently match NIOPosix SelectableEventLoop description")) + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testSelectableEventLoopDescription() { + let elg = AsyncEventLoopGroup(numberOfThreads: 1) + defer { + #expect(throws: Never.self) { + try elg.syncShutdownGracefully() + } + } + + let el: EventLoop = elg.next() + let expectedPrefix = "NIOAsyncRuntime.AsyncEventLoop { " + let expectedContains = "thread = NIOThread(name = NIO-ELT-" + let expectedSuffix = " }" + let desc = el.description + #expect(el.description.starts(with: expectedPrefix), Comment(rawValue: desc)) + #expect(el.description.reversed().starts(with: expectedSuffix.reversed()), Comment(rawValue: desc)) + // let's check if any substring contains the `expectedContains` + #expect( + desc.indices.contains { startIndex in + desc[startIndex...].starts(with: expectedContains) + }, + Comment(rawValue: desc) + ) + } + + @Test(.disabled("ELG description doesn't currently match NIOPosix MTELG description")) + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testMultiThreadedEventLoopGroupDescription() { + let elg: EventLoopGroup = AsyncEventLoopGroup(numberOfThreads: 1) + defer { + #expect(throws: Never.self) { + try elg.syncShutdownGracefully() + } + } + + #expect( + elg.description.starts(with: "NIOAsyncRuntime.MultiThreadedEventLoopGroup { threadPattern = NIO-ELT-"), + Comment(rawValue: elg.description) + ) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testWeFailOutstandingScheduledTasksOnELShutdown() { + let group = AsyncEventLoopGroup(numberOfThreads: 1) + let scheduledTask = group.next().scheduleTask(in: .hours(24)) { + Issue.record("We lost the 24 hour race and aren't even in Le Mans.") + } + let waiter = DispatchGroup() + waiter.enter() + scheduledTask.futureResult.map { _ in + Issue.record("didn't expect success") + }.whenFailure { error in + #expect(.shutdown == error as? EventLoopError) + waiter.leave() + } + + #expect(throws: Never.self) { + try group.syncShutdownGracefully() + } + waiter.wait() + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testSchedulingTaskOnFutureFailedByELShutdownDoesNotMakeUsExplode() throws { + let group = AsyncEventLoopGroup(numberOfThreads: 1) + let scheduledTask = group.next().scheduleTask(in: .hours(24)) { + Issue.record("Task was scheduled in 24 hours, yet it executed.") + } + let waiter = DispatchGroup() + waiter.enter() // first scheduled task + waiter.enter() // scheduled task in the first task's whenFailure. + scheduledTask.futureResult + .map { _ in + Issue.record("didn't expect success") + } + .whenFailure { error in + #expect(.shutdown == error as? EventLoopError) + group.next().execute {} // This previously blew up + group.next().scheduleTask(in: .hours(24)) { + Issue.record("Task was scheduled in 24 hours, yet it executed.") + }.futureResult.map { + Issue.record("didn't expect success") + }.whenFailure { error in + #expect(.shutdown == error as? EventLoopError) + waiter.leave() + } + waiter.leave() + } + + #expect(throws: Never.self) { + try group.syncShutdownGracefully() + } + waiter.wait() + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testEventLoopGroupProvider() { + let eventLoopGroup = AsyncEventLoopGroup(numberOfThreads: 1) + defer { + #expect(throws: Never.self) { + try eventLoopGroup.syncShutdownGracefully() + } + } + + let provider = NIOEventLoopGroupProvider.shared(eventLoopGroup) + + if case .shared(let sharedEventLoopGroup) = provider { + #expect(sharedEventLoopGroup is AsyncEventLoopGroup) + #expect(sharedEventLoopGroup === eventLoopGroup) + } else { + Issue.record("Not the same") + } + } + + // Test that scheduling a task at the maximum value doesn't crash. + // (Crashing resulted from an EINVAL/IOException thrown by the kevent + // syscall when the timeout value exceeded the maximum supported by + // the Darwin kernel #1056). + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testScheduleMaximum() async throws { + let eventLoop = makeEventLoop() + let maxAmount: TimeAmount = .nanoseconds(.max) + let scheduled = eventLoop.scheduleTask(in: maxAmount) { true } + + do { + scheduled.cancel() + _ = try await scheduled.futureResult.get() + Issue.record("Shouldn't reach this point due to cancellation.") + } catch { + await assertThat(future: scheduled.futureResult, isFulfilled: true) + #expect(error as? EventLoopError == .cancelled) + } + } + + @Test + func testEventLoopsWithPreSucceededFuturesCacheThem() { + let el = EventLoopWithPreSucceededFuture() + defer { + #expect(throws: Never.self) { + try el.syncShutdownGracefully() + } + } + + let future1 = el.makeSucceededFuture(()) + let future2 = el.makeSucceededFuture(()) + let future3 = el.makeSucceededVoidFuture() + + #expect(future1 === future2) + #expect(future2 === future3) + } + + @Test + func testEventLoopsWithoutPreSucceededFuturesDoNotCacheThem() { + let el = EventLoopWithoutPreSucceededFuture() + defer { + #expect(throws: Never.self) { + try el.syncShutdownGracefully() + } + } + + let future1 = el.makeSucceededFuture(()) + let future2 = el.makeSucceededFuture(()) + let future3 = el.makeSucceededVoidFuture() + + #expect(future1 !== future2) + #expect(future2 !== future3) + #expect(future1 !== future3) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testSelectableEventLoopHasPreSucceededFuturesOnlyOnTheEventLoop() throws { + let elg = AsyncEventLoopGroup(numberOfThreads: 1) + defer { + #expect(throws: Never.self) { + try elg.syncShutdownGracefully() + } + } + + let el = elg.next() + + let futureOutside1 = el.makeSucceededVoidFuture() + let futureOutside2 = el.makeSucceededFuture(()) + #expect(futureOutside1 !== futureOutside2) + + #expect(throws: Never.self) { + try el.submit { + let futureInside1 = el.makeSucceededVoidFuture() + let futureInside2 = el.makeSucceededFuture(()) + + #expect(futureOutside1 !== futureInside1) + #expect(futureInside1 === futureInside2) + }.wait() + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testMakeCompletedFuture() async throws { + let eventLoop = makeEventLoop() + + #expect(try await eventLoop.makeCompletedFuture(.success("foo")).get() == "foo") + + struct DummyError: Error {} + let future = eventLoop.makeCompletedFuture(Result.failure(DummyError())) + await #expect(throws: DummyError.self) { + try await future.get() + } + + await #expect(throws: Never.self) { + try await eventLoop.shutdownGracefully() + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testMakeCompletedFutureWithResultOf() async throws { + let eventLoop = makeEventLoop() + + #expect(try await eventLoop.makeCompletedFuture(withResultOf: { "foo" }).get() == "foo") + + struct DummyError: Error {} + func throwError() throws { + throw DummyError() + } + + let future = eventLoop.makeCompletedFuture(withResultOf: throwError) + await #expect(throws: DummyError.self) { + try await future.get() + } + + await #expect(throws: Never.self) { + try await eventLoop.shutdownGracefully() + } + } + + @Test + func testMakeCompletedVoidFuture() { + let eventLoop = EventLoopWithPreSucceededFuture() + defer { + #expect(throws: Never.self) { + try eventLoop.syncShutdownGracefully() + } + } + + let future1 = eventLoop.makeCompletedFuture(.success(())) + let future2 = eventLoop.makeSucceededVoidFuture() + let future3 = eventLoop.makeSucceededFuture(()) + #expect(future1 === future2) + #expect(future2 === future3) + } + + @Test(.disabled("AsyncEventLoopGroup disallows direct shutdown of ELG. Must be done through MTELG currently.")) + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testEventLoopGroupsWithoutAnyImplementationAreValid() async throws { + let group = EventLoopGroupOf3WithoutAnAnyImplementation() + + let submitDone = group.any().submit { + let el1 = group.any() + let el2 = group.any() + // our group doesn't support `any()` and will fall back to `next()`. + #expect(el1 !== el2) + } + for el in group.makeIterator() { + await (el as! AsyncEventLoop).run() + } + await #expect(throws: Never.self) { + try await submitDone.get() + } + + await #expect(throws: Never.self) { + try await group.shutdownGracefully() + } + } + + @Test(.disabled("NIOAsyncRuntime MTELG does not currently fully support any() function")) + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testCallingAnyOnAnMTELGThatIsNotSelfDoesNotReturnItself() { + let group1 = AsyncEventLoopGroup(numberOfThreads: 3) + let group2 = AsyncEventLoopGroup(numberOfThreads: 3) + defer { + #expect(throws: Never.self) { + try group2.syncShutdownGracefully() + try group1.syncShutdownGracefully() + } + } + + #expect(throws: Never.self) { + try group1.any().submit { + let el1_1 = group1.any() + let el1_2 = group1.any() + let el2_1 = group2.any() + let el2_2 = group2.any() + + // MTELG _does_ supprt `any()` so all these `EventLoop`s should be the same. + #expect(el1_1 === el1_2) + // MTELG _does_ supprt `any()` but this `any()` call went across `group`s. + #expect(el2_1 !== el2_2) + // different groups... + #expect(el1_1 !== el2_1) + // different groups... + #expect(el1_1 !== el2_2) + }.wait() + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testMultiThreadedEventLoopGroupSupportsStickyAnyImplementation() { + let group = AsyncEventLoopGroup(numberOfThreads: 3) + defer { + #expect(throws: Never.self) { + try group.syncShutdownGracefully() + } + } + + #expect(throws: Never.self) { + try group.any().submit { + let el1 = group.any() + let el2 = group.any() + #expect(el1 === el2) // MTELG _does_ supprt `any()` so all these `EventLoop`s should be the same. + }.wait() + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testAsyncToFutureConversionSuccess() async throws { + let group = AsyncEventLoopGroup(numberOfThreads: 1) + defer { + #expect(throws: Never.self) { + try group.syncShutdownGracefully() + } + } + + let result = try await group.next().makeFutureWithTask { + try await Task.sleep(nanoseconds: 37) + return "hello from async" + }.get() + #expect("hello from async" == result) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testAsyncToFutureConversionFailure() async throws { + let group = AsyncEventLoopGroup(numberOfThreads: 1) + defer { + #expect(throws: Never.self) { + try group.syncShutdownGracefully() + } + } + + struct DummyError: Error {} + + await #expect(throws: DummyError.self) { + try await group.next().makeFutureWithTask { + try await Task.sleep(nanoseconds: 37) + throw DummyError() + }.get() + } + } + + // Test for possible starvation discussed here: https://github.com/apple/swift-nio/pull/2645#discussion_r1486747118 + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testNonStarvation() throws { + let group = AsyncEventLoopGroup(numberOfThreads: 1) + defer { + #expect(throws: Never.self) { + try group.syncShutdownGracefully() + } + } + + let eventLoop = group.next() + let stop = try eventLoop.submit { NIOLoopBoundBox(false, eventLoop: eventLoop) }.wait() + + @Sendable + func reExecuteTask() { + if !stop.value { + eventLoop.execute { + reExecuteTask() + } + } + } + + eventLoop.execute { + // SelectableEventLoop runs batches of up to 4096. + // Submit significantly over that for good measure. + for _ in (0..<10000) { + eventLoop.assumeIsolated().execute(reExecuteTask) + } + } + let stopTask = eventLoop.scheduleTask(in: .microseconds(10)) { + stop.value = true + } + try stopTask.futureResult.wait() + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testMixedImmediateAndScheduledTasks() async throws { + let group = AsyncEventLoopGroup(numberOfThreads: 1) + defer { + #expect(throws: Never.self) { + try group.syncShutdownGracefully() + } + } + + let eventLoop = group.next() + let scheduledTaskMagic = 17 + let scheduledTask = eventLoop.scheduleTask(in: .microseconds(10)) { + scheduledTaskMagic + } + + let immediateTaskMagic = 18 + let immediateTask = eventLoop.submit { + immediateTaskMagic + } + + let scheduledTaskMagicOut = try await scheduledTask.futureResult.get() + #expect(scheduledTaskMagicOut == scheduledTaskMagic) + + let immediateTaskMagicOut = try await immediateTask.get() + #expect(immediateTaskMagicOut == immediateTaskMagic) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testLotsOfMixedImmediateAndScheduledTasks() async throws { + let group = AsyncEventLoopGroup(numberOfThreads: 1) + defer { + #expect(throws: Never.self) { + try group.syncShutdownGracefully() + } + } + + let eventLoop = group.next() + struct Counter: Sendable { + private var _submitCount = NIOLockedValueBox(0) + var submitCount: Int { + get { self._submitCount.withLockedValue { $0 } } + nonmutating set { self._submitCount.withLockedValue { $0 = newValue } } + } + private var _scheduleCount = NIOLockedValueBox(0) + var scheduleCount: Int { + get { self._scheduleCount.withLockedValue { $0 } } + nonmutating set { self._scheduleCount.withLockedValue { $0 = newValue } } + } + } + + let achieved = Counter() + var immediateTasks = [EventLoopFuture]() + var scheduledTasks = [Scheduled]() + for _ in (0..<100_000) { + if Bool.random() { + let task = eventLoop.submit { + achieved.submitCount += 1 + } + immediateTasks.append(task) + } + if Bool.random() { + let task = eventLoop.scheduleTask(in: .microseconds(10)) { + achieved.scheduleCount += 1 + } + scheduledTasks.append(task) + } + } + + let submitCount = try await EventLoopFuture.whenAllSucceed(immediateTasks, on: eventLoop).map({ + _ in + achieved.submitCount + }).get() + #expect(submitCount == achieved.submitCount) + + let scheduleCount = try await EventLoopFuture.whenAllSucceed( + scheduledTasks.map { $0.futureResult }, + on: eventLoop + ) + .map({ _ in + achieved.scheduleCount + }).get() + #expect(scheduleCount == scheduledTasks.count) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testLotsOfMixedImmediateAndScheduledTasksFromEventLoop() async throws { + let group = AsyncEventLoopGroup(numberOfThreads: 1) + defer { + #expect(throws: Never.self) { + try group.syncShutdownGracefully() + } + } + + let eventLoop = group.next() + struct Counter: Sendable { + private var _submitCount = NIOLockedValueBox(0) + var submitCount: Int { + get { self._submitCount.withLockedValue { $0 } } + nonmutating set { self._submitCount.withLockedValue { $0 = newValue } } + } + private var _scheduleCount = NIOLockedValueBox(0) + var scheduleCount: Int { + get { self._scheduleCount.withLockedValue { $0 } } + nonmutating set { self._scheduleCount.withLockedValue { $0 = newValue } } + } + } + + let achieved = Counter() + let (immediateTasks, scheduledTasks) = try await eventLoop.submit { + var immediateTasks = [EventLoopFuture]() + var scheduledTasks = [Scheduled]() + for _ in (0..<100_000) { + if Bool.random() { + let task = eventLoop.submit { + achieved.submitCount += 1 + } + immediateTasks.append(task) + } + if Bool.random() { + let task = eventLoop.scheduleTask(in: .microseconds(10)) { + achieved.scheduleCount += 1 + } + scheduledTasks.append(task) + } + } + return (immediateTasks, scheduledTasks) + }.get() + + let submitCount = try await EventLoopFuture.whenAllSucceed(immediateTasks, on: eventLoop) + .map({ _ in + achieved.submitCount + }).get() + #expect(submitCount == achieved.submitCount) + + let scheduleCount = try await EventLoopFuture.whenAllSucceed( + scheduledTasks.map { $0.futureResult }, + on: eventLoop + ) + .map({ _ in + achieved.scheduleCount + }).get() + #expect(scheduleCount == scheduledTasks.count) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testImmediateTasksDontGetStuck() async throws { + let group = AsyncEventLoopGroup(numberOfThreads: 1) + defer { + #expect(throws: Never.self) { + try group.syncShutdownGracefully() + } + } + + let eventLoop = group.next() + let testEventLoop = AsyncEventLoopGroup.singleton.any() + + let longWait = TimeAmount.seconds(60) + let failDeadline = NIODeadline.now() + longWait + let (immediateTasks, scheduledTask) = try await eventLoop.submit { + // Submit over the 4096 immediate tasks, and some scheduled tasks + // with expiry deadline in (nearish) future. + // We want to make sure immediate tasks, even those that don't fit + // in the first batch, don't get stuck waiting for scheduled task + // expiry + let immediateTasks = (0..<5000).map { _ in + eventLoop.submit {}.hop(to: testEventLoop) + } + let scheduledTask = eventLoop.scheduleTask(in: longWait) { + } + + return (immediateTasks, scheduledTask) + }.get() + + // The immediate tasks should all succeed ~immediately. + // We're testing for a case where the EventLoop gets confused + // into waiting for the scheduled task expiry to complete + // some immediate tasks. + _ = try await EventLoopFuture.whenAllSucceed(immediateTasks, on: testEventLoop).get() + #expect(.now() < failDeadline) + + scheduledTask.cancel() + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testInEventLoopABAProblem() async throws { + // Older SwiftNIO versions had a bug here, they held onto `pthread_t`s for ever (which is illegal) and then + // used `pthread_equal(pthread_self(), myPthread)`. `pthread_equal` just compares the pointer values which + // means there's an ABA problem here. This test checks that we don't suffer from that issue now. + let allELs: NIOLockedValueBox<[any EventLoop]> = NIOLockedValueBox([]) + + for _ in 0..<100 { + let group = AsyncEventLoopGroup(numberOfThreads: 4) + defer { + #expect(throws: Never.self) { + try group.syncShutdownGracefully() + } + } + for loop in group.makeIterator() { + try! await loop.submit { + allELs.withLockedValue { allELs in + #expect(loop.inEventLoop) + for otherEL in allELs { + #expect( + !otherEL.inEventLoop, + "should only be in \(loop) but turns out also in \(otherEL)" + ) + } + allELs.append(loop) + } + }.get() + } + } + } +} + +private final class EventLoopWithPreSucceededFuture: EventLoop { + var inEventLoop: Bool { + true + } + + func execute(_ task: @escaping () -> Void) { + preconditionFailure("not implemented") + } + + func submit(_ task: @escaping () throws -> T) -> EventLoopFuture { + preconditionFailure("not implemented") + } + + var now: NIODeadline { + preconditionFailure("not implemented") + } + + @discardableResult + func scheduleTask(deadline: NIODeadline, _ task: @escaping () throws -> T) -> Scheduled { + preconditionFailure("not implemented") + } + + @discardableResult + func scheduleTask(in: TimeAmount, _ task: @escaping () throws -> T) -> Scheduled { + preconditionFailure("not implemented") + } + + func preconditionInEventLoop(file: StaticString, line: UInt) { + preconditionFailure("not implemented") + } + + func preconditionNotInEventLoop(file: StaticString, line: UInt) { + preconditionFailure("not implemented") + } + + // We'd need to use an IUO here in order to use a loop-bound here (self needs to be initialized + // to create the loop-bound box). That'd require the use of unchecked Sendable. A locked value + // box is fine, it's only tests. + private let _succeededVoidFuture: NIOLockedValueBox?> + + func makeSucceededVoidFuture() -> EventLoopFuture { + guard self.inEventLoop, let voidFuture = self._succeededVoidFuture.withLockedValue({ $0 }) else { + return self.makeSucceededFuture(()) + } + return voidFuture + } + + init() { + self._succeededVoidFuture = NIOLockedValueBox(nil) + self._succeededVoidFuture.withLockedValue { + $0 = EventLoopFuture(eventLoop: self, value: ()) + } + } + + func shutdownGracefully(queue: DispatchQueue, _ callback: @escaping @Sendable (Error?) -> Void) { + self._succeededVoidFuture.withLockedValue { $0 = nil } + queue.async { + callback(nil) + } + } +} + +private final class EventLoopWithoutPreSucceededFuture: EventLoop { + var inEventLoop: Bool { + true + } + + func execute(_ task: @escaping () -> Void) { + preconditionFailure("not implemented") + } + + func submit(_ task: @escaping () throws -> T) -> EventLoopFuture { + preconditionFailure("not implemented") + } + + var now: NIODeadline { + preconditionFailure("not implemented") + } + + @discardableResult + func scheduleTask(deadline: NIODeadline, _ task: @escaping () throws -> T) -> Scheduled { + preconditionFailure("not implemented") + } + + @discardableResult + func scheduleTask(in: TimeAmount, _ task: @escaping () throws -> T) -> Scheduled { + preconditionFailure("not implemented") + } + + func preconditionInEventLoop(file: StaticString, line: UInt) { + preconditionFailure("not implemented") + } + + func preconditionNotInEventLoop(file: StaticString, line: UInt) { + preconditionFailure("not implemented") + } + + func shutdownGracefully(queue: DispatchQueue, _ callback: @Sendable @escaping (Error?) -> Void) { + queue.async { + callback(nil) + } + } +} + +@available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) +final class EventLoopGroupOf3WithoutAnAnyImplementation: EventLoopGroup { + private static func makeEventLoop() -> AsyncEventLoop { + AsyncEventLoop(__testOnly_manualTimeMode: true) + } + + private let eventloops = [makeEventLoop(), makeEventLoop(), makeEventLoop()] + private let nextID = ManagedAtomic(0) + + func next() -> EventLoop { + self.eventloops[Int(self.nextID.loadThenWrappingIncrement(ordering: .relaxed) % UInt64(self.eventloops.count))] + } + + func shutdownGracefully(queue: DispatchQueue, _ callback: @escaping (Error?) -> Void) { + let g = DispatchGroup() + + for el in self.eventloops { + g.enter() + el.shutdownGracefully(queue: queue) { error in + #expect(error == nil) + g.leave() + } + } + + g.notify(queue: queue) { + callback(nil) + } + } + + func makeIterator() -> EventLoopIterator { + .init(self.eventloops) + } +} + +private enum EventLoopTestsTimeoutError: Error { + case timeout +} + +private func waitForFuture( + _ future: EventLoopFuture, + timeout: TimeAmount +) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await future.get() + } + group.addTask { + let nanoseconds = UInt64(max(timeout.nanoseconds, 0)) + try await Task.sleep(nanoseconds: nanoseconds) + throw EventLoopTestsTimeoutError.timeout + } + + guard let value = try await group.next() else { + throw EventLoopTestsTimeoutError.timeout + } + group.cancelAll() + return value + } +} diff --git a/Tests/NIOAsyncRuntimeTests/AsyncThreadPoolTests.swift b/Tests/NIOAsyncRuntimeTests/AsyncThreadPoolTests.swift new file mode 100644 index 00000000000..678add0dbb5 --- /dev/null +++ b/Tests/NIOAsyncRuntimeTests/AsyncThreadPoolTests.swift @@ -0,0 +1,121 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2020-2026 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Atomics +import Dispatch +import NIOConcurrencyHelpers +import NIOCore +import Testing + +@testable import NIOAsyncRuntime + +@Suite("NIOThreadPoolTest", .timeLimit(.minutes(1))) +class AsyncThreadPoolTest { + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testAsyncThreadPool() async throws { + let numberOfThreads = 1 + let pool = AsyncThreadPool(numberOfThreads: numberOfThreads) + pool.start() + do { + let hitCount = ManagedAtomic(false) + try await pool.runIfActive { + hitCount.store(true, ordering: .relaxed) + } + #expect(hitCount.load(ordering: .relaxed) == true) + } catch {} + try await pool.shutdownGracefully() + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testAsyncThreadPoolErrorPropagation() async throws { + struct ThreadPoolError: Error {} + let numberOfThreads = 1 + let pool = AsyncThreadPool(numberOfThreads: numberOfThreads) + pool.start() + do { + try await pool.runIfActive { + throw ThreadPoolError() + } + Issue.record("Should not get here as closure sent to runIfActive threw an error") + } catch { + #expect(error as? ThreadPoolError != nil, "Error thrown should be of type ThreadPoolError") + } + try await pool.shutdownGracefully() + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testAsyncThreadPoolNotActiveError() async throws { + struct ThreadPoolError: Error {} + let numberOfThreads = 1 + let pool = AsyncThreadPool(numberOfThreads: numberOfThreads) + do { + try await pool.runIfActive { + throw ThreadPoolError() + } + Issue.record("Should not get here as thread pool isn't active") + } catch { + #expect( + error as? CancellationError != nil, + "Error thrown should be of type CancellationError" + ) + } + try await pool.shutdownGracefully() + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testAsyncThreadPoolCancellation() async throws { + let pool = AsyncThreadPool(numberOfThreads: 1) + pool.start() + + await withThrowingTaskGroup(of: Void.self) { group in + group.cancelAll() + group.addTask { + try await pool.runIfActive { + _ = Issue.record("Should be cancelled before executed") + } + } + + do { + try await group.waitForAll() + Issue.record("Expected CancellationError to be thrown") + } catch { + #expect(error is CancellationError) + } + } + + try await pool.shutdownGracefully() + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testAsyncShutdownWorks() async throws { + let threadPool = AsyncThreadPool(numberOfThreads: 17) + let eventLoop = AsyncEventLoop(__testOnly_manualTimeMode: true) + + threadPool.start() + try await threadPool.shutdownGracefully() + + let future: EventLoopFuture = threadPool.runIfActive(eventLoop: eventLoop) { + Issue.record("This shouldn't run because the pool is shutdown.") + } + + await #expect(throws: (any Error).self) { + try await future.get() + } + } +} diff --git a/Tests/NIOAsyncRuntimeTests/EventLoopFutureTest.swift b/Tests/NIOAsyncRuntimeTests/EventLoopFutureTest.swift new file mode 100644 index 00000000000..e66f07be25f --- /dev/null +++ b/Tests/NIOAsyncRuntimeTests/EventLoopFutureTest.swift @@ -0,0 +1,1943 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2017-2021 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Atomics +import Dispatch +import NIOConcurrencyHelpers +import Testing + +@testable import NIOAsyncRuntime +@testable import NIOCore + +enum EventLoopFutureTestError: Error { + case example +} + +@Suite("EventLoopFutureTest", .serialized, .timeLimit(.minutes(1))) +class EventLoopFutureTest { + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + private func makeEventLoop() -> AsyncEventLoop { + AsyncEventLoop(__testOnly_manualTimeMode: true) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testFutureFulfilledIfHasResult() throws { + let eventLoop = makeEventLoop() + let f = EventLoopFuture(eventLoop: eventLoop, value: 5) + #expect(f.isFulfilled) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testFutureFulfilledIfHasError() throws { + let eventLoop = makeEventLoop() + let f = EventLoopFuture(eventLoop: eventLoop, error: EventLoopFutureTestError.example) + #expect(f.isFulfilled) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testFoldWithMultipleEventLoops() throws { + let nThreads = 3 + let eventLoopGroup = AsyncEventLoopGroup(numberOfThreads: nThreads) + defer { + #expect(throws: Never.self) { try eventLoopGroup.syncShutdownGracefully() } + } + + let eventLoop0 = eventLoopGroup.next() + let eventLoop1 = eventLoopGroup.next() + let eventLoop2 = eventLoopGroup.next() + + #expect(eventLoop0 !== eventLoop1) + #expect(eventLoop1 !== eventLoop2) + #expect(eventLoop0 !== eventLoop2) + + let f0: EventLoopFuture<[Int]> = eventLoop0.submit { [0] } + let f1s: [EventLoopFuture] = (1...4).map { id in eventLoop1.submit { id } } + let f2s: [EventLoopFuture] = (5...8).map { id in eventLoop2.submit { id } } + + var fN = f0.fold(f1s) { (f1Value: [Int], f2Value: Int) -> EventLoopFuture<[Int]> in + #expect(eventLoop0.inEventLoop) + return eventLoop1.makeSucceededFuture(f1Value + [f2Value]) + } + + fN = fN.fold(f2s) { (f1Value: [Int], f2Value: Int) -> EventLoopFuture<[Int]> in + #expect(eventLoop0.inEventLoop) + return eventLoop2.makeSucceededFuture(f1Value + [f2Value]) + } + + let allValues = try fN.wait() + #expect(fN.eventLoop === f0.eventLoop) + #expect(fN.isFulfilled) + #expect(allValues == [0, 1, 2, 3, 4, 5, 6, 7, 8]) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testFoldWithSuccessAndAllSuccesses() throws { + let eventLoop = makeEventLoop() + let secondEventLoop = makeEventLoop() + let f0 = eventLoop.makeSucceededFuture([0]) + + let futures: [EventLoopFuture] = (1...5).map { (id: Int) in + secondEventLoop.makeSucceededFuture(id) + } + + let fN = f0.fold(futures) { (f1Value: [Int], f2Value: Int) -> EventLoopFuture<[Int]> in + #expect(eventLoop.inEventLoop) + return secondEventLoop.makeSucceededFuture(f1Value + [f2Value]) + } + + let allValues = try fN.wait() + #expect(fN.eventLoop === f0.eventLoop) + #expect(fN.isFulfilled) + #expect(allValues == [0, 1, 2, 3, 4, 5]) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testFoldWithSuccessAndOneFailure() async throws { + struct E: Error {} + let eventLoop = makeEventLoop() + let secondEventLoop = makeEventLoop() + let f0: EventLoopFuture = eventLoop.makeSucceededFuture(0) + + let promises: [EventLoopPromise] = (0..<100).map { (_: Int) in + secondEventLoop.makePromise() + } + var futures = promises.map { $0.futureResult } + let failedFuture: EventLoopFuture = secondEventLoop.makeFailedFuture(E()) + futures.insert(failedFuture, at: futures.startIndex) + + let fN = f0.fold(futures) { (f1Value: Int, f2Value: Int) -> EventLoopFuture in + #expect(eventLoop.inEventLoop) + return secondEventLoop.makeSucceededFuture(f1Value + f2Value) + } + + _ = promises.map { $0.succeed(0) } + await eventLoop.run() + await #expect(throws: E.self) { + try await fN.get() + } + #expect(fN.isFulfilled) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testFoldWithSuccessAndEmptyFutureList() throws { + let eventLoop = makeEventLoop() + let f0 = eventLoop.makeSucceededFuture(0) + + let futures: [EventLoopFuture] = [] + + let fN = f0.fold(futures) { (f1Value: Int, f2Value: Int) -> EventLoopFuture in + #expect(eventLoop.inEventLoop) + return eventLoop.makeSucceededFuture(f1Value + f2Value) + } + + let summationResult = try fN.wait() + #expect(fN.isFulfilled) + #expect(summationResult == 0) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testFoldWithFailureAndEmptyFutureList() throws { + struct E: Error {} + let eventLoop = makeEventLoop() + let f0: EventLoopFuture = eventLoop.makeFailedFuture(E()) + + let futures: [EventLoopFuture] = [] + + let fN = f0.fold(futures) { (f1Value: Int, f2Value: Int) -> EventLoopFuture in + #expect(eventLoop.inEventLoop) + return eventLoop.makeSucceededFuture(f1Value + f2Value) + } + + #expect(fN.isFulfilled) + #expect(throws: E.self) { + try fN.wait() + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testFoldWithFailureAndAllSuccesses() throws { + struct E: Error {} + let eventLoop = makeEventLoop() + let secondEventLoop = makeEventLoop() + let f0: EventLoopFuture = eventLoop.makeFailedFuture(E()) + + let promises: [EventLoopPromise] = (0..<100).map { (_: Int) in + secondEventLoop.makePromise() + } + let futures = promises.map { $0.futureResult } + + let fN = f0.fold(futures) { (f1Value: Int, f2Value: Int) -> EventLoopFuture in + #expect(eventLoop.inEventLoop) + return secondEventLoop.makeSucceededFuture(f1Value + f2Value) + } + + _ = promises.map { $0.succeed(1) } + #expect(fN.isFulfilled) + #expect(throws: E.self) { + try fN.wait() + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testFoldWithFailureAndAllUnfulfilled() throws { + struct E: Error {} + let eventLoop = makeEventLoop() + let secondEventLoop = makeEventLoop() + let f0: EventLoopFuture = eventLoop.makeFailedFuture(E()) + + let promises: [EventLoopPromise] = (0..<100).map { (_: Int) in + secondEventLoop.makePromise() + } + let futures = promises.map { $0.futureResult } + + let fN = f0.fold(futures) { (f1Value: Int, f2Value: Int) -> EventLoopFuture in + #expect(eventLoop.inEventLoop) + return secondEventLoop.makeSucceededFuture(f1Value + f2Value) + } + + #expect(fN.isFulfilled) + #expect(throws: E.self) { + try fN.wait() + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testFoldWithFailureAndAllFailures() throws { + struct E: Error {} + let eventLoop = makeEventLoop() + let secondEventLoop = makeEventLoop() + let f0: EventLoopFuture = eventLoop.makeFailedFuture(E()) + + let futures: [EventLoopFuture] = (0..<100).map { (_: Int) in + secondEventLoop.makeFailedFuture(E()) + } + + let fN = f0.fold(futures) { (f1Value: Int, f2Value: Int) -> EventLoopFuture in + #expect(eventLoop.inEventLoop) + return secondEventLoop.makeSucceededFuture(f1Value + f2Value) + } + + #expect(fN.isFulfilled) + #expect(throws: E.self) { + try fN.wait() + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testAndAllWithEmptyFutureList() throws { + let eventLoop = makeEventLoop() + let futures: [EventLoopFuture] = [] + + let fN = EventLoopFuture.andAllSucceed(futures, on: eventLoop) + + #expect(fN.isFulfilled) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testAndAllWithAllSuccesses() throws { + let eventLoop = makeEventLoop() + let promises: [EventLoopPromise] = (0..<100).map { (_: Int) in eventLoop.makePromise() } + let futures = promises.map { $0.futureResult } + + let fN = EventLoopFuture.andAllSucceed(futures, on: eventLoop) + _ = promises.map { $0.succeed(()) } + () = try fN.wait() + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testAndAllWithAllFailures() throws { + struct E: Error {} + let eventLoop = makeEventLoop() + let promises: [EventLoopPromise] = (0..<100).map { (_: Int) in eventLoop.makePromise() } + let futures = promises.map { $0.futureResult } + + let fN = EventLoopFuture.andAllSucceed(futures, on: eventLoop) + _ = promises.map { $0.fail(E()) } + #expect(throws: E.self) { + try fN.wait() + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testAndAllWithOneFailure() throws { + struct E: Error {} + let eventLoop = makeEventLoop() + var promises: [EventLoopPromise] = (0..<100).map { (_: Int) in eventLoop.makePromise() } + _ = promises.map { $0.succeed(()) } + let failedPromise = eventLoop.makePromise(of: Void.self) + failedPromise.fail(E()) + promises.append(failedPromise) + + let futures = promises.map { $0.futureResult } + + let fN = EventLoopFuture.andAllSucceed(futures, on: eventLoop) + #expect(throws: E.self) { + try fN.wait() + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testReduceWithAllSuccesses() throws { + let eventLoop = makeEventLoop() + let promises: [EventLoopPromise] = (0..<5).map { (_: Int) in eventLoop.makePromise() } + let futures = promises.map { $0.futureResult } + + let fN: EventLoopFuture<[Int]> = EventLoopFuture<[Int]>.reduce(into: [], futures, on: eventLoop) { + $0.append($1) + } + for i in 1...5 { + promises[i - 1].succeed((i)) + } + let results = try fN.wait() + #expect(results == [1, 2, 3, 4, 5]) + #expect(fN.eventLoop === eventLoop) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testReduceWithOnlyInitialValue() throws { + let eventLoop = makeEventLoop() + let futures: [EventLoopFuture] = [] + + let fN: EventLoopFuture<[Int]> = EventLoopFuture<[Int]>.reduce(into: [], futures, on: eventLoop) { + $0.append($1) + } + + let results = try fN.wait() + #expect(results == []) + #expect(fN.eventLoop === eventLoop) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testReduceWithAllFailures() throws { + struct E: Error {} + let eventLoop = makeEventLoop() + let promises: [EventLoopPromise] = (0..<100).map { (_: Int) in eventLoop.makePromise() } + let futures = promises.map { $0.futureResult } + + let fN: EventLoopFuture = EventLoopFuture.reduce(0, futures, on: eventLoop) { + $0 + $1 + } + _ = promises.map { $0.fail(E()) } + #expect(fN.eventLoop === eventLoop) + #expect(throws: E.self) { + try fN.wait() + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testReduceWithOneFailure() throws { + struct E: Error {} + let eventLoop = makeEventLoop() + var promises: [EventLoopPromise] = (0..<100).map { (_: Int) in eventLoop.makePromise() } + _ = promises.map { $0.succeed((1)) } + let failedPromise = eventLoop.makePromise(of: Int.self) + failedPromise.fail(E()) + promises.append(failedPromise) + + let futures = promises.map { $0.futureResult } + + let fN: EventLoopFuture = EventLoopFuture.reduce(0, futures, on: eventLoop) { + $0 + $1 + } + #expect(fN.eventLoop === eventLoop) + #expect(throws: E.self) { + try fN.wait() + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testReduceWhichDoesFailFast() throws { + struct E: Error {} + let eventLoop = makeEventLoop() + var promises: [EventLoopPromise] = (0..<100).map { (_: Int) in eventLoop.makePromise() } + + let failedPromise = eventLoop.makePromise(of: Int.self) + promises.insert(failedPromise, at: promises.startIndex) + + let futures = promises.map { $0.futureResult } + let fN: EventLoopFuture = EventLoopFuture.reduce(0, futures, on: eventLoop) { + $0 + $1 + } + + failedPromise.fail(E()) + + #expect(fN.isFulfilled) + #expect(fN.eventLoop === eventLoop) + #expect(throws: E.self) { + try fN.wait() + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testReduceIntoWithAllSuccesses() throws { + let eventLoop = makeEventLoop() + let futures: [EventLoopFuture] = [1, 2, 2, 3, 3, 3].map { (id: Int) in + eventLoop.makeSucceededFuture(id) + } + + let fN: EventLoopFuture<[Int: Int]> = EventLoopFuture<[Int: Int]>.reduce( + into: [:], + futures, + on: eventLoop + ) { + (freqs, elem) in + if let value = freqs[elem] { + freqs[elem] = value + 1 + } else { + freqs[elem] = 1 + } + } + + let results = try fN.wait() + #expect(results == [1: 1, 2: 2, 3: 3]) + #expect(fN.eventLoop === eventLoop) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testReduceIntoWithEmptyFutureList() throws { + let eventLoop = makeEventLoop() + let futures: [EventLoopFuture] = [] + + let fN: EventLoopFuture<[Int: Int]> = EventLoopFuture<[Int: Int]>.reduce( + into: [:], + futures, + on: eventLoop + ) { + (freqs, elem) in + if let value = freqs[elem] { + freqs[elem] = value + 1 + } else { + freqs[elem] = 1 + } + } + + let results = try fN.wait() + #expect(results.isEmpty) + #expect(fN.eventLoop === eventLoop) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testReduceIntoWithAllFailure() throws { + struct E: Error {} + let eventLoop = makeEventLoop() + let futures: [EventLoopFuture] = [1, 2, 2, 3, 3, 3].map { (id: Int) in + eventLoop.makeFailedFuture(E()) + } + + let fN: EventLoopFuture<[Int: Int]> = EventLoopFuture<[Int: Int]>.reduce( + into: [:], + futures, + on: eventLoop + ) { + (freqs, elem) in + if let value = freqs[elem] { + freqs[elem] = value + 1 + } else { + freqs[elem] = 1 + } + } + + #expect(fN.isFulfilled) + #expect(fN.eventLoop === eventLoop) + #expect(throws: E.self) { + try fN.wait() + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testReduceIntoWithMultipleEventLoops() throws { + let nThreads = 3 + let eventLoopGroup = AsyncEventLoopGroup(numberOfThreads: nThreads) + defer { + #expect(throws: Never.self) { try eventLoopGroup.syncShutdownGracefully() } + } + + let eventLoop0 = eventLoopGroup.next() + let eventLoop1 = eventLoopGroup.next() + let eventLoop2 = eventLoopGroup.next() + + #expect(eventLoop0 !== eventLoop1) + #expect(eventLoop1 !== eventLoop2) + #expect(eventLoop0 !== eventLoop2) + + let f0: EventLoopFuture<[Int: Int]> = eventLoop0.submit { [:] } + let f1s: [EventLoopFuture] = (1...4).map { id in eventLoop1.submit { id / 2 } } + let f2s: [EventLoopFuture] = (5...8).map { id in eventLoop2.submit { id / 2 } } + + let fN = EventLoopFuture<[Int: Int]>.reduce(into: [:], f1s + f2s, on: eventLoop0) { + (freqs, elem) in + #expect(eventLoop0.inEventLoop) + if let value = freqs[elem] { + freqs[elem] = value + 1 + } else { + freqs[elem] = 1 + } + } + + let allValues = try fN.wait() + #expect(fN.eventLoop === f0.eventLoop) + #expect(fN.isFulfilled) + #expect(allValues == [0: 1, 1: 2, 2: 2, 3: 2, 4: 1]) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testThenThrowingWhichDoesNotThrow() throws { + let eventLoop = makeEventLoop() + let completion = eventLoop.submit { + var ran = false + let p = eventLoop.makePromise(of: String.self) + p.futureResult.map { + $0.count + }.flatMapThrowing { + 1 + $0 + }.assumeIsolated().whenSuccess { + ran = true + #expect($0 == 6) + } + p.succeed("hello") + return ran + } + let ran = try completion.wait() + #expect(ran) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testThenThrowingWhichDoesThrow() throws { + enum DummyError: Error, Equatable { + case dummyError + } + let eventLoop = makeEventLoop() + let completion = eventLoop.submit { + var ran = false + let p = eventLoop.makePromise(of: String.self) + p.futureResult.map { + $0.count + }.flatMapThrowing { (x: Int) throws -> Int in + #expect(5 == x) + throw DummyError.dummyError + }.map { (x: Int) -> Int in + Issue.record("shouldn't have been called") + return x + }.assumeIsolated().whenFailure { + ran = true + #expect(.some(DummyError.dummyError) == $0 as? DummyError) + } + p.succeed("hello") + return ran + } + let ran = try completion.wait() + #expect(ran) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testflatMapErrorThrowingWhichDoesNotThrow() throws { + enum DummyError: Error, Equatable { + case dummyError + } + let eventLoop = makeEventLoop() + let completion = eventLoop.submit { + var ran = false + let p = eventLoop.makePromise(of: String.self) + p.futureResult.map { + $0.count + }.flatMapErrorThrowing { + #expect(.some(DummyError.dummyError) == $0 as? DummyError) + return 5 + }.flatMapErrorThrowing { (_: Error) in + Issue.record("shouldn't have been called") + return 5 + }.assumeIsolated().whenSuccess { + ran = true + #expect($0 == 5) + } + p.fail(DummyError.dummyError) + return ran + } + let ran = try completion.wait() + #expect(ran) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testflatMapErrorThrowingWhichDoesThrow() throws { + enum DummyError: Error, Equatable { + case dummyError1 + case dummyError2 + } + let eventLoop = makeEventLoop() + let completion = eventLoop.submit { + var ran = false + let p = eventLoop.makePromise(of: String.self) + p.futureResult.map { + $0.count + }.flatMapErrorThrowing { (x: Error) throws -> Int in + #expect(.some(DummyError.dummyError1) == x as? DummyError) + throw DummyError.dummyError2 + }.map { (x: Int) -> Int in + Issue.record("shouldn't have been called") + return x + }.assumeIsolated().whenFailure { + ran = true + #expect(.some(DummyError.dummyError2) == $0 as? DummyError) + } + p.fail(DummyError.dummyError1) + return ran + } + let ran = try completion.wait() + #expect(ran) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testOrderOfFutureCompletion() throws { + let eventLoop = makeEventLoop() + let completion = eventLoop.submit { + var state = 0 + let p: EventLoopPromise = EventLoopPromise( + eventLoop: eventLoop, + file: #filePath, + line: #line + ) + p.futureResult.assumeIsolated().map { + #expect(state == 0) + state += 1 + }.map { + #expect(state == 1) + state += 1 + }.whenSuccess { + #expect(state == 2) + state += 1 + } + p.succeed(()) + #expect(p.futureResult.isFulfilled) + return state + } + let state = try completion.wait() + #expect(state == 3) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testEventLoopHoppingInThen() throws { + let n = 20 + let elg = AsyncEventLoopGroup(numberOfThreads: n) + var prev: EventLoopFuture = elg.next().makeSucceededFuture(0) + for i in (1..<20) { + let p = elg.next().makePromise(of: Int.self) + prev.flatMap { (i2: Int) -> EventLoopFuture in + #expect(i - 1 == i2) + p.succeed(i) + return p.futureResult + }.whenSuccess { i2 in + #expect(i == i2) + } + prev = p.futureResult + } + let result = try prev.wait() + #expect(n - 1 == result) + #expect(throws: Never.self) { try elg.syncShutdownGracefully() } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testEventLoopHoppingInThenWithFailures() throws { + enum DummyError: Error { + case dummy + } + let n = 20 + let elg = AsyncEventLoopGroup(numberOfThreads: n) + var prev: EventLoopFuture = elg.next().makeSucceededFuture(0) + for i in (1.. EventLoopFuture in + #expect(i - 1 == i2) + if i == n / 2 { + p.fail(DummyError.dummy) + } else { + p.succeed(i) + } + return p.futureResult + }.flatMapError { error in + p.fail(error) + return p.futureResult + }.whenSuccess { i2 in + #expect(i == i2) + } + prev = p.futureResult + } + #expect(throws: DummyError.self) { + try prev.wait() + } + #expect(throws: Never.self) { try elg.syncShutdownGracefully() } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testEventLoopHoppingAndAll() throws { + let n = 20 + let elg = AsyncEventLoopGroup(numberOfThreads: n) + let ps = (0.. EventLoopPromise in + elg.next().makePromise() + } + let allOfEm = EventLoopFuture.andAllSucceed(ps.map { $0.futureResult }, on: elg.next()) + for promise in ps.reversed() { + DispatchQueue.global().async { + promise.succeed(()) + } + } + try allOfEm.wait() + #expect(throws: Never.self) { try elg.syncShutdownGracefully() } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testEventLoopHoppingAndAllWithFailures() throws { + enum DummyError: Error { case dummy } + let n = 20 + let fireBackEl = AsyncEventLoopGroup(numberOfThreads: 1) + let elg = AsyncEventLoopGroup(numberOfThreads: n) + let ps = (0.. EventLoopPromise in + elg.next().makePromise() + } + let allOfEm = EventLoopFuture.andAllSucceed(ps.map { $0.futureResult }, on: fireBackEl.next()) + for (index, promise) in ps.reversed().enumerated() { + DispatchQueue.global().async { + if index == n / 2 { + promise.fail(DummyError.dummy) + } else { + promise.succeed(()) + } + } + } + #expect(throws: DummyError.self) { + try allOfEm.wait() + } + #expect(throws: Never.self) { try elg.syncShutdownGracefully() } + #expect(throws: Never.self) { try fireBackEl.syncShutdownGracefully() } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testFutureInVariousScenarios() throws { + enum DummyError: Error { + case dummy0 + case dummy1 + } + let elg = AsyncEventLoopGroup(numberOfThreads: 2) + let el1 = elg.next() + let el2 = elg.next() + precondition(el1 !== el2) + let q1 = DispatchQueue(label: "q1") + let q2 = DispatchQueue(label: "q2") + + // this determines which promise is fulfilled first (and (true, true) meaning they race) + for whoGoesFirst in [(false, true), (true, false), (true, true)] { + // this determines what EventLoops the Promises are created on + for eventLoops in [(el1, el1), (el1, el2), (el2, el1), (el2, el2)] { + // this determines if the promises fail or succeed + for whoSucceeds in [(false, false), (false, true), (true, false), (true, true)] { + let p0 = eventLoops.0.makePromise(of: Int.self) + let p1 = eventLoops.1.makePromise(of: String.self) + let fAll = p0.futureResult.and(p1.futureResult) + + // preheat both queues so we have a better chance of racing + let sem1 = DispatchSemaphore(value: 0) + let sem2 = DispatchSemaphore(value: 0) + let g = DispatchGroup() + q1.async(group: g) { + sem2.signal() + sem1.wait() + } + q2.async(group: g) { + sem1.signal() + sem2.wait() + } + g.wait() + + if whoGoesFirst.0 { + q1.async { + if whoSucceeds.0 { + p0.succeed(7) + } else { + p0.fail(DummyError.dummy0) + } + if !whoGoesFirst.1 { + q2.asyncAfter(deadline: .now() + 0.1) { + if whoSucceeds.1 { + p1.succeed("hello") + } else { + p1.fail(DummyError.dummy1) + } + } + } + } + } + if whoGoesFirst.1 { + q2.async { + if whoSucceeds.1 { + p1.succeed("hello") + } else { + p1.fail(DummyError.dummy1) + } + if !whoGoesFirst.0 { + q1.asyncAfter(deadline: .now() + 0.1) { + if whoSucceeds.0 { + p0.succeed(7) + } else { + p0.fail(DummyError.dummy0) + } + } + } + } + } + do { + let result = try fAll.wait() + if !whoSucceeds.0 || !whoSucceeds.1 { + Issue.record("unexpected success") + } else { + #expect((7, "hello") == result) + } + } catch let e as DummyError { + switch e { + case .dummy0: + #expect(!whoSucceeds.0) + case .dummy1: + #expect(!whoSucceeds.1) + } + } catch { + Issue.record("unexpected error: \(error)") + } + } + } + } + + #expect(throws: Never.self) { try elg.syncShutdownGracefully() } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testLoopHoppingHelperSuccess() throws { + let group = AsyncEventLoopGroup(numberOfThreads: 2) + defer { + #expect(throws: Never.self) { try group.syncShutdownGracefully() } + } + let loop1 = group.next() + let loop2 = group.next() + #expect(!(loop1 === loop2)) + + let succeedingPromise = loop1.makePromise(of: Void.self) + let succeedingFuture = succeedingPromise.futureResult.map { + #expect(loop1.inEventLoop) + }.hop(to: loop2).map { + #expect(loop2.inEventLoop) + } + succeedingPromise.succeed(()) + #expect(throws: Never.self) { try succeedingFuture.wait() } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testLoopHoppingHelperFailure() throws { + let group = AsyncEventLoopGroup(numberOfThreads: 2) + defer { + #expect(throws: Never.self) { try group.syncShutdownGracefully() } + } + + let loop1 = group.next() + let loop2 = group.next() + #expect(!(loop1 === loop2)) + + let failingPromise = loop2.makePromise(of: Void.self) + let failingFuture = failingPromise.futureResult.flatMapErrorThrowing { error in + #expect(error as? EventLoopFutureTestError == EventLoopFutureTestError.example) + #expect(loop2.inEventLoop) + throw error + }.hop(to: loop1).recover { error in + #expect(error as? EventLoopFutureTestError == EventLoopFutureTestError.example) + #expect(loop1.inEventLoop) + } + + failingPromise.fail(EventLoopFutureTestError.example) + #expect(throws: Never.self) { try failingFuture.wait() } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testLoopHoppingHelperNoHopping() throws { + let group = AsyncEventLoopGroup(numberOfThreads: 2) + defer { + #expect(throws: Never.self) { try group.syncShutdownGracefully() } + } + let loop1 = group.next() + let loop2 = group.next() + #expect(!(loop1 === loop2)) + + let noHoppingPromise = loop1.makePromise(of: Void.self) + let noHoppingFuture = noHoppingPromise.futureResult.hop(to: loop1) + #expect(noHoppingFuture === noHoppingPromise.futureResult) + noHoppingPromise.succeed(()) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testFlatMapResultHappyPath() async { + let el = makeEventLoop() + + let p = el.makePromise(of: Int.self) + let f = p.futureResult.flatMapResult { (_: Int) in + Result.success("hello world") + } + p.succeed(1) + await #expect(throws: Never.self) { + let result = try await f.get() + #expect("hello world" == result) + } + await #expect(throws: Never.self) { try await el.shutdownGracefully() } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testFlatMapResultFailurePath() async { + struct DummyError: Error {} + let el = makeEventLoop() + + let p = el.makePromise(of: Int.self) + let f = p.futureResult.flatMapResult { (_: Int) in + Result.failure(DummyError()) + } + p.succeed(1) + await #expect(throws: DummyError.self) { try await f.get() } + await #expect(throws: Never.self) { try await el.shutdownGracefully() } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testWhenAllSucceedFailsImmediately() { + let group = AsyncEventLoopGroup(numberOfThreads: 2) + defer { + #expect(throws: Never.self) { try group.syncShutdownGracefully() } + } + + func doTest(promise: EventLoopPromise<[Int]>?) { + let promises = [ + group.next().makePromise(of: Int.self), + group.next().makePromise(of: Int.self), + ] + let futures = promises.map { $0.futureResult } + let futureResult: EventLoopFuture<[Int]> + + if let promise = promise { + futureResult = promise.futureResult + EventLoopFuture.whenAllSucceed(futures, promise: promise) + } else { + futureResult = EventLoopFuture.whenAllSucceed(futures, on: group.next()) + } + + promises[0].fail(EventLoopFutureTestError.example) + #expect(throws: EventLoopFutureTestError.self) { + try futureResult.wait() + } + } + + doTest(promise: nil) + doTest(promise: group.next().makePromise()) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testWhenAllSucceedResolvesAfterFutures() throws { + let group = AsyncEventLoopGroup(numberOfThreads: 6) + defer { + #expect(throws: Never.self) { try group.syncShutdownGracefully() } + } + + func doTest(promise: EventLoopPromise<[Int]>?) throws { + let promises = (0..<5).map { _ in group.next().makePromise(of: Int.self) } + let futures = promises.map { $0.futureResult } + + let succeeded = NIOLockedValueBox(false) + let completedPromises = NIOLockedValueBox(false) + + let mainFuture: EventLoopFuture<[Int]> + + if let promise = promise { + mainFuture = promise.futureResult + EventLoopFuture.whenAllSucceed(futures, promise: promise) + } else { + mainFuture = EventLoopFuture.whenAllSucceed(futures, on: group.next()) + } + + mainFuture.whenSuccess { _ in + #expect(completedPromises.withLockedValue { $0 }) + #expect(!succeeded.withLockedValue { $0 }) + succeeded.withLockedValue { $0 = true } + } + + // Should be false, as none of the promises have completed yet + #expect(!succeeded.withLockedValue { $0 }) + + // complete the first four promises + for (index, promise) in promises.dropLast().enumerated() { + promise.succeed(index) + } + + // Should still be false, as one promise hasn't completed yet + #expect(!succeeded.withLockedValue { $0 }) + + // Complete the last promise + completedPromises.withLockedValue { $0 = true } + promises.last!.succeed(4) + + let results = try assertNoThrowWithValue(mainFuture.wait()) + #expect(results == [0, 1, 2, 3, 4]) + } + + #expect(throws: Never.self) { try doTest(promise: nil) } + #expect(throws: Never.self) { try doTest(promise: group.next().makePromise()) } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testWhenAllSucceedIsIndependentOfFulfillmentOrder() throws { + let group = AsyncEventLoopGroup(numberOfThreads: 6) + defer { + #expect(throws: Never.self) { try group.syncShutdownGracefully() } + } + + func doTest(promise: EventLoopPromise<[Int]>?) throws { + let expected = Array(0..<1000) + let promises = expected.map { _ in group.next().makePromise(of: Int.self) } + let futures = promises.map { $0.futureResult } + + let succeeded = NIOLockedValueBox(false) + let completedPromises = NIOLockedValueBox(false) + + let mainFuture: EventLoopFuture<[Int]> + + if let promise = promise { + mainFuture = promise.futureResult + EventLoopFuture.whenAllSucceed(futures, promise: promise) + } else { + mainFuture = EventLoopFuture.whenAllSucceed(futures, on: group.next()) + } + + mainFuture.whenSuccess { _ in + #expect(completedPromises.withLockedValue { $0 }) + #expect(!succeeded.withLockedValue { $0 }) + succeeded.withLockedValue { $0 = true } + } + + for index in expected.reversed() { + if index == 0 { + completedPromises.withLockedValue { $0 = true } + } + promises[index].succeed(index) + } + + let results = try assertNoThrowWithValue(mainFuture.wait()) + #expect(results == expected) + } + + #expect(throws: Never.self) { try doTest(promise: nil) } + #expect(throws: Never.self) { try doTest(promise: group.next().makePromise()) } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testWhenAllCompleteResultsWithFailuresStillSucceed() { + let group = AsyncEventLoopGroup(numberOfThreads: 2) + defer { + #expect(throws: Never.self) { try group.syncShutdownGracefully() } + } + + func doTest(promise: EventLoopPromise<[Result]>?) { + let futures: [EventLoopFuture] = [ + group.next().makeFailedFuture(EventLoopFutureTestError.example), + group.next().makeSucceededFuture(true), + ] + let future: EventLoopFuture<[Result]> + + if let promise = promise { + future = promise.futureResult + EventLoopFuture.whenAllComplete(futures, promise: promise) + } else { + future = EventLoopFuture.whenAllComplete(futures, on: group.next()) + } + + #expect(throws: Never.self) { try future.wait() } + } + + doTest(promise: nil) + doTest(promise: group.next().makePromise()) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testWhenAllCompleteResults() throws { + let group = AsyncEventLoopGroup(numberOfThreads: 2) + defer { + #expect(throws: Never.self) { try group.syncShutdownGracefully() } + } + + func doTest(promise: EventLoopPromise<[Result]>?) throws { + let futures: [EventLoopFuture] = [ + group.next().makeSucceededFuture(3), + group.next().makeFailedFuture(EventLoopFutureTestError.example), + group.next().makeSucceededFuture(10), + group.next().makeFailedFuture(EventLoopFutureTestError.example), + group.next().makeSucceededFuture(5), + ] + let future: EventLoopFuture<[Result]> + + if let promise = promise { + future = promise.futureResult + EventLoopFuture.whenAllComplete(futures, promise: promise) + } else { + future = EventLoopFuture.whenAllComplete(futures, on: group.next()) + } + + let results = try assertNoThrowWithValue(future.wait()) + + #expect(try results[0].get() == 3) + #expect(throws: Error.self) { try results[1].get() } + #expect(try results[2].get() == 10) + #expect(throws: Error.self) { try results[3].get() } + #expect(try results[4].get() == 5) + } + + #expect(throws: Never.self) { try doTest(promise: nil) } + #expect(throws: Never.self) { try doTest(promise: group.next().makePromise()) } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testWhenAllCompleteResolvesAfterFutures() throws { + let group = AsyncEventLoopGroup(numberOfThreads: 6) + defer { + #expect(throws: Never.self) { try group.syncShutdownGracefully() } + } + + func doTest(promise: EventLoopPromise<[Result]>?) throws { + let promises = (0..<5).map { _ in group.next().makePromise(of: Int.self) } + let futures = promises.map { $0.futureResult } + + let succeeded = NIOLockedValueBox(false) + let completedPromises = NIOLockedValueBox(false) + + let mainFuture: EventLoopFuture<[Result]> + + if let promise = promise { + mainFuture = promise.futureResult + EventLoopFuture.whenAllComplete(futures, promise: promise) + } else { + mainFuture = EventLoopFuture.whenAllComplete(futures, on: group.next()) + } + + mainFuture.whenSuccess { _ in + #expect(completedPromises.withLockedValue { $0 }) + #expect(!succeeded.withLockedValue { $0 }) + succeeded.withLockedValue { $0 = true } + } + + // Should be false, as none of the promises have completed yet + #expect(!succeeded.withLockedValue { $0 }) + + // complete the first four promises + for (index, promise) in promises.dropLast().enumerated() { + promise.succeed(index) + } + + // Should still be false, as one promise hasn't completed yet + #expect(!succeeded.withLockedValue { $0 }) + + // Complete the last promise + completedPromises.withLockedValue { $0 = true } + promises.last!.succeed(4) + + let results = try assertNoThrowWithValue(mainFuture.wait().map { try $0.get() }) + #expect(results == [0, 1, 2, 3, 4]) + } + + #expect(throws: Never.self) { try doTest(promise: nil) } + #expect(throws: Never.self) { try doTest(promise: group.next().makePromise()) } + } + + struct DatabaseError: Error {} + final class Database: Sendable { + private let query: @Sendable () -> EventLoopFuture<[String]> + private let _closed = NIOLockedValueBox(false) + + var closed: Bool { + self._closed.withLockedValue { $0 } + } + + init(query: @escaping @Sendable () -> EventLoopFuture<[String]>) { + self.query = query + } + + func runQuery() -> EventLoopFuture<[String]> { + self.query() + } + + func close() { + self._closed.withLockedValue { $0 = true } + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testAlways() throws { + let group = makeEventLoop() + let loop = group.next() + let db = Database { loop.makeSucceededFuture(["Item 1", "Item 2", "Item 3"]) } + + #expect(!db.closed) + let _ = try assertNoThrowWithValue( + db.runQuery().always { result in + assertSuccess(result) + db.close() + }.map { $0.map { $0.uppercased() } }.wait() + ) + #expect(db.closed) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testAlwaysWithFailingPromise() throws { + let group = makeEventLoop() + let loop = group.next() + let db = Database { loop.makeFailedFuture(DatabaseError()) } + + #expect(!db.closed) + + #expect(throws: DatabaseError.self) { + try db.runQuery().always { result in + assertFailure(result) + db.close() + }.map { $0.map { $0.uppercased() } }.wait() + } + #expect(db.closed) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testPromiseCompletedWithSuccessfulFuture() throws { + let group = makeEventLoop() + let loop = group.next() + + let future = loop.makeSucceededFuture("yay") + let promise = loop.makePromise(of: String.self) + + promise.completeWith(future) + #expect(try promise.futureResult.wait() == "yay") + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testFutureFulfilledIfHasNonSendableResult() throws { + let eventLoop = makeEventLoop() + let completion = eventLoop.submit { + let f = EventLoopFuture(eventLoop: eventLoop, isolatedValue: NonSendableObject(value: 5)) + #expect(f.isFulfilled) + } + #expect(throws: Never.self) { + try completion.wait() + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testSucceededIsolatedFutureIsCompleted() throws { + let group = makeEventLoop() + let loop = group.next() + let completion = loop.submit { + let value = NonSendableObject(value: 4) + + let future = loop.makeSucceededIsolatedFuture(value) + + future.whenComplete { result in + switch result { + case .success(let nonSendableStruct): + #expect(nonSendableStruct == value) + case .failure(let error): + Issue.record("\(error)") + } + } + } + #expect(throws: Never.self) { + try completion.wait() + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testPromiseCompletedWithFailedFuture() throws { + let group = makeEventLoop() + let loop = group.next() + + let future: EventLoopFuture = loop.makeFailedFuture( + EventLoopFutureTestError.example + ) + let promise = loop.makePromise(of: EventLoopFutureTestError.self) + + promise.completeWith(future) + #expect(throws: EventLoopFutureTestError.self) { + try promise.futureResult.wait() + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testPromiseCompletedWithSuccessfulResult() throws { + let group = makeEventLoop() + let loop = group.next() + + let promise = loop.makePromise(of: Void.self) + + let result: Result = .success(()) + promise.completeWith(result) + #expect(throws: Never.self) { try promise.futureResult.wait() } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testPromiseCompletedWithFailedResult() throws { + let group = makeEventLoop() + let loop = group.next() + + let promise = loop.makePromise(of: Void.self) + + let result: Result = .failure(EventLoopFutureTestError.example) + promise.completeWith(result) + #expect(throws: EventLoopFutureTestError.self) { + try promise.futureResult.wait() + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testAndAllCompleteWithZeroFutures() { + let eventLoop = makeEventLoop() + let done = DispatchSemaphore(value: 0) + EventLoopFuture.andAllComplete([], on: eventLoop).whenComplete { + (result: Result) in + _ = result.mapError { error -> Error in + Issue.record("unexpected error \(error)") + return error + } + done.signal() + } + done.wait() + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testAndAllSucceedWithZeroFutures() { + let eventLoop = makeEventLoop() + let done = DispatchSemaphore(value: 0) + EventLoopFuture.andAllSucceed([], on: eventLoop).whenComplete { result in + _ = result.mapError { error -> Error in + Issue.record("unexpected error \(error)") + return error + } + done.signal() + } + done.wait() + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testAndAllCompleteWithPreSucceededFutures() { + let eventLoop = makeEventLoop() + let succeeded = eventLoop.makeSucceededFuture(()) + + for i in 0..<10 { + #expect(throws: Never.self) { + try EventLoopFuture.andAllComplete( + Array(repeating: succeeded, count: i), + on: eventLoop + ).wait() + } + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testAndAllCompleteWithPreFailedFutures() { + struct Dummy: Error {} + let eventLoop = makeEventLoop() + let failed: EventLoopFuture = eventLoop.makeFailedFuture(Dummy()) + + for i in 0..<10 { + #expect(throws: Never.self) { + try EventLoopFuture.andAllComplete( + Array(repeating: failed, count: i), + on: eventLoop + ).wait() + } + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testAndAllCompleteWithMixOfPreSuccededAndNotYetCompletedFutures() { + struct Dummy: Error {} + let eventLoop = makeEventLoop() + let succeeded = eventLoop.makeSucceededFuture(()) + let incompletes = [ + eventLoop.makePromise(of: Void.self), eventLoop.makePromise(of: Void.self), + eventLoop.makePromise(of: Void.self), eventLoop.makePromise(of: Void.self), + eventLoop.makePromise(of: Void.self), + ] + var futures: [EventLoopFuture] = [] + + for i in 0..<10 { + if i % 2 == 0 { + futures.append(succeeded) + } else { + futures.append(incompletes[i / 2].futureResult) + } + } + + let overall = EventLoopFuture.andAllComplete(futures, on: eventLoop) + #expect(!overall.isFulfilled) + for (idx, incomplete) in incompletes.enumerated() { + #expect(!overall.isFulfilled) + if idx % 2 == 0 { + incomplete.succeed(()) + } else { + incomplete.fail(Dummy()) + } + } + #expect(throws: Never.self) { try overall.wait() } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testWhenAllCompleteWithMixOfPreSuccededAndNotYetCompletedFutures() { + struct Dummy: Error {} + let eventLoop = makeEventLoop() + let succeeded = eventLoop.makeSucceededFuture(()) + let incompletes = [ + eventLoop.makePromise(of: Void.self), eventLoop.makePromise(of: Void.self), + eventLoop.makePromise(of: Void.self), eventLoop.makePromise(of: Void.self), + eventLoop.makePromise(of: Void.self), + ] + var futures: [EventLoopFuture] = [] + + for i in 0..<10 { + if i % 2 == 0 { + futures.append(succeeded) + } else { + futures.append(incompletes[i / 2].futureResult) + } + } + + let overall = EventLoopFuture.whenAllComplete(futures, on: eventLoop) + #expect(!overall.isFulfilled) + for (idx, incomplete) in incompletes.enumerated() { + #expect(!overall.isFulfilled) + if idx % 2 == 0 { + incomplete.succeed(()) + } else { + incomplete.fail(Dummy()) + } + } + let expected: [Result] = [ + .success(()), .success(()), + .success(()), .failure(Dummy()), + .success(()), .success(()), + .success(()), .failure(Dummy()), + .success(()), .success(()), + ] + func assertIsEqual(_ expecteds: [Result], _ actuals: [Result]) { + #expect(expecteds.count == actuals.count, "counts not equal") + for i in expecteds.indices { + let expected = expecteds[i] + let actual = actuals[i] + switch (expected, actual) { + case (.success(()), .success(())): + () + case (.failure(let le), .failure(let re)): + #expect(le is Dummy) + #expect(re is Dummy) + default: + Issue.record("\(expecteds) and \(actuals) not equal") + } + } + } + #expect(throws: Never.self) { assertIsEqual(expected, try overall.wait()) } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testRepeatedTaskOffEventLoopGroupFuture() throws { + let elg1: EventLoopGroup = AsyncEventLoopGroup(numberOfThreads: 1) + defer { + #expect(throws: Never.self) { try elg1.syncShutdownGracefully() } + } + + let elg2: EventLoopGroup = AsyncEventLoopGroup(numberOfThreads: 1) + defer { + #expect(throws: Never.self) { try elg2.syncShutdownGracefully() } + } + + let exitPromise: EventLoopPromise = elg1.next().makePromise() + let callNumber = NIOLockedValueBox(0) + _ = elg1.next().scheduleRepeatedAsyncTask(initialDelay: .nanoseconds(0), delay: .nanoseconds(0)) { task in + struct Dummy: Error {} + + callNumber.withLockedValue { $0 += 1 } + switch callNumber.withLockedValue({ $0 }) { + case 1: + return elg2.next().makeSucceededFuture(()) + case 2: + task.cancel(promise: exitPromise) + return elg2.next().makeFailedFuture(Dummy()) + default: + Issue.record("shouldn't be called \(callNumber)") + return elg2.next().makeFailedFuture(Dummy()) + } + } + + try exitPromise.futureResult.wait() + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testEventLoopFutureOrErrorNoThrow() throws { + let eventLoop = makeEventLoop() + let promise = eventLoop.makePromise(of: Int?.self) + let result: Result = .success(42) + promise.completeWith(result) + + #expect(try promise.futureResult.unwrap(orError: EventLoopFutureTestError.example).wait() == 42) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testEventLoopFutureOrThrows() { + let eventLoop = makeEventLoop() + let promise = eventLoop.makePromise(of: Int?.self) + let result: Result = .success(nil) + promise.completeWith(result) + + #expect(throws: EventLoopFutureTestError.example) { + try promise.futureResult.unwrap(orError: EventLoopFutureTestError.example).wait() + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testEventLoopFutureOrNoReplacement() { + let eventLoop = makeEventLoop() + let promise = eventLoop.makePromise(of: Int?.self) + let result: Result = .success(42) + promise.completeWith(result) + + #expect(try! promise.futureResult.unwrap(orReplace: 41).wait() == 42) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testEventLoopFutureOrReplacement() { + let eventLoop = makeEventLoop() + let promise = eventLoop.makePromise(of: Int?.self) + let result: Result = .success(nil) + promise.completeWith(result) + + #expect(try! promise.futureResult.unwrap(orReplace: 42).wait() == 42) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testEventLoopFutureOrNoElse() { + let eventLoop = makeEventLoop() + let promise = eventLoop.makePromise(of: Int?.self) + let result: Result = .success(42) + promise.completeWith(result) + + #expect(try! promise.futureResult.unwrap(orElse: { 41 }).wait() == 42) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testEventLoopFutureOrElse() { + let eventLoop = makeEventLoop() + let promise = eventLoop.makePromise(of: Int?.self) + let result: Result = .success(4) + promise.completeWith(result) + + let x = 2 + #expect(try! promise.futureResult.unwrap(orElse: { x * 2 }).wait() == 4) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testFlatBlockingMapOnto() { + let group = AsyncEventLoopGroup(numberOfThreads: 1) + defer { + #expect(throws: Never.self) { try group.syncShutdownGracefully() } + } + + let eventLoop = group.next() + let p = eventLoop.makePromise(of: String.self) + let sem = DispatchSemaphore(value: 0) + let blockingRan = ManagedAtomic(false) + let nonBlockingRan = ManagedAtomic(false) + p.futureResult.map { + $0.count + }.flatMapBlocking(onto: DispatchQueue.global()) { value -> Int in + sem.wait() // Block in chained EventLoopFuture + blockingRan.store(true, ordering: .sequentiallyConsistent) + return 1 + value + }.whenSuccess { + #expect($0 == 6) + let blockingRanResult = blockingRan.load(ordering: .sequentiallyConsistent) + #expect(blockingRanResult) + let nonBlockingRanResult = nonBlockingRan.load(ordering: .sequentiallyConsistent) + #expect(nonBlockingRanResult) + } + p.succeed("hello") + + let p2 = eventLoop.makePromise(of: Bool.self) + p2.futureResult.whenSuccess { _ in + nonBlockingRan.store(true, ordering: .sequentiallyConsistent) + } + p2.succeed(true) + + sem.signal() + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testWhenSuccessBlocking() { + let eventLoop = makeEventLoop() + let sem = DispatchSemaphore(value: 0) + let nonBlockingRan = NIOLockedValueBox(false) + let p = eventLoop.makePromise(of: String.self) + p.futureResult.whenSuccessBlocking(onto: DispatchQueue.global()) { + sem.wait() // Block in callback + #expect($0 == "hello") + nonBlockingRan.withLockedValue { #expect($0) } + + } + p.succeed("hello") + + let p2 = eventLoop.makePromise(of: Bool.self) + p2.futureResult.whenSuccess { _ in + nonBlockingRan.withLockedValue { $0 = true } + } + p2.succeed(true) + + let didRun = try! p2.futureResult.wait() + #expect(didRun) + sem.signal() + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testWhenFailureBlocking() { + let eventLoop = makeEventLoop() + let sem = DispatchSemaphore(value: 0) + let nonBlockingRan = NIOLockedValueBox(false) + let p = eventLoop.makePromise(of: String.self) + p.futureResult.whenFailureBlocking(onto: DispatchQueue.global()) { err in + sem.wait() // Block in callback + #expect(err as! EventLoopFutureTestError == EventLoopFutureTestError.example) + #expect(nonBlockingRan.withLockedValue { $0 }) + } + p.fail(EventLoopFutureTestError.example) + + let p2 = eventLoop.makePromise(of: Bool.self) + p2.futureResult.whenSuccess { _ in + nonBlockingRan.withLockedValue { $0 = true } + } + p2.succeed(true) + + let didRun = try! p2.futureResult.wait() + #expect(didRun) + sem.signal() + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testWhenCompleteBlockingSuccess() { + let eventLoop = makeEventLoop() + let sem = DispatchSemaphore(value: 0) + let nonBlockingRan = NIOLockedValueBox(false) + let p = eventLoop.makePromise(of: String.self) + p.futureResult.whenCompleteBlocking(onto: DispatchQueue.global()) { _ in + sem.wait() // Block in callback + #expect(nonBlockingRan.withLockedValue { $0 }) + } + p.succeed("hello") + + let p2 = eventLoop.makePromise(of: Bool.self) + p2.futureResult.whenSuccess { _ in + nonBlockingRan.withLockedValue { $0 = true } + } + p2.succeed(true) + + let didRun = try! p2.futureResult.wait() + #expect(didRun) + sem.signal() + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testWhenCompleteBlockingFailure() { + let eventLoop = makeEventLoop() + let sem = DispatchSemaphore(value: 0) + let nonBlockingRan = NIOLockedValueBox(false) + let p = eventLoop.makePromise(of: String.self) + p.futureResult.whenCompleteBlocking(onto: DispatchQueue.global()) { _ in + sem.wait() // Block in callback + #expect(nonBlockingRan.withLockedValue { $0 }) + } + p.fail(EventLoopFutureTestError.example) + + let p2 = eventLoop.makePromise(of: Bool.self) + p2.futureResult.whenSuccess { _ in + nonBlockingRan.withLockedValue { $0 = true } + } + p2.succeed(true) + + let didRun = try! p2.futureResult.wait() + #expect(didRun) + sem.signal() + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testFlatMapWithEL() throws { + let el = makeEventLoop() + + let result = try el.makeSucceededFuture(1).flatMapWithEventLoop { one, el2 in + #expect(el === el2) + return el2.makeSucceededFuture(one + 1) + }.wait() + #expect(2 == result) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testFlatMapErrorWithEL() throws { + let el = makeEventLoop() + struct E: Error {} + + let result = try el.makeFailedFuture(E()).flatMapErrorWithEventLoop { error, el2 in + #expect(error is E) + return el2.makeSucceededFuture(1) + }.wait() + #expect(1 == result) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testFoldWithEL() throws { + let el = makeEventLoop() + + let futures = (1...10).map { el.makeSucceededFuture($0) } + + let calls = NIOLockedValueBox(0) + let all = el.makeSucceededFuture(0).foldWithEventLoop(futures) { l, r, el2 in + calls.withLockedValue { $0 += 1 } + #expect(el === el2) + #expect(calls.withLockedValue { $0 } == r) + return el2.makeSucceededFuture(l + r) + } + + let expectedResult = (1...10).reduce(0, +) + let result = try all.wait() + #expect(expectedResult == result) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testAssertSuccess() { + let eventLoop = makeEventLoop() + + let promise = eventLoop.makePromise(of: String.self) + let assertedFuture = promise.futureResult.assertSuccess() + promise.succeed("hello") + + #expect(throws: Never.self) { try assertedFuture.wait() } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testAssertFailure() { + let eventLoop = makeEventLoop() + + let promise = eventLoop.makePromise(of: String.self) + let assertedFuture = promise.futureResult.assertFailure() + promise.fail(EventLoopFutureTestError.example) + + #expect(throws: EventLoopFutureTestError.example) { + try assertedFuture.wait() + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testPreconditionSuccess() { + let eventLoop = makeEventLoop() + + let promise = eventLoop.makePromise(of: String.self) + let preconditionedFuture = promise.futureResult.preconditionSuccess() + promise.succeed("hello") + + #expect(throws: Never.self) { try preconditionedFuture.wait() } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testPreconditionFailure() { + let eventLoop = makeEventLoop() + + let promise = eventLoop.makePromise(of: String.self) + let preconditionedFuture = promise.futureResult.preconditionFailure() + promise.fail(EventLoopFutureTestError.example) + + #expect(throws: EventLoopFutureTestError.example) { + try preconditionedFuture.wait() + } + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testSetOrCascadeReplacesNil() throws { + let eventLoop = makeEventLoop() + + var promise: EventLoopPromise? = nil + let other = eventLoop.makePromise(of: Void.self) + promise.setOrCascade(to: other) + #expect(promise != nil) + promise?.succeed() + try other.futureResult.wait() + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testSetOrCascadeCascadesToExisting() throws { + let eventLoop = makeEventLoop() + + var promise: EventLoopPromise? = eventLoop.makePromise(of: Void.self) + let other = eventLoop.makePromise(of: Void.self) + promise.setOrCascade(to: other) + promise?.succeed() + try other.futureResult.wait() + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testSetOrCascadeNoOpOnNil() throws { + let eventLoop = makeEventLoop() + + var promise: EventLoopPromise? = eventLoop.makePromise(of: Void.self) + promise.setOrCascade(to: nil) + #expect(promise != nil) + promise?.succeed() + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testPromiseEquatable() { + let eventLoop = makeEventLoop() + + let promise1 = eventLoop.makePromise(of: Void.self) + let promise2 = eventLoop.makePromise(of: Void.self) + let promise3 = promise1 + #expect(promise1 == promise3) + #expect(promise1 != promise2) + #expect(promise3 != promise2) + + promise1.succeed() + promise2.succeed() + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testPromiseEquatable_WhenSucceeded() { + let eventLoop = makeEventLoop() + + let promise1 = eventLoop.makePromise(of: Void.self) + let promise2 = eventLoop.makePromise(of: Void.self) + let promise3 = promise1 + + promise1.succeed() + promise2.succeed() + #expect(promise1 == promise3) + #expect(promise1 != promise2) + #expect(promise3 != promise2) + } + + @Test + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) + func testPromiseEquatable_WhenFailed() { + struct E: Error {} + let eventLoop = makeEventLoop() + + let promise1 = eventLoop.makePromise(of: Void.self) + let promise2 = eventLoop.makePromise(of: Void.self) + let promise3 = promise1 + + promise1.fail(E()) + promise2.fail(E()) + #expect(promise1 == promise3) + #expect(promise1 != promise2) + #expect(promise3 != promise2) + } +} + +class NonSendableObject: Equatable { + var value: Int + init(value: Int) { + self.value = value + } + + static func == (lhs: NonSendableObject, rhs: NonSendableObject) -> Bool { + lhs.value == rhs.value + } +} +@available(*, unavailable) +extension NonSendableObject: Sendable {} diff --git a/Tests/NIOAsyncRuntimeTests/TestUtils.swift b/Tests/NIOAsyncRuntimeTests/TestUtils.swift new file mode 100644 index 00000000000..b1b796fbb31 --- /dev/null +++ b/Tests/NIOAsyncRuntimeTests/TestUtils.swift @@ -0,0 +1,112 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2026 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +// swift-format-ignore: AmbiguousTrailingClosureOverload + +import NIOConcurrencyHelpers +import XCTest + +@testable import NIOCore + +func assertNoThrowWithValue( + _ body: @autoclosure () throws -> T, + defaultValue: T? = nil, + message: String? = nil, + file: StaticString = #filePath, + line: UInt = #line +) throws -> T { + do { + return try body() + } catch { + XCTFail( + "\(message.map { $0 + ": " } ?? "")unexpected error \(error) thrown", + file: (file), + line: line + ) + if let defaultValue = defaultValue { + return defaultValue + } else { + throw error + } + } +} + +func assert( + _ condition: @autoclosure () -> Bool, + within time: TimeAmount, + testInterval: TimeAmount? = nil, + _ message: String = "condition not satisfied in time", + file: StaticString = #filePath, + line: UInt = #line +) { + let testInterval = testInterval ?? TimeAmount.nanoseconds(time.nanoseconds / 5) + let endTime = NIODeadline.now() + time + + repeat { + if condition() { return } + usleep(UInt32(testInterval.nanoseconds / 1000)) + } while NIODeadline.now() < endTime + + if !condition() { + XCTFail(message, file: (file), line: line) + } +} + +func assertSuccess( + _ result: Result, + file: StaticString = #filePath, + line: UInt = #line +) { + guard case .success = result else { + return XCTFail("Expected result to be successful", file: (file), line: line) + } +} + +func assertFailure( + _ result: Result, + file: StaticString = #filePath, + line: UInt = #line +) { + guard case .failure = result else { + return XCTFail("Expected result to be a failure", file: (file), line: line) + } +} + +extension EventLoopFuture { + var isFulfilled: Bool { + if self.eventLoop.inEventLoop { + // Easy, we're on the EventLoop. Let's just use our knowledge that we run completed future callbacks + // immediately. + var fulfilled = false + self.assumeIsolated().whenComplete { _ in + fulfilled = true + } + return fulfilled + } else { + let fulfilledBox = NIOLockedValueBox(false) + let group = DispatchGroup() + + group.enter() + self.eventLoop.execute { + let isFulfilled = self.isFulfilled // This will now enter the above branch. + fulfilledBox.withLockedValue { + $0 = isFulfilled + } + group.leave() + } + group.wait() // this is very nasty but this is for tests only, so... + return fulfilledBox.withLockedValue { $0 } + } + } +} diff --git a/Tests/NIOHTTP1Tests/HTTPHeaderValidationTests.swift b/Tests/NIOHTTP1Tests/HTTPHeaderValidationTests.swift index 346a64a8591..dd320ef50bd 100644 --- a/Tests/NIOHTTP1Tests/HTTPHeaderValidationTests.swift +++ b/Tests/NIOHTTP1Tests/HTTPHeaderValidationTests.swift @@ -12,14 +12,13 @@ // //===----------------------------------------------------------------------===// -import Dispatch import NIOCore import NIOEmbedded import NIOHTTP1 -import XCTest +import Testing -final class HTTPHeaderValidationTests: XCTestCase { - func testEncodingInvalidHeaderFieldNamesInRequests() throws { +@Suite struct HTTPHeaderValidationTests { + @Test func encodingInvalidHeaderFieldNamesInRequests() throws { // The spec in [RFC 9110](https://httpwg.org/specs/rfc9110.html#fields.values) defines the valid // characters as the following: // @@ -32,7 +31,8 @@ final class HTTPHeaderValidationTests: XCTestCase { // / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" // / DIGIT / ALPHA // ; any VCHAR, except delimiters - let weirdAllowedFieldName = "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + let weirdAllowedFieldName = + "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" let channel = EmbeddedChannel() try channel.pipeline.syncOperations.addHTTPClientHandlers() @@ -43,13 +43,9 @@ final class HTTPHeaderValidationTests: XCTestCase { string: "GET / HTTP/1.1\r\nHost: example.com\r\n\(weirdAllowedFieldName): present\r\n\r\n" ) - XCTAssertNoThrow(try channel.writeOutbound(HTTPClientRequestPart.head(goodRequest))) - XCTAssertNoThrow(try channel.writeOutbound(HTTPClientRequestPart.end(nil))) - - var maybeReceivedBytes: ByteBuffer? - - XCTAssertNoThrow(maybeReceivedBytes = try channel.readOutbound()) - XCTAssertEqual(maybeReceivedBytes, goodRequestBytes) + #expect(throws: Never.self) { try channel.writeOutbound(HTTPClientRequestPart.head(goodRequest)) } + #expect(throws: Never.self) { try channel.writeOutbound(HTTPClientRequestPart.end(nil)) } + #expect(try channel.readOutbound(as: ByteBuffer.self) == goodRequestBytes) // Now confirm all other bytes are rejected. for byte in UInt8(0)...UInt8(255) { @@ -65,17 +61,18 @@ final class HTTPHeaderValidationTests: XCTestCase { let headers = HTTPHeaders([("Host", "example.com"), (forbiddenFieldName, "present")]) let badRequest = HTTPRequestHead(version: .http1_1, method: .GET, uri: "/", headers: headers) - XCTAssertThrowsError( - try channel.writeOutbound(HTTPClientRequestPart.head(badRequest)), + let error = #expect( + throws: HTTPParserError.self, "Incorrectly tolerated character in header field name: \(String(decoding: [byte], as: UTF8.self))" - ) { error in - XCTAssertEqual(error as? HTTPParserError, .invalidHeaderToken) + ) { + try channel.writeOutbound(HTTPClientRequestPart.head(badRequest)) } + #expect(error == .invalidHeaderToken) _ = try? channel.finish() } } - func testEncodingInvalidTrailerFieldNamesInRequests() throws { + @Test func encodingInvalidTrailerFieldNamesInRequests() throws { // The spec in [RFC 9110](https://httpwg.org/specs/rfc9110.html#fields.values) defines the valid // characters as the following: // @@ -88,7 +85,8 @@ final class HTTPHeaderValidationTests: XCTestCase { // / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" // / DIGIT / ALPHA // ; any VCHAR, except delimiters - let weirdAllowedFieldName = "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + let weirdAllowedFieldName = + "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" let channel = EmbeddedChannel() try channel.pipeline.syncOperations.addHTTPClientHandlers() @@ -100,16 +98,13 @@ final class HTTPHeaderValidationTests: XCTestCase { ) let goodTrailers = ByteBuffer(string: "0\r\n\(weirdAllowedFieldName): present\r\n\r\n") - XCTAssertNoThrow(try channel.writeOutbound(HTTPClientRequestPart.head(goodRequest))) - XCTAssertNoThrow(try channel.writeOutbound(HTTPClientRequestPart.end([weirdAllowedFieldName: "present"]))) - - var maybeRequestHeadBytes: ByteBuffer? - var maybeRequestEndBytes: ByteBuffer? + #expect(throws: Never.self) { try channel.writeOutbound(HTTPClientRequestPart.head(goodRequest)) } + #expect(throws: Never.self) { + try channel.writeOutbound(HTTPClientRequestPart.end([weirdAllowedFieldName: "present"])) + } - XCTAssertNoThrow(maybeRequestHeadBytes = try channel.readOutbound()) - XCTAssertNoThrow(maybeRequestEndBytes = try channel.readOutbound()) - XCTAssertEqual(maybeRequestHeadBytes, goodRequestBytes) - XCTAssertEqual(maybeRequestEndBytes, goodTrailers) + #expect(try channel.readOutbound(as: ByteBuffer.self) == goodRequestBytes) + #expect(try channel.readOutbound(as: ByteBuffer.self) == goodTrailers) // Now confirm all other bytes are rejected. for byte in UInt8(0)...UInt8(255) { @@ -122,19 +117,20 @@ final class HTTPHeaderValidationTests: XCTestCase { let channel = EmbeddedChannel() try channel.pipeline.syncOperations.addHTTPClientHandlers() - XCTAssertNoThrow(try channel.writeOutbound(HTTPClientRequestPart.head(goodRequest))) + #expect(throws: Never.self) { try channel.writeOutbound(HTTPClientRequestPart.head(goodRequest)) } - XCTAssertThrowsError( - try channel.writeOutbound(HTTPClientRequestPart.end([forbiddenFieldName: "present"])), + let error = #expect( + throws: HTTPParserError.self, "Incorrectly tolerated character in trailer field name: \(String(decoding: [byte], as: UTF8.self))" - ) { error in - XCTAssertEqual(error as? HTTPParserError, .invalidHeaderToken) + ) { + try channel.writeOutbound(HTTPClientRequestPart.end([forbiddenFieldName: "present"])) } + #expect(error == .invalidHeaderToken) _ = try? channel.finish() } } - func testEncodingInvalidHeaderFieldValuesInRequests() throws { + @Test func encodingInvalidHeaderFieldValuesInRequests() throws { // We reject all ASCII control characters except HTAB and tolerate everything else. let weirdAllowedFieldValue = "!\" \t#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" @@ -148,12 +144,8 @@ final class HTTPHeaderValidationTests: XCTestCase { string: "GET / HTTP/1.1\r\nHost: example.com\r\nWeird-Value: \(weirdAllowedFieldValue)\r\n\r\n" ) - XCTAssertNoThrow(try channel.writeOutbound(HTTPClientRequestPart.head(goodRequest))) - - var maybeBytes: ByteBuffer? - - XCTAssertNoThrow(maybeBytes = try channel.readOutbound()) - XCTAssertEqual(maybeBytes, goodRequestBytes) + try channel.writeOutbound(HTTPClientRequestPart.head(goodRequest)) + #expect(try channel.readOutbound(as: ByteBuffer.self) == goodRequestBytes) // Now confirm all other bytes in the ASCII range are rejected. for byte in UInt8(0)..?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" @@ -217,16 +205,13 @@ final class HTTPHeaderValidationTests: XCTestCase { ) let goodTrailers = ByteBuffer(string: "0\r\nWeird-Value: \(weirdAllowedFieldValue)\r\n\r\n") - XCTAssertNoThrow(try channel.writeOutbound(HTTPClientRequestPart.head(goodRequest))) - XCTAssertNoThrow(try channel.writeOutbound(HTTPClientRequestPart.end(["Weird-Value": weirdAllowedFieldValue]))) - - var maybeRequestHeadBytes: ByteBuffer? - var maybeRequestEndBytes: ByteBuffer? + #expect(throws: Never.self) { try channel.writeOutbound(HTTPClientRequestPart.head(goodRequest)) } + #expect(throws: Never.self) { + try channel.writeOutbound(HTTPClientRequestPart.end(["Weird-Value": weirdAllowedFieldValue])) + } - XCTAssertNoThrow(maybeRequestHeadBytes = try channel.readOutbound()) - XCTAssertNoThrow(maybeRequestEndBytes = try channel.readOutbound()) - XCTAssertEqual(maybeRequestHeadBytes, goodRequestBytes) - XCTAssertEqual(maybeRequestEndBytes, goodTrailers) + #expect(try channel.readOutbound(as: ByteBuffer.self) == goodRequestBytes) + #expect(try channel.readOutbound(as: ByteBuffer.self) == goodTrailers) // Now confirm all other bytes in the ASCII range are rejected. for byte in UInt8(0)..?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" @@ -404,12 +384,8 @@ final class HTTPHeaderValidationTests: XCTestCase { string: "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nWeird-Value: \(weirdAllowedFieldValue)\r\n\r\n" ) - XCTAssertNoThrow(try channel.writeOutbound(HTTPServerResponsePart.head(goodResponse))) - - var maybeBytes: ByteBuffer? - - XCTAssertNoThrow(maybeBytes = try channel.readOutbound()) - XCTAssertEqual(maybeBytes, goodResponseBytes) + #expect(throws: Never.self) { try channel.writeOutbound(HTTPServerResponsePart.head(goodResponse)) } + #expect(try channel.readOutbound(as: ByteBuffer.self) == goodResponseBytes) // Now confirm all other bytes in the ASCII range are rejected. for byte in UInt8(0)..?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" @@ -474,16 +447,13 @@ final class HTTPHeaderValidationTests: XCTestCase { let goodResponseBytes = ByteBuffer(string: "HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\n\r\n") let goodTrailers = ByteBuffer(string: "0\r\nWeird-Value: \(weirdAllowedFieldValue)\r\n\r\n") - XCTAssertNoThrow(try channel.writeOutbound(HTTPServerResponsePart.head(goodResponse))) - XCTAssertNoThrow(try channel.writeOutbound(HTTPServerResponsePart.end(["Weird-Value": weirdAllowedFieldValue]))) - - var maybeResponseHeadBytes: ByteBuffer? - var maybeResponseEndBytes: ByteBuffer? + #expect(throws: Never.self) { try channel.writeOutbound(HTTPServerResponsePart.head(goodResponse)) } + #expect(throws: Never.self) { + try channel.writeOutbound(HTTPServerResponsePart.end(["Weird-Value": weirdAllowedFieldValue])) + } - XCTAssertNoThrow(maybeResponseHeadBytes = try channel.readOutbound()) - XCTAssertNoThrow(maybeResponseEndBytes = try channel.readOutbound()) - XCTAssertEqual(maybeResponseHeadBytes, goodResponseBytes) - XCTAssertEqual(maybeResponseEndBytes, goodTrailers) + #expect(try channel.readOutbound(as: ByteBuffer.self) == goodResponseBytes) + #expect(try channel.readOutbound(as: ByteBuffer.self) == goodTrailers) // Now confirm all other bytes in the ASCII range are rejected. for byte in UInt8(0).. /dev/null; snapshot_return_code=$?