Skip to content

Commit 8d2347a

Browse files
ptoffyfabianfett
andauthored
Add scheduleCallback APIs to NIOIsolatedEventLoop (#3263)
Fixes #3262 by adding the missing APIs. ### Motivation: As explained by the issue linked above, having a non-`Sendable`-requiring variant of the new `scheduleCallback` APIs on `NIOIsolatedEventLoop` can be useful since we're not always dealing with `Sendable` types. ### Modifications: This adds the required `scheduleCallback` APIs to `NIOIsolatedEventLoop` by wrapping the non-`Sendable` `NIOScheduleCallbackHandler` in a `NIOLoopBound`-based handler. ### Result: The two `scheduleCallback`s can be used on `NIOIsolatedEventLoop` too. --------- Co-authored-by: Fabian Fett <[email protected]>
1 parent cddbde5 commit 8d2347a

File tree

4 files changed

+295
-4
lines changed

4 files changed

+295
-4
lines changed

Sources/NIOCore/EventLoop.swift

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,11 @@ public protocol EventLoop: EventLoopGroup {
371371

372372
/// Schedule a callback at a given time.
373373
///
374+
/// - Parameters:
375+
/// - deadline: The instant in time before which the task will not execute.
376+
/// - handler: The handler that defines the behavior of the callback when executed or canceled.
377+
/// - Returns: A ``NIOScheduledCallback`` that can be used to cancel the scheduled callback.
378+
///
374379
/// - NOTE: Event loops that provide a custom scheduled callback implementation **must** also implement
375380
/// `cancelScheduledCallback`. Failure to do so will result in a runtime error.
376381
@preconcurrency
@@ -382,6 +387,11 @@ public protocol EventLoop: EventLoopGroup {
382387

383388
/// Schedule a callback after given time.
384389
///
390+
/// - Parameters:
391+
/// - amount: The amount of time before which the task will not execute.
392+
/// - handler: The handler that defines the behavior of the callback when executed or canceled.
393+
/// - Returns: A ``NIOScheduledCallback`` that can be used to cancel the scheduled callback.
394+
///
385395
/// - NOTE: Event loops that provide a custom scheduled callback implementation **must** also implement
386396
/// `cancelScheduledCallback`. Failure to do so will result in a runtime error.
387397
@preconcurrency
@@ -458,6 +468,40 @@ public protocol EventLoop: EventLoopGroup {
458468
in: TimeAmount,
459469
_ task: @escaping () throws -> T
460470
) -> Scheduled<T>
471+
472+
/// Schedule a callback that is executed by this ``EventLoop`` at a given time, from a context where the caller
473+
/// statically knows that the context is isolated.
474+
///
475+
/// This is an optional performance hook. ``EventLoop`` implementers are not required to implement
476+
/// this witness, but may choose to do so to enable better performance of the isolated EL views. If
477+
/// they do so, ``EventLoop/Isolated/scheduleCallback(at:_:)`` will perform better.
478+
///
479+
/// - Parameters:
480+
/// - at: The instant in time before which the task will not execute.
481+
/// - handler: The handler that defines the behavior of the callback when executed or canceled.
482+
/// - Returns: A ``NIOScheduledCallback`` that can be used to cancel the scheduled callback.
483+
@discardableResult
484+
func _scheduleCallbackIsolatedUnsafeUnchecked(
485+
at deadline: NIODeadline,
486+
handler: some NIOScheduledCallbackHandler
487+
) throws -> NIOScheduledCallback
488+
489+
/// Schedule a callback that is executed by this ``EventLoop`` after a given time, from a context where the caller
490+
/// statically knows that the context is isolated.
491+
///
492+
/// This is an optional performance hook. ``EventLoop`` implementers are not required to implement
493+
/// this witness, but may choose to do so to enable better performance of the isolated EL views. If
494+
/// they do so, ``EventLoop/Isolated/scheduleCallback(in:_:)`` will perform better.
495+
///
496+
/// - Parameters:
497+
/// - in: The amount of time before which the task will not execute.
498+
/// - handler: The handler that defines the behavior of the callback when executed or canceled.
499+
/// - Returns: A ``NIOScheduledCallback`` that can be used to cancel the scheduled callback.
500+
@discardableResult
501+
func _scheduleCallbackIsolatedUnsafeUnchecked(
502+
in amount: TimeAmount,
503+
handler: some NIOScheduledCallbackHandler
504+
) throws -> NIOScheduledCallback
461505
}
462506

463507
extension EventLoop {
@@ -533,6 +577,26 @@ extension EventLoop {
533577
try unsafeTransfer.wrappedValue()
534578
}
535579
}
580+
581+
@inlinable
582+
@discardableResult
583+
public func _scheduleCallbackIsolatedUnsafeUnchecked(
584+
at deadline: NIODeadline,
585+
handler: some NIOScheduledCallbackHandler
586+
) throws -> NIOScheduledCallback {
587+
let unsafeHandlerWrapper = LoopBoundScheduledCallbackHandlerWrapper(wrapping: handler, eventLoop: self)
588+
return try self.scheduleCallback(at: deadline, handler: unsafeHandlerWrapper)
589+
}
590+
591+
@inlinable
592+
@discardableResult
593+
public func _scheduleCallbackIsolatedUnsafeUnchecked(
594+
in amount: TimeAmount,
595+
handler: some NIOScheduledCallbackHandler
596+
) throws -> NIOScheduledCallback {
597+
let unsafeHandlerWrapper = LoopBoundScheduledCallbackHandlerWrapper(wrapping: handler, eventLoop: self)
598+
return try self.scheduleCallback(in: amount, handler: unsafeHandlerWrapper)
599+
}
536600
}
537601

538602
extension EventLoop {

Sources/NIOCore/EventLoopFuture+AssumeIsolated.swift

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
/// domains.
2121
///
2222
/// Using this type relaxes the need to have the closures for ``EventLoop/execute(_:)``,
23-
/// ``EventLoop/submit(_:)``, and ``EventLoop/scheduleTask(in:_:)`` to be `@Sendable`.
23+
/// ``EventLoop/submit(_:)``, ``EventLoop/scheduleTask(in:_:)``,
24+
/// and ``EventLoop/scheduleCallback(in:handler:)`` to be `@Sendable`.
2425
public struct NIOIsolatedEventLoop {
2526
@usableFromInline
2627
let _wrapped: EventLoop
@@ -125,6 +126,46 @@ public struct NIOIsolatedEventLoop {
125126
return .init(promise: promise, cancellationTask: { scheduled.cancel() })
126127
}
127128

129+
/// Schedule a callback at a given time.
130+
///
131+
/// - Parameters:
132+
/// - deadline: The instant in time before which the task will not execute.
133+
/// - handler: The handler that defines the behavior of the callback when executed or canceled.
134+
/// - Returns: A ``NIOScheduledCallback`` that can be used to cancel the scheduled callback.
135+
@discardableResult
136+
@available(*, noasync)
137+
@inlinable
138+
public func scheduleCallback(
139+
at deadline: NIODeadline,
140+
handler: some NIOScheduledCallbackHandler
141+
) throws -> NIOScheduledCallback {
142+
try self._wrapped._scheduleCallbackIsolatedUnsafeUnchecked(at: deadline, handler: handler)
143+
}
144+
145+
/// Schedule a callback after given time.
146+
///
147+
/// - Parameters:
148+
/// - amount: The amount of time before which the task will not execute.
149+
/// - handler: The handler that defines the behavior of the callback when executed or canceled.
150+
/// - Returns: A ``NIOScheduledCallback`` that can be used to cancel the scheduled callback.
151+
@discardableResult
152+
@available(*, noasync)
153+
@inlinable
154+
public func scheduleCallback(
155+
in amount: TimeAmount,
156+
handler: some NIOScheduledCallbackHandler
157+
) throws -> NIOScheduledCallback {
158+
try self._wrapped._scheduleCallbackIsolatedUnsafeUnchecked(in: amount, handler: handler)
159+
}
160+
161+
/// Cancel a scheduled callback.
162+
@inlinable
163+
@available(*, noasync)
164+
public func cancelScheduledCallback(_ scheduledCallback: NIOScheduledCallback) {
165+
self._wrapped.preconditionInEventLoop()
166+
self._wrapped.cancelScheduledCallback(scheduledCallback)
167+
}
168+
128169
/// Creates and returns a new `EventLoopFuture` that is already marked as success. Notifications
129170
/// will be done using this `EventLoop` as execution `NIOThread`.
130171
///

Sources/NIOCore/NIOScheduledCallback.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,25 @@ extension EventLoop {
170170
}
171171
}
172172
}
173+
174+
@usableFromInline
175+
struct LoopBoundScheduledCallbackHandlerWrapper<Handler: NIOScheduledCallbackHandler>:
176+
NIOScheduledCallbackHandler, Sendable
177+
{
178+
private let box: NIOLoopBound<Handler>
179+
180+
@usableFromInline
181+
init(wrapping handler: Handler, eventLoop: some EventLoop) {
182+
self.box = .init(handler, eventLoop: eventLoop)
183+
}
184+
185+
@usableFromInline
186+
func handleScheduledCallback(eventLoop: some EventLoop) {
187+
self.box.value.handleScheduledCallback(eventLoop: eventLoop)
188+
}
189+
190+
@usableFromInline
191+
func didCancelScheduledCallback(eventLoop: some EventLoop) {
192+
self.box.value.didCancelScheduledCallback(eventLoop: eventLoop)
193+
}
194+
}

Tests/NIOPosixTests/NIOScheduledCallbackTests.swift

Lines changed: 167 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,140 @@ final class NIOAsyncTestingEventLoopScheduledCallbackTests: _BaseScheduledCallba
7777
}
7878
}
7979

