From 2a8811acd6916527143962998612c8e07999a7ec Mon Sep 17 00:00:00 2001 From: Johannes Weiss Date: Thu, 21 Nov 2024 18:22:42 +0000 Subject: [PATCH] EventLoopFuture.waitSpinningRunLoop() (#2985) ### Motivation: In some (probably niche) scenarios, especially in pre-Concurrency UI applications on Darwin, it can be useful to wait for an a value whilst still running the current `RunLoop`. That allows the UI and other things to work whilst we're waiting for a future to complete. ### Modifications: - Add `NIOFoundationCompat.EventLoopFuture.waitSpinningRunLoop()`. ### Result: Better compatibility with Cocoa. --- .../WaitSpinningRunLoop.swift | 72 +++++++++++++++++++ .../WaitSpinningRunLoopTests.swift | 64 +++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 Sources/NIOFoundationCompat/WaitSpinningRunLoop.swift create mode 100644 Tests/NIOFoundationCompatTests/WaitSpinningRunLoopTests.swift diff --git a/Sources/NIOFoundationCompat/WaitSpinningRunLoop.swift b/Sources/NIOFoundationCompat/WaitSpinningRunLoop.swift new file mode 100644 index 0000000000..9b9e16f955 --- /dev/null +++ b/Sources/NIOFoundationCompat/WaitSpinningRunLoop.swift @@ -0,0 +1,72 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 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 Foundation +import NIOConcurrencyHelpers +import NIOCore + +extension EventLoopFuture { + /// Wait for the resolution of this `EventLoopFuture` by spinning `RunLoop.current` in `mode` until the future + /// resolves. The calling thread will be blocked albeit running `RunLoop.current`. + /// + /// If the `EventLoopFuture` resolves with a value, that value is returned from `waitSpinningRunLoop()`. If + /// the `EventLoopFuture` resolves with an error, that error will be thrown instead. + /// `waitSpinningRunLoop()` will block whatever thread it is called on, so it must not be called on event loop + /// threads: it is primarily useful for testing, or for building interfaces between blocking + /// and non-blocking code. + /// + /// This is also forbidden in async contexts: prefer `EventLoopFuture/get()`. + /// + /// - Note: The `Value` must be `Sendable` since it is shared outside of the isolation domain of the event loop. + /// + /// - Returns: The value of the `EventLoopFuture` when it completes. + /// - Throws: The error value of the `EventLoopFuture` if it errors. + @available(*, noasync, message: "waitSpinningRunLoop() can block indefinitely, prefer get()", renamed: "get()") + @inlinable + public func waitSpinningRunLoop( + inMode mode: RunLoop.Mode = .default, + file: StaticString = #file, + line: UInt = #line + ) throws -> Value where Value: Sendable { + try self._blockingWaitForFutureCompletion(mode: mode, file: file, line: line) + } + + @inlinable + @inline(never) + func _blockingWaitForFutureCompletion( + mode: RunLoop.Mode, + file: StaticString, + line: UInt + ) throws -> Value where Value: Sendable { + self.eventLoop._preconditionSafeToWait(file: file, line: line) + + let runLoop = RunLoop.current + + let value: NIOLockedValueBox?> = NIOLockedValueBox(nil) + self.whenComplete { result in + value.withLockedValue { value in + value = result + } + } + + while value.withLockedValue({ $0 }) == nil { + _ = runLoop.run(mode: mode, before: Date().addingTimeInterval(0.01)) + } + + return try value.withLockedValue { value in + try value!.get() + } + } +} diff --git a/Tests/NIOFoundationCompatTests/WaitSpinningRunLoopTests.swift b/Tests/NIOFoundationCompatTests/WaitSpinningRunLoopTests.swift new file mode 100644 index 0000000000..18307c744f --- /dev/null +++ b/Tests/NIOFoundationCompatTests/WaitSpinningRunLoopTests.swift @@ -0,0 +1,64 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 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 NIO +import NIOFoundationCompat +import XCTest + +final class WaitSpinningRunLoopTests: XCTestCase { + private let loop = MultiThreadedEventLoopGroup.singleton.any() + + func testPreFailedWorks() { + struct Dummy: Error {} + let future: EventLoopFuture = self.loop.makeFailedFuture(Dummy()) + XCTAssertThrowsError(try future.waitSpinningRunLoop()) { error in + XCTAssert(error is Dummy) + } + } + + func testPreSucceededWorks() { + let future = self.loop.makeSucceededFuture("hello") + XCTAssertEqual("hello", try future.waitSpinningRunLoop()) + } + + func testFailingAfterALittleWhileWorks() { + struct Dummy: Error {} + let future: EventLoopFuture = self.loop.scheduleTask(in: .milliseconds(10)) { + throw Dummy() + }.futureResult + XCTAssertThrowsError(try future.waitSpinningRunLoop()) { error in + XCTAssert(error is Dummy) + } + } + + func testSucceedingAfterALittleWhileWorks() { + let future = self.loop.scheduleTask(in: .milliseconds(10)) { + "hello" + }.futureResult + XCTAssertEqual("hello", try future.waitSpinningRunLoop()) + } + + func testWeCanStillUseOurRunLoopWhilstBlocking() { + let promise = self.loop.makePromise(of: String.self) + let myRunLoop = RunLoop.current + let timer = Timer(timeInterval: 0.1, repeats: false) { [loop = self.loop] _ in + loop.scheduleTask(in: .microseconds(10)) { + promise.succeed("hello") + } + } + myRunLoop.add(timer, forMode: .default) + XCTAssertEqual("hello", try promise.futureResult.waitSpinningRunLoop()) + } + +}