diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml new file mode 100644 index 0000000..47f41da --- /dev/null +++ b/.github/workflows/pull_request.yml @@ -0,0 +1,44 @@ +name: Pull request + +on: + pull_request: + types: [opened, reopened, synchronize] + +jobs: + soundness: + name: Soundness + uses: swiftlang/github-workflows/.github/workflows/soundness.yml@main + with: + format_check_container_image: swift:6.1.0-noble + license_header_check_enabled: false + api_breakage_check_enabled: false + + tests: + name: Tests + uses: swiftlang/github-workflows/.github/workflows/swift_package_test.yml@main + with: + enable_macos_checks: false + linux_exclude_swift_versions: "[{\"swift_version\": \"5.9\"}, {\"swift_version\": \"5.10\"}, {\"swift_version\": \"5.10.1\"}, {\"swift_version\": \"6.0\"}]" + enable_windows_checks: false + enable_wasm_sdk_build: true + enable_embedded_wasm_sdk_build: false + + wasm-sdk: + name: WebAssembly SDK + runs-on: ubuntu-latest + container: + image: "swift:6.1.0-noble" + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Swift version + run: swift --version + - name: WasmBuild + run: | + apt-get update -y -q + apt-get install -y -q curl + apt-get install -y -q jq + version="$(swift --version | head -n1)" + tag="$(curl -sL "https://raw.githubusercontent.com/swiftwasm/swift-sdk-index/refs/heads/main/v1/tag-by-version.json" | jq -e -r --arg v "$version" '.[$v] | .[-1]')" + curl -sL "https://raw.githubusercontent.com/swiftwasm/swift-sdk-index/refs/heads/main/v1/builds/$tag.json" | jq -r '.["swift-sdks"]["wasm32-unknown-wasi"] | "swift sdk install \"\(.url)\" --checksum \"\(.checksum)\""' | sh -x + swift build --swift-sdk wasm32-unknown-wasi diff --git a/.gitignore b/.gitignore index 52fe2f7..53298db 100644 --- a/.gitignore +++ b/.gitignore @@ -18,16 +18,10 @@ timeline.xctimeline playground.xcworkspace # Swift Package Manager -# -# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. -# Packages/ -# Package.pins -# Package.resolved -# *.xcodeproj -# + # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata # hence it is not needed unless you have added a package configuration file to your project -# .swiftpm +.swiftpm .build/ diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..2e74a4a --- /dev/null +++ b/Package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "23e94f6a7762b68c41183501a81832fe19ff8108a47e2adf1db0fda8cd2e7bea", + "pins" : [ + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "56724a2b6d8e2aed1b2c5f23865b9ea5c43f9977", + "version" : "2.89.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db", + "version" : "1.6.3" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..7c6a56c --- /dev/null +++ b/Package.swift @@ -0,0 +1,46 @@ +// swift-tools-version: 6.0 +//===----------------------------------------------------------------------===// +// +// Copyright (c) 2025 PassiveLogic, Inc. +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import PackageDescription + +let package = Package( + name: "nio-async-runtime", + products: [ + .library( + name: "NIOAsyncRuntime", + targets: ["NIOAsyncRuntime"] + ) + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-atomics.git", from: "1.1.0"), + .package(url: "https://github.com/apple/swift-nio.git", from: "2.89.0"), + ], + targets: [ + .target( + name: "NIOAsyncRuntime", + dependencies: [ + .product(name: "Atomics", package: "swift-atomics"), + .product(name: "NIOCore", package: "swift-nio"), + ], + ), + .testTarget( + name: "NIOAsyncRuntimeTests", + dependencies: [ + "NIOAsyncRuntime", + .product(name: "NIOConcurrencyHelpers", package: "swift-nio"), + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOFoundationCompat", package: "swift-nio"), + .product(name: "NIOTestUtils", package: "swift-nio"), + ], + ), + ] +) diff --git a/Sources/NIOAsyncRuntime/AsyncEventLoop.swift b/Sources/NIOAsyncRuntime/AsyncEventLoop.swift new file mode 100644 index 0000000..8de9a5c --- /dev/null +++ b/Sources/NIOAsyncRuntime/AsyncEventLoop.swift @@ -0,0 +1,355 @@ +//===----------------------------------------------------------------------===// +// +// Copyright (c) 2025 PassiveLogic, Inc. +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Atomics +import NIOCore + +import struct Foundation.UUID + +#if canImport(Dispatch) + import Dispatch +#endif + +// MARK: - AsyncEventLoop - + +/// A single‑threaded `EventLoop` implemented solely with Swift Concurrency. +@available(macOS 13, *) +public final class AsyncEventLoop: EventLoop, @unchecked Sendable { + public enum AsynceEventLoopError: Error { + case cancellationFailure + } + + private let _id = UUID() // unique identifier + private let executor: AsyncEventLoopExecutor + private var cachedSucceededVoidFuture: EventLoopFuture? + private enum ShutdownState: UInt8 { + case running = 0 + case closing = 1 + case closed = 2 + } + private let shutdownState = ManagedAtomic(ShutdownState.running.rawValue) + + public init(manualTimeModeForTesting: Bool = false) { + self.executor = AsyncEventLoopExecutor(loopID: _id, manualTimeMode: manualTimeModeForTesting) + } + + // MARK: - EventLoop basics - + + public var inEventLoop: Bool { + _CurrentEventLoopKey.id == _id + } + + private func isAcceptingNewTasks() -> Bool { + shutdownState.load(ordering: .acquiring) == ShutdownState.running.rawValue + } + + private func isFullyShutdown() -> Bool { + shutdownState.load(ordering: .acquiring) == ShutdownState.closed.rawValue + } + + @_disfavoredOverload + public func execute(_ task: @escaping @Sendable () -> Void) { + guard self.isAcceptingNewTasks() || self._canAcceptExecuteDuringShutdown else { return } + executor.enqueue(task) + } + + private var _canAcceptExecuteDuringShutdown: Bool { + self.inEventLoop + || MultiThreadedEventLoopGroup._GroupContextKey.isFromMultiThreadedEventLoopGroup + } + + // MARK: - Promises / Futures - + + public 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 + } + + public func makeFailedFuture(_ error: Error) -> EventLoopFuture { + let p = makePromise(of: T.self) + p.fail(error) + return p.futureResult + } + + public func makeSucceededVoidFuture() -> EventLoopFuture { + if self.inEventLoop { + if let cached = self.cachedSucceededVoidFuture { + return cached + } + let future = self.makeSucceededVoidFutureUncached() + self.cachedSucceededVoidFuture = future + return future + } else { + return self.makeSucceededVoidFutureUncached() + } + } + + private func makeSucceededVoidFutureUncached() -> EventLoopFuture { + let promise = self.makePromise(of: Void.self) + promise.succeed(()) + return promise.futureResult + } + + // MARK: - Submitting work - + @preconcurrency + public func submit(_ task: @escaping @Sendable () throws -> T) -> EventLoopFuture { + self.submit { () throws -> _UncheckedSendable in + _UncheckedSendable(try task()) + }.map { $0.value } + } + + public 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 + } + + public 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 + public 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) + } + + public func scheduleTask( + deadline: NIODeadline, + _ task: @escaping @Sendable () throws -> T + ) -> Scheduled { + self._scheduleTask(deadline: deadline, task: task) + } + + @preconcurrency + public 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) + } + + public func scheduleTask( + in delay: TimeAmount, + _ task: @escaping @Sendable () throws -> T + ) -> Scheduled { + self._scheduleTask(in: delay, task: task) + } + + 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) { [weak self] in + // 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) { [weak self] in + // 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.rawValue, ordering: .acquiring) + guard ShutdownState(rawValue: previous) != .closed else { return } + self.cachedSucceededVoidFuture = nil + await executor.clearQueue() + shutdownState.store(ShutdownState.closed.rawValue, ordering: .releasing) + } + + public func next() -> EventLoop { + self + } + public 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. + func advanceTime(by increment: TimeAmount) async throws { + try await executor.advanceTime(by: increment) + } + + func advanceTime(to deadline: NIODeadline) async throws { + try await executor.advanceTime(to: deadline) + } + + func run() async { + await executor.run() + } + + #if canImport(Dispatch) + public func shutdownGracefully( + queue: DispatchQueue, _ callback: @escaping @Sendable (Error?) -> Void + ) { + if MultiThreadedEventLoopGroup._GroupContextKey.isFromMultiThreadedEventLoopGroup { + 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 + + public func syncShutdownGracefully() throws { + // The test AsyncEventLoopTests.testIllegalCloseOfEventLoopFails requires + // this implementation to throw an error, because uses should call shutdown on + // MultiThreadedEventLoopGroup instead of calling it directly on the loop. + throw EventLoopError.unsupportedOperation + } + + public func shutdownGracefully() async throws { + await self.closeGracefully() + } + + #if !canImport(Dispatch) + public 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. + private struct _UncheckedSendable: @unchecked Sendable { + let value: T + init(_ value: T) { self.value = value } + } +} diff --git a/Sources/NIOAsyncRuntime/AsyncEventLoopExecutor.swift b/Sources/NIOAsyncRuntime/AsyncEventLoopExecutor.swift new file mode 100644 index 0000000..1d44faa --- /dev/null +++ b/Sources/NIOAsyncRuntime/AsyncEventLoopExecutor.swift @@ -0,0 +1,529 @@ +//===----------------------------------------------------------------------===// +// +// Copyright (c) 2025 PassiveLogic, Inc. +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOCore +import _NIODataStructures + +import struct Foundation.UUID + +/// Task‑local key that stores the UUID of the `AsyncEventLoop` currently +/// executing. Lets us answer `inEventLoop` without private APIs. +@available(macOS 13, *) +enum _CurrentEventLoopKey { @TaskLocal static var id: UUID? } + +/// This is an actor designed to execute provided tasks in the order then 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 13, *) +actor AsyncEventLoopExecutor { + private let executor: _AsyncEventLoopExecutor + + init(loopID: UUID, manualTimeMode: Bool = false) { + self.executor = _AsyncEventLoopExecutor(loopID: loopID, manualTimeMode: 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 a UUID for the job that can be used to cancel the job if needed + @discardableResult + nonisolated func schedule( + at deadline: NIODeadline, + job: @Sendable @escaping () -> Void, + failFn: @Sendable @escaping (Error) -> Void + ) -> UUID { + let id = UUID() + Task { @_AsyncEventLoopExecutor._IsolatingSerialEntryActor [job, weak self] in + // ^----- Ensures FIFO entry from nonisolated contexts + await self?.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 + nonisolated func schedule( + after delay: TimeAmount, + job: @Sendable @escaping () -> Void, + failFn: @Sendable @escaping (Error) -> Void + ) -> UUID { + let id = UUID() + Task { @_AsyncEventLoopExecutor._IsolatingSerialEntryActor [delay, job, weak self] in + // ^----- Ensures FIFO entry from nonisolated contexts + await self?.executor.schedule(after: delay, id: id, job: job, failFn: failFn) + } + return id + } + + nonisolated func enqueue(_ job: @Sendable @escaping () -> Void) { + Task { @_AsyncEventLoopExecutor._IsolatingSerialEntryActor [job, weak self] in + // ^----- Ensures FIFO entry from nonisolated contexts + await self?.executor.enqueue(job) + } + } + + nonisolated func cancelScheduledJob(withID id: UUID) { + Task { @_AsyncEventLoopExecutor._IsolatingSerialEntryActor [id, weak self] in + // ^----- Ensures FIFO entry from nonisolated contexts + await self?.executor.cancelScheduledJob(withID: id) + } + } + + // MARK: - async API's - + + // NOTE: The following are async api's and don't require special handling + + func clearQueue() async { + await executor.clearQueue() + } + + func advanceTime(by increment: TimeAmount) async throws { + try await executor.advanceTime(by: increment) + } + + func advanceTime(to deadline: NIODeadline) async throws { + try await executor.advanceTime(to: deadline) + } + + func run() async { + await executor.run() + } +} + +/// This class provides the private implementation details for ``AsyncEventLoopExecutor``. +/// +/// However, it defers the nonisolated API's to ``AsyncEventLoopExecutor`` which +/// helps make the isolation boundary very clear. +@available(macOS 13, *) +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 manualTimeMode { + _now + } else { + NIODeadline.now() + } + } + + /// We use this actor to make serialized FIFO 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: UUID + let deadline: NIODeadline + let order: OrderIntegerType + let job: @Sendable () -> Void + let failFn: @Sendable (Error) -> Void + + init( + id: UUID = UUID(), + 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 manualTimeMode: Bool + private var wakeUpTask: Task? + private var jobQueue: [() -> Void] = [] + private var pendingCancellationJobIDs: Set = [] + + let loopID: UUID + init(loopID: UUID, manualTimeMode: Bool = false) { + self.loopID = loopID + self.manualTimeMode = manualTimeMode + } + + fileprivate func schedule( + after delay: TimeAmount, + id: UUID, + 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: UUID, + job: @Sendable @escaping () -> Void, + failFn: @Sendable @escaping (Error) -> Void + ) { + if pendingCancellationJobIDs.remove(id) != nil { + return + } + 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 FIFO entry from nonisolated contexts + await noOp() // We want to await for self here + }.value + } + + private func noOp() {} + + private func schedulingNow() -> NIODeadline { + if manualTimeMode { + return _now + } else { + let wallNow = NIODeadline.now() + _now = max(_now, wallNow) + return _now + } + } + + /// Moves time forward by specified increment, and runs event loop, causing + /// all pending events either from enqueing or scheduling requirements to run. + fileprivate func advanceTime(by increment: TimeAmount) async throws { + guard manualTimeMode else { + throw EventLoopError.unsupportedOperation + } + try await self.advanceTime(to: self._now + increment) + } + + fileprivate func advanceTime(to deadline: NIODeadline) async throws { + guard 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 + } + } + + @discardableResult + private func runNextJobIfNeeded() -> Task? { + // Stop if both queues are empty. + if jobQueue.isEmpty && scheduledQueue.isEmpty { + // no more tasks to run + return nil + } + + // No need to start if a task is already running + if let existingTask = currentlyRunningExecutorTask { + return existingTask + } + + // 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) { + // 1. Run all jobs currently in taskQueue + runEnqueuedJobs() + + // 2. Run all jobs in scheduledQueue past the due date + let snapshot = await runPastDueScheduledJobs(nowSnapshot: captureNowSnapshot()) + + // 3. Schedule next run or wake‑up if needed. + scheduleNextRunIfNeeded(latestSnapshot: snapshot) + } + } + currentlyRunningExecutorTask = newTask + return newTask + } + + private func captureNowSnapshot() -> NIODeadline { + if 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 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 = !manualTimeMode + if shouldScheduleWakeUp, let nextScheduledTask = scheduledQueue.peek() { + let interval = nextScheduledTask.deadline - nowSnapshot + let nanoseconds = max(interval.nanoseconds, 0) + 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: UUID) { + scheduledQueue.removeFirst(where: { $0.id == id }) + } + + fileprivate func clearQueue() async { + await awaitPendingEntryOperations() + cancelScheduledWakeUp() + pendingCancellationJobIDs.removeAll() + 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() + pendingCancellationJobIDs.removeAll() + } + + 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 13, *) +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 + } +} diff --git a/Sources/NIOAsyncRuntime/MultiThreadedEventLoopGroup.swift b/Sources/NIOAsyncRuntime/MultiThreadedEventLoopGroup.swift new file mode 100644 index 0000000..d632806 --- /dev/null +++ b/Sources/NIOAsyncRuntime/MultiThreadedEventLoopGroup.swift @@ -0,0 +1,81 @@ +//===----------------------------------------------------------------------===// +// +// Copyright (c) 2025 PassiveLogic, Inc. +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import class Atomics.ManagedAtomic +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. +@available(macOS 13, *) +public final class MultiThreadedEventLoopGroup: EventLoopGroup, @unchecked 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. + @available(macOS 13, *) + enum _GroupContextKey { @TaskLocal static var isFromMultiThreadedEventLoopGroup: Bool = false } + + private let loops: [AsyncEventLoop] + private let counter = ManagedAtomic(0) + + public init(numberOfThreads: Int = System.coreCount) { + precondition(numberOfThreads > 0, "thread count must be positive") + self.loops = (0.. EventLoop { + loops[counter.loadThenWrappingIncrement(ordering: .sequentiallyConsistent) % 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 { + await _GroupContextKey.$isFromMultiThreadedEventLoopGroup.withValue(true) { + for loop in loops { await loop.closeGracefully() } + + queue.async { + onCompletion(nil) + } + } + } + } + #endif // canImport(Dispatch) + + public static let singleton = MultiThreadedEventLoopGroup() + + #if !canImport(Dispatch) + public func _preconditionSafeToSyncShutdown(file: StaticString, line: UInt) { + assertionFailure( + "Synchronous shutdown API's are not currently supported by MultiThreadedEventLoopGroup") + } + #endif +} diff --git a/Sources/NIOAsyncRuntime/NIOThreadPool.swift b/Sources/NIOAsyncRuntime/NIOThreadPool.swift new file mode 100644 index 0000000..2a164a5 --- /dev/null +++ b/Sources/NIOAsyncRuntime/NIOThreadPool.swift @@ -0,0 +1,286 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2025 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 DequeModule +import NIOConcurrencyHelpers + +import class Atomics.ManagedAtomic +import protocol NIOCore.EventLoop +import class NIOCore.EventLoopFuture +import enum NIOCore.System + +/// Errors that may be thrown when executing work on a `NIOThreadPool`. +public enum NIOThreadPoolError: Sendable { + public struct ThreadPoolInactive: Error { + public init() {} + } + + public struct UnsupportedOperation: Error { + public init() {} + } +} + +/// Drop‑in stand‑in for `NIOThreadPool`, powered by Swift Concurrency. +@available(macOS 10.15, *) +public final class NIOThreadPool: @unchecked 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 = ManagedAtomic(false) + private let started = ManagedAtomic(false) + private let numberOfThreads: Int + private let workQueue = WorkQueue() + private let workerTasksLock = NIOLock() + private var workerTasks: [Task] = [] + + 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 self.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(NIOThreadPoolError.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(NIOThreadPoolError.ThreadPoolInactive()) + } + } + return promise.futureResult + } + + // Lifecycle -------------------------------------------------------------- + + public static let singleton: NIOThreadPool = { + let pool = NIOThreadPool() + pool.start() + return pool + }() + + @preconcurrency + public func shutdownGracefully(_ callback: @escaping @Sendable (Error?) -> Void = { _ in }) { + _shutdownGracefully { + callback(nil) + } + } + + @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) + 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) + } + + workerTasksLock.withLock { + for worker in workerTasks { + worker.cancel() + } + workerTasks.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() { + workerTasksLock.withLock { + guard workerTasks.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: ManagedAtomic) 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 } + } +} diff --git a/Tests/NIOAsyncRuntimeTests/AsyncEventLoopTests.swift b/Tests/NIOAsyncRuntimeTests/AsyncEventLoopTests.swift new file mode 100644 index 0000000..c1be350 --- /dev/null +++ b/Tests/NIOAsyncRuntimeTests/AsyncEventLoopTests.swift @@ -0,0 +1,1628 @@ +//===----------------------------------------------------------------------===// +// +// Copyright (c) 2025 PassiveLogic, Inc. +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Atomics +import Dispatch +import NIOConcurrencyHelpers +import Testing + +@testable import NIOAsyncRuntime +@testable import NIOCore + +// NOTE: These tests are copied and adapted from NIOPosixTests.EventLoopTest +// They have been modified to use async running, among other things. + +@Suite("AsyncEventLoopTests", .serialized, .timeLimit(.minutes(1))) +final class AsyncEventLoopTests { + private func makeEventLoop() -> AsyncEventLoop { + AsyncEventLoop(manualTimeModeForTesting: true) + } + + private func assertThat( + future: EventLoopFuture, + isFulfilled: Bool, + sourceLocation: SourceLocation = #_sourceLocation, + ) async { + let isFutureFulfilled = future.isFulfilled + #expect(isFutureFulfilled == isFulfilled, sourceLocation: sourceLocation) + } + + @Test + 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.advanceTime(by: .seconds(2)) // should fire now + + await assertThat(future: scheduled.futureResult, isFulfilled: true) + let result3 = result.load(ordering: .sequentiallyConsistent) + #expect(result3) + } + + @Test + 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.advanceTime(by: .seconds(2)) // should fire now + await assertThat(future: scheduled.futureResult, isFulfilled: true) + + let result3 = result.load(ordering: .sequentiallyConsistent) + #expect(result3) + } + + @Test + func testScheduledTaskWakesEventLoopFromIdle() async throws { + let eventLoop = AsyncEventLoop(manualTimeModeForTesting: 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 + 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.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 + func testScheduleCancelled() async throws { + let eventLoop = makeEventLoop() + + let scheduled = eventLoop.scheduleTask(in: .seconds(1)) { true } + + do { + try await eventLoop.advanceTime(by: .milliseconds(500)) // advance halfway to firing time + scheduled.cancel() + try await eventLoop.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 + func testFlatScheduleCancelled() async throws { + let eventLoop = makeEventLoop() + + let scheduled = eventLoop.flatScheduleTask(in: .seconds(1)) { + eventLoop.makeSucceededFuture(true) + } + + do { + try await eventLoop.advanceTime(by: .milliseconds(500)) // advance halfway to firing time + scheduled.cancel() + try await eventLoop.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 + func testScheduleRepeatedTask() throws { + let nanos: NIODeadline = .now() + let initialDelay: TimeAmount = .milliseconds(5) + let delay: TimeAmount = .milliseconds(10) + let count = 5 + let eventLoopGroup = MultiThreadedEventLoopGroup(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 + func testScheduledTaskThatIsImmediatelyCancelledNeverFires() async throws { + let eventLoop = makeEventLoop() + let scheduled = eventLoop.scheduleTask(in: .seconds(1)) { true } + + do { + scheduled.cancel() + try await eventLoop.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 + func testScheduledTasksAreOrdered() async throws { + let eventLoopGroup = MultiThreadedEventLoopGroup(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 + func testFlatScheduledTaskThatIsImmediatelyCancelledNeverFires() async throws { + let eventLoop = makeEventLoop() + let scheduled = eventLoop.flatScheduleTask(in: .seconds(1)) { + eventLoop.makeSucceededFuture(true) + } + + do { + scheduled.cancel() + try await eventLoop.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 + func testRepeatedTaskThatIsImmediatelyCancelledNeverFires() async throws { + let eventLoopGroup = MultiThreadedEventLoopGroup(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 + 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 = MultiThreadedEventLoopGroup(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 + func testScheduleRepeatedTaskToNotRetainRepeatedTask() throws { + let initialDelay: TimeAmount = .milliseconds(5) + let delay: TimeAmount = .milliseconds(10) + let eventLoopGroup = MultiThreadedEventLoopGroup(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 + func testScheduleRepeatedTaskToNotRetainEventLoop() throws { + weak var weakEventLoop: EventLoop? = nil + let initialDelay: TimeAmount = .milliseconds(5) + let delay: TimeAmount = .milliseconds(10) + let eventLoopGroup = MultiThreadedEventLoopGroup(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 + 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..<30 { + // 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.advanceTime(by: .milliseconds(5)) + await eventLoop.run() + #expect(0 == counter) + + // At == 10ms, counter == 1 + try await eventLoop.advanceTime(by: .milliseconds(5)) + await eventLoop.run() + #expect(1 == counter) + + // At t == 15ms, counter == 1 + try await eventLoop.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.advanceTime(by: .milliseconds(5)) + #expect(1 == counter) + + // At t == 25, counter == 1 (because the task takes 10ms to execute) + try await eventLoop.advanceTime(by: .milliseconds(5)) + #expect(1 == counter) + + // At t == 30ms, counter == 2 + try await eventLoop.advanceTime(by: .milliseconds(5)) + #expect(2 == counter) + + // At t == 40ms, counter == 2 + try await eventLoop.advanceTime(by: .milliseconds(10)) + #expect(2 == counter) + + // At t == 50ms, counter == 3 + try await eventLoop.advanceTime(by: .milliseconds(10)) + #expect(3 == counter) + + // At t == 60ms, counter == 3 + try await eventLoop.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.advanceTime(by: .milliseconds(29)) + #expect(4 == counter) + + // At t == 90ms, counter == 5 + try await eventLoop.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.advanceTime(by: .hours(10)) + #expect(5 == counter) + } + + @Test + func testScheduledRepeatedAsyncTaskIsJittered() async throws { + let initialDelay = TimeAmount.minutes(5) + let delay = TimeAmount.minutes(2) + let maximumAllowableJitter = TimeAmount.minutes(1) + let counter = ManagedAtomic(0) + let loop = makeEventLoop() + + _ = loop.scheduleRepeatedAsyncTask( + initialDelay: initialDelay, + delay: delay, + maximumAllowableJitter: maximumAllowableJitter, + { _ in + counter.wrappingIncrement(ordering: .relaxed) + let p = loop.makePromise(of: Void.self) + _ = loop.scheduleTask(in: .milliseconds(10)) { + p.succeed(()) + } + return p.futureResult + } + ) + + for _ in 0..<10 { + // just running shouldn't do anything + await loop.run() + } + + let timeRange = TimeAmount.hours(1) + // Due to jittered delays is not possible to exactly know how many tasks will be executed in a given time range, + // instead calculate a range representing an estimate of the number of tasks executed during that given time range. + let minNumberOfExecutedTasks = + (timeRange.nanoseconds - initialDelay.nanoseconds) + / (delay.nanoseconds + maximumAllowableJitter.nanoseconds) + let maxNumberOfExecutedTasks = + (timeRange.nanoseconds - initialDelay.nanoseconds) / delay.nanoseconds + 1 + + try await loop.advanceTime(by: timeRange) + #expect( + (minNumberOfExecutedTasks...maxNumberOfExecutedTasks).contains( + counter.load(ordering: .relaxed))) + } + + @Test + func testEventLoopGroupMakeIterator() throws { + let eventLoopGroup = MultiThreadedEventLoopGroup(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 + 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) + + await eventLoop.closeGracefully() + } + + @Test + func testExecuteRejectedWhileShuttingDown() async { + let eventLoop = makeEventLoop() + let didRun = ManagedAtomic(false) + + let shutdownTask = Task { + await eventLoop.closeGracefully() + } + + await Task.yield() + + eventLoop.execute { + didRun.store(true, ordering: .relaxed) + } + + await shutdownTask.value + + #expect(didRun.load(ordering: .relaxed) == false) + } + + @Test + func testShutdownWhileScheduledTasksNotReady() throws { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + let eventLoop = group.next() + _ = eventLoop.scheduleTask(in: .hours(1)) {} + try group.syncShutdownGracefully() + } + + @Test + func testScheduleMultipleTasks() async throws { + let eventLoopGroup = MultiThreadedEventLoopGroup(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 + func testRepeatedTaskThatIsImmediatelyCancelledNotifies() async throws { + let eventLoopGroup = MultiThreadedEventLoopGroup(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 + func testRepeatedTaskThatIsCancelledAfterRunningAtLeastTwiceNotifies() async throws { + let eventLoopGroup = MultiThreadedEventLoopGroup(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 + func testRepeatedTaskThatCancelsItselfNotifiesOnlyWhenFinished() async throws { + let eventLoopGroup = MultiThreadedEventLoopGroup(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 + func testRepeatedTaskIsJittered() async throws { + let initialDelay = TimeAmount.minutes(5) + let delay = TimeAmount.minutes(2) + 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) + } + ) + + let timeRange = TimeAmount.hours(1) + // Due to jittered delays is not possible to exactly know how many tasks will be executed in a given time range, + // instead calculate a range representing an estimate of the number of tasks executed during that given time range. + let minNumberOfExecutedTasks = + (timeRange.nanoseconds - initialDelay.nanoseconds) + / (delay.nanoseconds + maximumAllowableJitter.nanoseconds) + let maxNumberOfExecutedTasks = + (timeRange.nanoseconds - initialDelay.nanoseconds) / delay.nanoseconds + 1 + + try await loop.advanceTime(by: timeRange) + #expect( + (minNumberOfExecutedTasks...maxNumberOfExecutedTasks).contains( + counter.load(ordering: .relaxed))) + } + + @Test + func testCancelledScheduledTasksDoNotHoldOnToRunClosure() async throws { + let group = MultiThreadedEventLoopGroup(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 + func testCancelledScheduledTasksDoNotHoldOnToRunClosureEvenIfTheyWereTheNextTaskToExecute() + async throws + { + let group = MultiThreadedEventLoopGroup(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 + func testIllegalCloseOfEventLoopFails() { + // Vapor 3 closes EventLoops directly which is illegal and makes the `shutdownGracefully` of the owning + // MultiThreadedEventLoopGroup never succeed. + let group = MultiThreadedEventLoopGroup(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 + func testCallingSyncShutdownGracefullyMultipleTimesShouldNotHang() throws { + let elg = MultiThreadedEventLoopGroup(numberOfThreads: 4) + try elg.syncShutdownGracefully() + try elg.syncShutdownGracefully() + try elg.syncShutdownGracefully() + } + + @Test + func testCallingShutdownGracefullyMultipleTimesShouldExecuteAllCallbacks() throws { + let elg = MultiThreadedEventLoopGroup(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 + 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 + 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 + 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 + func testSuccessfulFlatSubmit() async throws { + let eventLoop = makeEventLoop() + let future = eventLoop.flatSubmit { + eventLoop.makeSucceededFuture(1) + } + let result = try await future.get() + #expect(result == 1) + } + + @Test + 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 + func testSchedulingTaskOnTheEventLoopWithinTheEventLoopsOnlyTask() throws { + let elg = MultiThreadedEventLoopGroup(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 + func testCancellingTheLastOutstandingTask() async throws { + let elg = MultiThreadedEventLoopGroup(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 + func testSchedulingTaskOnTheEventLoopWithinTheEventLoopsOnlyScheduledTask() throws { + let elg = MultiThreadedEventLoopGroup(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 + func testWeFailOutstandingScheduledTasksOnELShutdown() { + let group = MultiThreadedEventLoopGroup(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 + func testSchedulingTaskOnFutureFailedByELShutdownDoesNotMakeUsExplode() throws { + let group = MultiThreadedEventLoopGroup(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 + func testEventLoopGroupProvider() { + let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { + #expect(throws: Never.self) { + try eventLoopGroup.syncShutdownGracefully() + } + } + + let provider = NIOEventLoopGroupProvider.shared(eventLoopGroup) + + if case .shared(let sharedEventLoopGroup) = provider { + #expect(sharedEventLoopGroup is MultiThreadedEventLoopGroup) + #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 + 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 + func testSelectableEventLoopHasPreSucceededFuturesOnlyOnTheEventLoop() throws { + let elg = MultiThreadedEventLoopGroup(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 + 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 + 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 + func testEventLoopGroupsWithoutAnyImplementationAreValid() async throws { + let group = EventLoopGroupOf3WithoutAnAnyImplementation() + defer { + #expect(throws: Never.self) { + try group.syncShutdownGracefully() + } + } + + 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() + } + } + + @Test + func testAsyncToFutureConversionSuccess() async throws { + guard #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) else { return } + let group = MultiThreadedEventLoopGroup(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 + func testAsyncToFutureConversionFailure() async throws { + guard #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) else { return } + let group = MultiThreadedEventLoopGroup(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 + func testNonStarvation() throws { + let group = MultiThreadedEventLoopGroup(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 + func testMixedImmediateAndScheduledTasks() async throws { + let group = MultiThreadedEventLoopGroup(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) + } +} + +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) + } + } +} + +final class EventLoopGroupOf3WithoutAnAnyImplementation: EventLoopGroup { + private static func makeEventLoop() -> AsyncEventLoop { + AsyncEventLoop(manualTimeModeForTesting: 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 AsyncEventLoopTestsTimeoutError: 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 AsyncEventLoopTestsTimeoutError.timeout + } + + guard let value = try await group.next() else { + throw AsyncEventLoopTestsTimeoutError.timeout + } + group.cancelAll() + return value + } +} diff --git a/Tests/NIOAsyncRuntimeTests/EventLoopFutureTest.swift b/Tests/NIOAsyncRuntimeTests/EventLoopFutureTest.swift new file mode 100644 index 0000000..5a58dac --- /dev/null +++ b/Tests/NIOAsyncRuntimeTests/EventLoopFutureTest.swift @@ -0,0 +1,1853 @@ +//===----------------------------------------------------------------------===// +// +// 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 NIOAsyncRuntime +import NIOConcurrencyHelpers +import Testing + +@testable import NIOCore + +enum EventLoopFutureTestError: Error { + case example +} + +@Suite("EventLoopFutureTest", .serialized, .timeLimit(.minutes(1))) +class EventLoopFutureTest { + private func makeEventLoop() -> AsyncEventLoop { + AsyncEventLoop(manualTimeModeForTesting: true) + } + + @Test + func testFutureFulfilledIfHasResult() throws { + let eventLoop = makeEventLoop() + let f = EventLoopFuture(eventLoop: eventLoop, value: 5) + #expect(f.isFulfilled) + } + + @Test + func testFutureFulfilledIfHasError() throws { + let eventLoop = makeEventLoop() + let f = EventLoopFuture(eventLoop: eventLoop, error: EventLoopFutureTestError.example) + #expect(f.isFulfilled) + } + + @Test + func testFoldWithMultipleEventLoops() throws { + let nThreads = 3 + let eventLoopGroup = MultiThreadedEventLoopGroup(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 + 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 + 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 #expect(throws: E.self) { + try await fN.get() + } + #expect(fN.isFulfilled) + } + + @Test + 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 + func testFoldWithFailureAndEmptyFutureList() async 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) + await #expect(throws: E.self) { + try await fN.get() + } + } + + @Test + func testFoldWithFailureAndAllSuccesses() async 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) + await #expect(throws: E.self) { + try await fN.get() + } + } + + @Test + func testFoldWithFailureAndAllUnfulfilled() async 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) + await #expect(throws: E.self) { + try await fN.get() + } + } + + @Test + func testFoldWithFailureAndAllFailures() async 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) + await #expect(throws: E.self) { + try await fN.get() + } + } + + @Test + func testAndAllWithEmptyFutureList() throws { + let eventLoop = makeEventLoop() + let futures: [EventLoopFuture] = [] + + let fN = EventLoopFuture.andAllSucceed(futures, on: eventLoop) + + #expect(fN.isFulfilled) + } + + @Test + 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 + func testAndAllWithAllFailures() async 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()) } + await #expect(throws: E.self) { + try await fN.get() + } + } + + @Test + func testAndAllWithOneFailure() async 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) + await #expect(throws: E.self) { + try await fN.get() + } + } + + @Test + 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 + 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 + func testReduceWithAllFailures() async 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) + await #expect(throws: E.self) { + try await fN.get() + } + } + + @Test + func testReduceWithOneFailure() async 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) + await #expect(throws: E.self) { + try await fN.get() + } + } + + @Test + func testReduceWhichDoesFailFast() async 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) + await #expect(throws: E.self) { + try await fN.get() + } + } + + @Test + 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 + 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 + func testReduceIntoWithAllFailure() async 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) + await #expect(throws: E.self) { + try await fN.get() + } + } + + @Test + func testReduceIntoWithMultipleEventLoops() throws { + let nThreads = 3 + let eventLoopGroup = MultiThreadedEventLoopGroup(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 + 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 + 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 + 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 + 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 + 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 + func testEventLoopHoppingInThen() async throws { + let n = 20 + let elg = MultiThreadedEventLoopGroup(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 await prev.get() + #expect(n - 1 == result) + #expect(throws: Never.self) { try elg.syncShutdownGracefully() } + } + + @Test + func testEventLoopHoppingInThenWithFailures() async throws { + enum DummyError: Error { + case dummy + } + let n = 20 + let elg = MultiThreadedEventLoopGroup(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 + } + await #expect(throws: DummyError.self) { + try await prev.get() + } + #expect(throws: Never.self) { try elg.syncShutdownGracefully() } + } + + @Test + func testEventLoopHoppingAndAll() throws { + let n = 20 + let elg = MultiThreadedEventLoopGroup(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 + func testEventLoopHoppingAndAllWithFailures() async throws { + enum DummyError: Error { case dummy } + let n = 20 + let fireBackEl = MultiThreadedEventLoopGroup(numberOfThreads: 1) + let elg = MultiThreadedEventLoopGroup(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(()) + } + } + } + await #expect(throws: DummyError.self) { + try await allOfEm.get() + } + #expect(throws: Never.self) { try elg.syncShutdownGracefully() } + #expect(throws: Never.self) { try fireBackEl.syncShutdownGracefully() } + } + + @Test + func testFutureInVariousScenarios() throws { + enum DummyError: Error { + case dummy0 + case dummy1 + } + let elg = MultiThreadedEventLoopGroup(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 + func testLoopHoppingHelperSuccess() throws { + let group = MultiThreadedEventLoopGroup(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 + func testLoopHoppingHelperFailure() throws { + let group = MultiThreadedEventLoopGroup(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 + func testLoopHoppingHelperNoHopping() throws { + let group = MultiThreadedEventLoopGroup(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 + 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 + 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 + func testWhenAllSucceedFailsImmediately() async { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 2) + defer { + #expect(throws: Never.self) { try group.syncShutdownGracefully() } + } + + func doTest(promise: EventLoopPromise<[Int]>?) async { + 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) + await #expect(throws: EventLoopFutureTestError.self) { + try await futureResult.get() + } + } + + await doTest(promise: nil) + await doTest(promise: group.next().makePromise()) + } + + @Test + func testWhenAllSucceedResolvesAfterFutures() throws { + let group = MultiThreadedEventLoopGroup(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 + func testWhenAllSucceedIsIndependentOfFulfillmentOrder() throws { + let group = MultiThreadedEventLoopGroup(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 + func testWhenAllCompleteResultsWithFailuresStillSucceed() { + let group = MultiThreadedEventLoopGroup(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 + func testWhenAllCompleteResults() async throws { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 2) + defer { + #expect(throws: Never.self) { try group.syncShutdownGracefully() } + } + + func doTest(promise: EventLoopPromise<[Result]>?) async 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) + } + + await #expect(throws: Never.self) { try await doTest(promise: nil) } + await #expect(throws: Never.self) { try await doTest(promise: group.next().makePromise()) } + } + + @Test + func testWhenAllCompleteResolvesAfterFutures() throws { + let group = MultiThreadedEventLoopGroup(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 + 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 + func testAlwaysWithFailingPromise() async throws { + let group = makeEventLoop() + let loop = group.next() + let db = Database { loop.makeFailedFuture(DatabaseError()) } + + #expect(!db.closed) + + await #expect(throws: DatabaseError.self) { + try await db.runQuery().always { result in + assertFailure(result) + db.close() + }.map { $0.map { $0.uppercased() } }.get() + } + #expect(db.closed) + } + + @Test + 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 + 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 + 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 + func testPromiseCompletedWithFailedFuture() async 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) + await #expect(throws: EventLoopFutureTestError.self) { + try await promise.futureResult.get() + } + } + + @Test + 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 + func testPromiseCompletedWithFailedResult() async throws { + let group = makeEventLoop() + let loop = group.next() + + let promise = loop.makePromise(of: Void.self) + + let result: Result = .failure(EventLoopFutureTestError.example) + promise.completeWith(result) + await #expect(throws: EventLoopFutureTestError.self) { + try await promise.futureResult.get() + } + } + + @Test + 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 + 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 + func testAndAllCompleteWithPreSucceededFutures() async { + let eventLoop = makeEventLoop() + let succeeded = eventLoop.makeSucceededFuture(()) + + for i in 0..<10 { + await #expect(throws: Never.self) { + try await EventLoopFuture.andAllComplete( + Array(repeating: succeeded, count: i), + on: eventLoop + ).get() + } + } + } + + @Test + func testAndAllCompleteWithPreFailedFutures() async { + struct Dummy: Error {} + let eventLoop = makeEventLoop() + let failed: EventLoopFuture = eventLoop.makeFailedFuture(Dummy()) + + for i in 0..<10 { + await #expect(throws: Never.self) { + try await EventLoopFuture.andAllComplete( + Array(repeating: failed, count: i), + on: eventLoop + ).get() + } + } + } + + @Test + 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 + 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 + func testRepeatedTaskOffEventLoopGroupFuture() throws { + let elg1: EventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { + #expect(throws: Never.self) { try elg1.syncShutdownGracefully() } + } + + let elg2: EventLoopGroup = MultiThreadedEventLoopGroup(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 + 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 + func testEventLoopFutureOrThrows() async { + let eventLoop = makeEventLoop() + let promise = eventLoop.makePromise(of: Int?.self) + let result: Result = .success(nil) + promise.completeWith(result) + + await #expect(throws: EventLoopFutureTestError.example) { + try await promise.futureResult.unwrap(orError: EventLoopFutureTestError.example).get() + } + } + + @Test + 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 + 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 + 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 + 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 + func testFlatBlockingMapOnto() async { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + + 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 = blockingRan.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() + + await #expect(throws: Never.self) { + try await group.shutdownGracefully() + } + } + + @Test + 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 + 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 + 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 + 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 + func testFlatMapWithEL() async throws { + let el = makeEventLoop() + + let result = try await el.makeSucceededFuture(1).flatMapWithEventLoop { one, el2 in + #expect(el === el2) + return el2.makeSucceededFuture(one + 1) + }.get() + #expect(2 == result) + } + + @Test + func testFlatMapErrorWithEL() async throws { + let el = makeEventLoop() + struct E: Error {} + + let result = try await el.makeFailedFuture(E()).flatMapErrorWithEventLoop { error, el2 in + #expect(error is E) + return el2.makeSucceededFuture(1) + }.get() + #expect(1 == result) + } + + @Test + func testFoldWithEL() async 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 await all.get() + #expect(expectedResult == result) + } + + @Test + 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 + func testAssertFailure() async { + let eventLoop = makeEventLoop() + + let promise = eventLoop.makePromise(of: String.self) + let assertedFuture = promise.futureResult.assertFailure() + promise.fail(EventLoopFutureTestError.example) + + await #expect(throws: EventLoopFutureTestError.example) { + try await assertedFuture.get() + } + } + + @Test + 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 + func testPreconditionFailure() async { + let eventLoop = makeEventLoop() + + let promise = eventLoop.makePromise(of: String.self) + let preconditionedFuture = promise.futureResult.preconditionFailure() + promise.fail(EventLoopFutureTestError.example) + + await #expect(throws: EventLoopFutureTestError.example) { + try await preconditionedFuture.get() + } + } + + @Test + 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 + 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 + func testSetOrCascadeNoOpOnNil() throws { + let eventLoop = makeEventLoop() + + var promise: EventLoopPromise? = eventLoop.makePromise(of: Void.self) + promise.setOrCascade(to: nil) + #expect(promise != nil) + promise?.succeed() + } + + @Test + 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 + 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 + 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/MultiThreadedEventLoopGroupTests.swift b/Tests/NIOAsyncRuntimeTests/MultiThreadedEventLoopGroupTests.swift new file mode 100644 index 0000000..524c89b --- /dev/null +++ b/Tests/NIOAsyncRuntimeTests/MultiThreadedEventLoopGroupTests.swift @@ -0,0 +1,209 @@ +//===----------------------------------------------------------------------===// +// +// Copyright (c) 2025 PassiveLogic, Inc. +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOConcurrencyHelpers +import Testing + +@testable import NIOAsyncRuntime +@testable import NIOCore + +// NOTE: These tests are copied and adapted from NIOPosixTests.EventLoopTest +// They have been modified to use async running, among other things. + +@Suite("MultiThreadedEventLoopGroupTests", .serialized, .timeLimit(.minutes(1))) +final class MultiThreadedEventLoopGroupTests { + @Test + func testLotsOfMixedImmediateAndScheduledTasks() async throws { + let group = MultiThreadedEventLoopGroup(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 + func testLotsOfMixedImmediateAndScheduledTasksFromEventLoop() async throws { + let group = MultiThreadedEventLoopGroup(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 + func testImmediateTasksDontGetStuck() async throws { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { + #expect(throws: Never.self) { + try group.syncShutdownGracefully() + } + } + + let eventLoop = group.next() + let testEventLoop = MultiThreadedEventLoopGroup.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 + 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 = MultiThreadedEventLoopGroup(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() + } + } + } +} diff --git a/Tests/NIOAsyncRuntimeTests/NIOThreadPoolTests.swift b/Tests/NIOAsyncRuntimeTests/NIOThreadPoolTests.swift new file mode 100644 index 0000000..745fe6d --- /dev/null +++ b/Tests/NIOAsyncRuntimeTests/NIOThreadPoolTests.swift @@ -0,0 +1,166 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2020-2024 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 NIOThreadPoolTest { + private func makeEventLoop() -> AsyncEventLoop { + AsyncEventLoop(manualTimeModeForTesting: true) + } + + @Test + func testThreadPoolStartsMultipleTimes() throws { + let numberOfThreads = 1 + let pool = NIOThreadPool(numberOfThreads: numberOfThreads) + pool.start() + defer { + #expect(throws: Never.self) { pool.shutdownGracefully() } + } + + let completionGroup = DispatchGroup() + + // The lock here is arguably redundant with the dispatchgroup, but let's make + // this test thread-safe even something goes wrong + let threadOne: NIOLockedValueBox = NIOLockedValueBox(UInt?.none) + let threadTwo: NIOLockedValueBox = NIOLockedValueBox(UInt?.none) + + let expectedValue: UInt = 1 + + completionGroup.enter() + pool.submit { s in + precondition(s == .active) + threadOne.withLockedValue { threadOne in + #expect(threadOne == nil) + threadOne = expectedValue + } + completionGroup.leave() + } + + // Now start the thread pool again. This must not destroy existing threads, so our thread should be the same. + pool.start() + completionGroup.enter() + pool.submit { s in + precondition(s == .active) + threadTwo.withLockedValue { threadTwo in + #expect(threadTwo == nil) + threadTwo = expectedValue + } + completionGroup.leave() + } + + completionGroup.wait() + + #expect(threadOne.withLockedValue { $0 } != nil) + #expect(threadTwo.withLockedValue { $0 } != nil) + #expect(threadOne.withLockedValue { $0 } == threadTwo.withLockedValue { $0 }) + } + + @Test + func testAsyncThreadPool() async throws { + let numberOfThreads = 1 + let pool = NIOThreadPool(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 + func testAsyncThreadPoolErrorPropagation() async throws { + struct ThreadPoolError: Error {} + let numberOfThreads = 1 + let pool = NIOThreadPool(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 + func testAsyncThreadPoolNotActiveError() async throws { + struct ThreadPoolError: Error {} + let numberOfThreads = 1 + let pool = NIOThreadPool(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 + func testAsyncThreadPoolCancellation() async throws { + let pool = NIOThreadPool(numberOfThreads: 1) + pool.start() + + await withThrowingTaskGroup(of: Issue.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 + func testAsyncShutdownWorks() async throws { + let threadPool = NIOThreadPool(numberOfThreads: 17) + let eventLoop = makeEventLoop() + + 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/TestUtils.swift b/Tests/NIOAsyncRuntimeTests/TestUtils.swift new file mode 100644 index 0000000..274368b --- /dev/null +++ b/Tests/NIOAsyncRuntimeTests/TestUtils.swift @@ -0,0 +1,103 @@ +//===----------------------------------------------------------------------===// +// +// Copyright (c) 2025 PassiveLogic, Inc. +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// 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 } + } + } +}