From 4820791fe5fd5ce4ade52f3ca4066f2e575429a8 Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Fri, 14 Nov 2025 13:25:27 -0700 Subject: [PATCH 01/11] build: Initial commit of swift package init with package name nio-async-runtime and target/library name NIOAsyncRuntime --- .gitignore | 10 ++-------- Package.swift | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) create mode 100644 Package.swift 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.swift b/Package.swift new file mode 100644 index 0000000..86cdc29 --- /dev/null +++ b/Package.swift @@ -0,0 +1,32 @@ +// 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"] + ), + ], + targets: [ + .target( + name: "NIOAsyncRuntime" + ), + .testTarget( + name: "NIOAsyncRuntimeTests", + dependencies: ["NIOAsyncRuntime"] + ), + ] +) From 7a438659f0943cd3f6c4fb71a300b63b163f2109 Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Fri, 14 Nov 2025 14:05:38 -0700 Subject: [PATCH 02/11] build: Add dependencies used by NIOAsyncRuntime. --- Package.resolved | 42 ++++++++++++++++++++++++++++++++++++++++++ Package.swift | 46 ++++++++++++++++++++++++++++++---------------- 2 files changed, 72 insertions(+), 16 deletions(-) create mode 100644 Package.resolved 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 index 86cdc29..7c6a56c 100644 --- a/Package.swift +++ b/Package.swift @@ -13,20 +13,34 @@ import PackageDescription let package = Package( - name: "nio-async-runtime", - products: [ - .library( - name: "NIOAsyncRuntime", - targets: ["NIOAsyncRuntime"] - ), - ], - targets: [ - .target( - name: "NIOAsyncRuntime" - ), - .testTarget( - name: "NIOAsyncRuntimeTests", - dependencies: ["NIOAsyncRuntime"] - ), - ] + 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"), + ], + ), + ] ) From e070adc948a0c2934cd252b31e841209965cfbb4 Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Fri, 14 Nov 2025 14:09:09 -0700 Subject: [PATCH 03/11] feat: Add AsyncEventLoopExecutor that forms the foundation for creating AsyncEventLoop. --- .../AsyncEventLoopExecutor.swift | 529 ++++++++++++++++++ 1 file changed, 529 insertions(+) create mode 100644 Sources/NIOAsyncRuntime/AsyncEventLoopExecutor.swift 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 + } +} From 4b332bd8d9e96d53bd3ae33a0aeaee63d651622d Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Fri, 14 Nov 2025 14:14:14 -0700 Subject: [PATCH 04/11] feat: Add testing utilities that make a number of nio-related tests much easier to run. --- Tests/NIOAsyncRuntimeTests/TestUtils.swift | 103 +++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 Tests/NIOAsyncRuntimeTests/TestUtils.swift 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 } + } + } +} From f29a0b98b19b3e17d0184da88b27750538b3c6c5 Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Fri, 14 Nov 2025 14:21:18 -0700 Subject: [PATCH 05/11] feat: Implement AsynceEventLoop and MultiThreadedEventLoopGroup using Swift Concurrency and the AsyncEventLoopExecutor. Forms a key foundation for several vapor repositories to use NIO without using NIOPosix. This enables a large amount of wasm compilation for packages that currently consume NIOPosix. --- Sources/NIOAsyncRuntime/AsyncEventLoop.swift | 355 ++++++++++++++++++ .../MultiThreadedEventLoopGroup.swift | 81 ++++ 2 files changed, 436 insertions(+) create mode 100644 Sources/NIOAsyncRuntime/AsyncEventLoop.swift create mode 100644 Sources/NIOAsyncRuntime/MultiThreadedEventLoopGroup.swift 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/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 +} From 22b45a31f43d8b225754bf9b0afe6f00a6473b9c Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Fri, 14 Nov 2025 14:22:34 -0700 Subject: [PATCH 06/11] test: Add tests for AsyncEventLoop and AsyncEventLoopExecutor. The vast majority of these tests were ported from NIOPosix tests for SelectableEventLoop, which AsyncEventLoop replaces. --- .../AsyncEventLoopTests.swift | 1628 +++++++++++++++++ 1 file changed, 1628 insertions(+) create mode 100644 Tests/NIOAsyncRuntimeTests/AsyncEventLoopTests.swift 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 + } +} From 71e80a195f2307a79f0dbd0161c321f8580ab03f Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Fri, 14 Nov 2025 14:26:41 -0700 Subject: [PATCH 07/11] test: Add tests for MultiThreadedEventLoopGroup in NIOAsyncRuntime. The vast majority of these tests were ported from NIOPosix tests for its own MultiThreadedEventLoopGroup. These tests ensure basic feature parity between the two different implementations. --- .../MultiThreadedEventLoopGroupTests.swift | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 Tests/NIOAsyncRuntimeTests/MultiThreadedEventLoopGroupTests.swift 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() + } + } + } +} From 09060898d3afe6a1a920a0fd2566ceb41ca3726b Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Fri, 14 Nov 2025 14:45:57 -0700 Subject: [PATCH 08/11] feat: Implement NIOThreadPool using swift concurrency --- Sources/NIOAsyncRuntime/NIOThreadPool.swift | 286 ++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 Sources/NIOAsyncRuntime/NIOThreadPool.swift 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 } + } +} From 72a692f34e1dd79b7364eca722e301de6552fedc Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Fri, 14 Nov 2025 14:46:11 -0700 Subject: [PATCH 09/11] test: Add tests for NIOThreadPool --- .../NIOThreadPoolTests.swift | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 Tests/NIOAsyncRuntimeTests/NIOThreadPoolTests.swift 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() + } + + } +} From 1b28ae030cf3c86ac1f798724b407fd4e46b7a7d Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Fri, 14 Nov 2025 14:46:39 -0700 Subject: [PATCH 10/11] test: Add tests for EventLoopFuture usages, ported from swift-nio to ensure feature parity. --- .../EventLoopFutureTest.swift | 1853 +++++++++++++++++ 1 file changed, 1853 insertions(+) create mode 100644 Tests/NIOAsyncRuntimeTests/EventLoopFutureTest.swift 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 {} From 24a2d659d613db87a8a3ace55ce69480ac9616eb Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Mon, 1 Dec 2025 16:22:33 -0700 Subject: [PATCH 11/11] ci: Set up basic pull request CI using https://github.com/PassiveLogic/swift-dispatch-async/blob/main/.github/workflows/pull_request.yml as a source of inspiration. --- .github/workflows/pull_request.yml | 44 ++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/pull_request.yml 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