80+
final class IsolatedEventLoopScheduledCallbackTests: XCTestCase {
81+
struct Requirements: ScheduledCallbackTestRequirements {
82+
let _loop = EmbeddedEventLoop()
83+
var loop: (any EventLoop) { self._loop }
84+
85+
func advanceTime(by amount: TimeAmount) {
86+
self._loop.advanceTime(by: amount)
87+
}
88+
89+
func shutdownEventLoop() {
90+
try! self._loop.syncShutdownGracefully()
91+
}
92+
93+
func waitForLoopTick() {}
94+
}
95+
96+
var requirements: Requirements! = nil
97+
var loop: (any EventLoop) { self.requirements.loop }
98+
99+
func advanceTime(by amount: TimeAmount) {
100+
self.requirements.advanceTime(by: amount)
101+
}
102+
103+
func shutdownEventLoop() {
104+
self.requirements.shutdownEventLoop()
105+
}
106+
107+
override func setUp() {
108+
self.requirements = Requirements()
109+
}
110+
111+
func testScheduledCallbackNotExecutedBeforeDeadline() throws {
112+
let handler = NonSendableMockScheduledCallbackHandler()
113+
114+
_ = try self.loop.assumeIsolated().scheduleCallback(in: .milliseconds(1), handler: handler)
115+
handler.assert(callbackCount: 0, cancelCount: 0)
116+
117+
self.advanceTime(by: .microseconds(1))
118+
handler.assert(callbackCount: 0, cancelCount: 0)
119+
}
120+
121+
func testScheduledCallbackExecutedAtDeadline() throws {
122+
let handler = NonSendableMockScheduledCallbackHandler()
123+
124+
_ = try self.loop.assumeIsolated().scheduleCallback(in: .milliseconds(1), handler: handler)
125+
self.advanceTime(by: .milliseconds(1))
126+
handler.assert(callbackCount: 1, cancelCount: 0)
127+
}
128+
129+
func testMultipleScheduledCallbacksUsingSameHandler() throws {
130+
let handler = NonSendableMockScheduledCallbackHandler()
131+
132+
_ = try self.loop.assumeIsolated().scheduleCallback(in: .milliseconds(1), handler: handler)
133+
_ = try self.loop.assumeIsolated().scheduleCallback(in: .milliseconds(1), handler: handler)
134+
135+
self.advanceTime(by: .milliseconds(1))
136+
handler.assert(callbackCount: 2, cancelCount: 0)
137+
138+
_ = try self.loop.assumeIsolated().scheduleCallback(in: .milliseconds(2), handler: handler)
139+
_ = try self.loop.assumeIsolated().scheduleCallback(in: .milliseconds(3), handler: handler)
140+
141+
self.advanceTime(by: .milliseconds(3))
142+
handler.assert(callbackCount: 4, cancelCount: 0)
143+
}
144+
145+
func testCancelExecutesCancellationCallback() throws {
146+
let handler = NonSendableMockScheduledCallbackHandler()
147+
148+
let scheduledCallback = try self.loop.assumeIsolated().scheduleCallback(in: .milliseconds(1), handler: handler)
149+
scheduledCallback.cancel()
150+
handler.assert(callbackCount: 0, cancelCount: 1)
151+
}
152+
153+
func testCancelAfterDeadlineDoesNotExecutesCancellationCallback() throws {
154+
let handler = NonSendableMockScheduledCallbackHandler()
155+
156+
let scheduledCallback = try self.loop.assumeIsolated().scheduleCallback(in: .milliseconds(1), handler: handler)
157+
self.advanceTime(by: .milliseconds(1))
158+
scheduledCallback.cancel()
159+
self.requirements.waitForLoopTick()
160+
handler.assert(callbackCount: 1, cancelCount: 0)
161+
}
162+
163+
func testCancelAfterCancelDoesNotCallCancellationCallbackAgain() throws {
164+
let handler = NonSendableMockScheduledCallbackHandler()
165+
166+
let scheduledCallback = try self.loop.assumeIsolated().scheduleCallback(in: .milliseconds(1), handler: handler)
167+
scheduledCallback.cancel()
168+
scheduledCallback.cancel()
169+
handler.assert(callbackCount: 0, cancelCount: 1)
170+
}
171+
172+
func testCancelAfterShutdownDoesNotCallCancellationCallbackAgain() throws {
173+
let handler = NonSendableMockScheduledCallbackHandler()
174+
175+
let scheduledCallback = try self.loop.assumeIsolated().scheduleCallback(in: .milliseconds(1), handler: handler)
176+
self.shutdownEventLoop()
177+
handler.assert(callbackCount: 0, cancelCount: 1)
178+
179+
scheduledCallback.cancel()
180+
handler.assert(callbackCount: 0, cancelCount: 1)
181+
}
182+
183+
func testShutdownCancelsOutstandingScheduledCallbacks() throws {
184+
let handler = NonSendableMockScheduledCallbackHandler()
185+
186+
_ = try self.loop.assumeIsolated().scheduleCallback(in: .milliseconds(1), handler: handler)
187+
self.shutdownEventLoop()
188+
handler.assert(callbackCount: 0, cancelCount: 1)
189+
}
190+
191+
func testShutdownDoesNotCancelCancelledCallbacksAgain() throws {
192+
let handler = NonSendableMockScheduledCallbackHandler()
193+
194+
let handle = try self.loop.assumeIsolated().scheduleCallback(in: .milliseconds(1), handler: handler)
195+
handle.cancel()
196+
handler.assert(callbackCount: 0, cancelCount: 1)
197+
198+
self.shutdownEventLoop()
199+
handler.assert(callbackCount: 0, cancelCount: 1)
200+
}
201+
202+
func testShutdownDoesNotCancelPastCallbacks() throws {
203+
let handler = NonSendableMockScheduledCallbackHandler()
204+
205+
_ = try self.loop.assumeIsolated().scheduleCallback(in: .milliseconds(1), handler: handler)
206+
self.advanceTime(by: .milliseconds(1))
207+
handler.assert(callbackCount: 1, cancelCount: 0)
208+
209+
self.shutdownEventLoop()
210+
handler.assert(callbackCount: 1, cancelCount: 0)
211+
}
212+
}
213+
80214
class _BaseScheduledCallbackTests: XCTestCase {
81215
// EL-specific test requirements.
82216
var requirements: (any ScheduledCallbackTestRequirements)! = nil
@@ -114,7 +248,7 @@ extension _BaseScheduledCallbackTests {
114248
handler.assert(callbackCount: 0, cancelCount: 0)
115249
}
116250

117-
func testSheduledCallbackExecutedAtDeadline() async throws {
251+
func testScheduledCallbackExecutedAtDeadline() async throws {
118252
let handler = MockScheduledCallbackHandler()
119253

120254
_ = try self.loop.scheduleCallback(in: .milliseconds(1), handler: handler)
@@ -123,7 +257,7 @@ extension _BaseScheduledCallbackTests {
123257
handler.assert(callbackCount: 1, cancelCount: 0)
124258
}
125259

126-
func testMultipleSheduledCallbacksUsingSameHandler() async throws {
260+
func testMultipleScheduledCallbacksUsingSameHandler() async throws {
127261
let handler = MockScheduledCallbackHandler()
128262

129263
_ = try self.loop.scheduleCallback(in: .milliseconds(1), handler: handler)
@@ -143,7 +277,7 @@ extension _BaseScheduledCallbackTests {
143277
handler.assert(callbackCount: 4, cancelCount: 0)
144278
}
145279

146-
func testMultipleSheduledCallbacksUsingDifferentHandlers() async throws {
280+
func testMultipleScheduledCallbacksUsingDifferentHandlers() async throws {
147281
let handlerA = MockScheduledCallbackHandler()
148282
let handlerB = MockScheduledCallbackHandler()
149283

@@ -278,6 +412,36 @@ private final class MockScheduledCallbackHandler: NIOScheduledCallbackHandler, S
278412
}
279413
}
280414

415+
private final class NonSendableMockScheduledCallbackHandler: NIOScheduledCallbackHandler {
416+
private(set) var callbackCount = 0
417+
private(set) var cancelCount = 0
418+
419+
func handleScheduledCallback(eventLoop: some EventLoop) {
420+
self.callbackCount += 1
421+
}
422+
423+
func didCancelScheduledCallback(eventLoop: some EventLoop) {
424+
self.cancelCount += 1
425+
}
426+
427+
func assert(callbackCount: Int, cancelCount: Int, file: StaticString = #file, line: UInt = #line) {
428+
XCTAssertEqual(
429+
self.callbackCount,
430+
callbackCount,
431+
"Unexpected callback count",
432+
file: file,
433+
line: line
434+
)
435+
XCTAssertEqual(
436+
self.cancelCount,
437+
cancelCount,
438+
"Unexpected cancel count",
439+
file: file,
440+
line: line
441+
)
442+
}
443+
}
444+
281445
/// This function exists because there's no nice way of waiting in tests for something to happen in the handler
282446
/// without an arbitrary sleep.
283447
///

0 commit comments

Comments
 (0)