From c3a8d18219e32c1eb218c96243eefa130c05cf9f Mon Sep 17 00:00:00 2001 From: Si Beaumont Date: Fri, 6 Dec 2024 17:08:56 +0000 Subject: [PATCH] Add EventLoop.now API for getting the current time (#3015) ### Motivation Code that tries to work with `NIODeadline` can be challenging to test using `EmbeddedEventLoop` and `NIOAsyncTestingEventLoop` because they have their own, fake clock. These testing event loops do implement `scheduleTask(in:_)` to submit work in the future, relative to the event loop clock, but there is no way to get the event loop's current notion of "now", as a `NIODeadline`, and users sometimes find that `NIODeadline.now`, which returns the time of the real clock, to be surprising. ### Modifications Add `EventLoop.now` to get the current time of the event loop clock. ### Result New APIs to support writing code that's easier to test. --- Sources/NIOCore/EventLoop.swift | 8 +++++ .../NIOEmbedded/AsyncTestingEventLoop.swift | 4 ++- Sources/NIOEmbedded/Embedded.swift | 3 +- Sources/NIOPosix/SelectableEventLoop.swift | 6 ++++ .../AsyncTestingEventLoopTests.swift | 10 ++++++ .../EmbeddedEventLoopTest.swift | 33 ++++++++++++++++++- Tests/NIOPosixTests/EventLoopTest.swift | 8 +++++ 7 files changed, 69 insertions(+), 3 deletions(-) diff --git a/Sources/NIOCore/EventLoop.swift b/Sources/NIOCore/EventLoop.swift index 376f06d8fb..021725af97 100644 --- a/Sources/NIOCore/EventLoop.swift +++ b/Sources/NIOCore/EventLoop.swift @@ -269,6 +269,9 @@ public protocol EventLoop: EventLoopGroup { @preconcurrency func submit(_ task: @escaping @Sendable () throws -> T) -> EventLoopFuture + /// The current time of the event loop. + var now: NIODeadline { get } + /// Schedule a `task` that is executed by this `EventLoop` at the given time. /// /// - Parameters: @@ -394,6 +397,11 @@ public protocol EventLoop: EventLoopGroup { func cancelScheduledCallback(_ scheduledCallback: NIOScheduledCallback) } +extension EventLoop { + /// Default implementation of `now`: Returns `NIODeadline.now()`. + public var now: NIODeadline { .now() } +} + extension EventLoop { /// Default implementation of `makeSucceededVoidFuture`: Return a fresh future (which will allocate). public func makeSucceededVoidFuture() -> EventLoopFuture { diff --git a/Sources/NIOEmbedded/AsyncTestingEventLoop.swift b/Sources/NIOEmbedded/AsyncTestingEventLoop.swift index 14e5242df0..7997d38e36 100644 --- a/Sources/NIOEmbedded/AsyncTestingEventLoop.swift +++ b/Sources/NIOEmbedded/AsyncTestingEventLoop.swift @@ -63,7 +63,9 @@ public final class NIOAsyncTestingEventLoop: EventLoop, @unchecked Sendable { /// The current "time" for this event loop. This is an amount in nanoseconds. /// As we need to access this from any thread, we store this as an atomic. private let _now = ManagedAtomic(0) - internal var now: NIODeadline { + + /// The current "time" for this event loop. This is an amount in nanoseconds. + public var now: NIODeadline { NIODeadline.uptimeNanoseconds(self._now.load(ordering: .relaxed)) } diff --git a/Sources/NIOEmbedded/Embedded.swift b/Sources/NIOEmbedded/Embedded.swift index c509a8ffa3..70148c4163 100644 --- a/Sources/NIOEmbedded/Embedded.swift +++ b/Sources/NIOEmbedded/Embedded.swift @@ -94,8 +94,9 @@ extension EmbeddedScheduledTask: Comparable { /// responsible for ensuring they never call into the `EmbeddedEventLoop` in an /// unsynchronized fashion. public final class EmbeddedEventLoop: EventLoop, CustomStringConvertible { + private var _now: NIODeadline = .uptimeNanoseconds(0) /// The current "time" for this event loop. This is an amount in nanoseconds. - internal var _now: NIODeadline = .uptimeNanoseconds(0) + public var now: NIODeadline { _now } private enum State { case open, closing, closed } private var state: State = .open diff --git a/Sources/NIOPosix/SelectableEventLoop.swift b/Sources/NIOPosix/SelectableEventLoop.swift index 89fc4737f9..9449d62978 100644 --- a/Sources/NIOPosix/SelectableEventLoop.swift +++ b/Sources/NIOPosix/SelectableEventLoop.swift @@ -298,6 +298,12 @@ internal final class SelectableEventLoop: EventLoop { thread.isCurrent } + /// - see: `EventLoop.now` + @usableFromInline + internal var now: NIODeadline { + .now() + } + /// - see: `EventLoop.scheduleTask(deadline:_:)` @inlinable internal func scheduleTask(deadline: NIODeadline, _ task: @escaping () throws -> T) -> Scheduled { diff --git a/Tests/NIOEmbeddedTests/AsyncTestingEventLoopTests.swift b/Tests/NIOEmbeddedTests/AsyncTestingEventLoopTests.swift index 08cee06cb4..66c40c7414 100644 --- a/Tests/NIOEmbeddedTests/AsyncTestingEventLoopTests.swift +++ b/Tests/NIOEmbeddedTests/AsyncTestingEventLoopTests.swift @@ -619,4 +619,14 @@ final class NIOAsyncTestingEventLoopTests: XCTestCase { await eventLoop.advanceTime(by: .seconds(1)) XCTAssertEqual(counter.load(ordering: .relaxed), 3) } + + func testCurrentTime() async { + let eventLoop = NIOAsyncTestingEventLoop() + + await eventLoop.advanceTime(to: .uptimeNanoseconds(42)) + XCTAssertEqual(eventLoop.now, .uptimeNanoseconds(42)) + + await eventLoop.advanceTime(by: .nanoseconds(42)) + XCTAssertEqual(eventLoop.now, .uptimeNanoseconds(84)) + } } diff --git a/Tests/NIOEmbeddedTests/EmbeddedEventLoopTest.swift b/Tests/NIOEmbeddedTests/EmbeddedEventLoopTest.swift index a23665ec25..b1690f7f86 100644 --- a/Tests/NIOEmbeddedTests/EmbeddedEventLoopTest.swift +++ b/Tests/NIOEmbeddedTests/EmbeddedEventLoopTest.swift @@ -396,7 +396,7 @@ public final class EmbeddedEventLoopTest: XCTestCase { try eventLoop.syncShutdownGracefully() childTasks.append( scheduleRecursiveTask( - at: eventLoop._now + childTaskStartDelay, + at: eventLoop.now + childTaskStartDelay, andChildTaskAfter: childTaskStartDelay ) ) @@ -497,4 +497,35 @@ public final class EmbeddedEventLoopTest: XCTestCase { eventLoop.advanceTime(by: .seconds(1)) XCTAssertEqual(counter, 3) } + + func testCurrentTime() { + let eventLoop = EmbeddedEventLoop() + + eventLoop.advanceTime(to: .uptimeNanoseconds(42)) + XCTAssertEqual(eventLoop.now, .uptimeNanoseconds(42)) + + eventLoop.advanceTime(by: .nanoseconds(42)) + XCTAssertEqual(eventLoop.now, .uptimeNanoseconds(84)) + } + + func testScheduleRepeatedTask() { + let eventLoop = EmbeddedEventLoop() + + var counter = 0 + eventLoop.scheduleRepeatedTask(initialDelay: .seconds(1), delay: .seconds(1)) { repeatedTask in + guard counter < 10 else { + repeatedTask.cancel() + return + } + counter += 1 + } + + XCTAssertEqual(counter, 0) + + eventLoop.advanceTime(by: .seconds(1)) + XCTAssertEqual(counter, 1) + + eventLoop.advanceTime(by: .seconds(9)) + XCTAssertEqual(counter, 10) + } } diff --git a/Tests/NIOPosixTests/EventLoopTest.swift b/Tests/NIOPosixTests/EventLoopTest.swift index f14ba2dc4f..947d916be0 100644 --- a/Tests/NIOPosixTests/EventLoopTest.swift +++ b/Tests/NIOPosixTests/EventLoopTest.swift @@ -1962,6 +1962,10 @@ private class EventLoopWithPreSucceededFuture: EventLoop { preconditionFailure("not implemented") } + var now: NIODeadline { + preconditionFailure("not implemented") + } + @discardableResult func scheduleTask(deadline: NIODeadline, _ task: @escaping () throws -> T) -> Scheduled { preconditionFailure("not implemented") @@ -2013,6 +2017,10 @@ private class EventLoopWithoutPreSucceededFuture: EventLoop { preconditionFailure("not implemented") } + var now: NIODeadline { + preconditionFailure("not implemented") + } + @discardableResult func scheduleTask(deadline: NIODeadline, _ task: @escaping () throws -> T) -> Scheduled { preconditionFailure("not implemented")