diff --git a/Sources/GRPCCore/Call/Server/Internal/ServerRPCExecutor.swift b/Sources/GRPCCore/Call/Server/Internal/ServerRPCExecutor.swift index 54b775195..9bf0e75a5 100644 --- a/Sources/GRPCCore/Call/Server/Internal/ServerRPCExecutor.swift +++ b/Sources/GRPCCore/Call/Server/Internal/ServerRPCExecutor.swift @@ -76,7 +76,7 @@ struct ServerRPCExecutor { if let timeout = metadata.timeout { group.addTask { let result = await Result { - try await Task.sleep(until: .now.advanced(by: timeout), clock: .continuous) + try await Task.sleep(for: timeout, clock: .continuous) } return .timedOut(result) } diff --git a/Sources/GRPCCore/Internal/Metadata+GRPC.swift b/Sources/GRPCCore/Internal/Metadata+GRPC.swift index f28bfc966..9bff423e3 100644 --- a/Sources/GRPCCore/Internal/Metadata+GRPC.swift +++ b/Sources/GRPCCore/Internal/Metadata+GRPC.swift @@ -39,14 +39,16 @@ extension Metadata { @inlinable var timeout: Duration? { - // Temporary hack to support tests; only supports nanoseconds. - guard let value = self.firstString(forKey: .timeout) else { return nil } - guard value.utf8.last == UTF8.CodeUnit(ascii: "n") else { return nil } - var index = value.utf8.endIndex - value.utf8.formIndex(before: &index) - guard let digits = String(value.utf8[.. Timeout.maxAmount { + switch roundedUnit { + case .nanoseconds: + roundedAmount = roundedAmount.quotientRoundedUp(dividingBy: 1000) + roundedUnit = .microseconds + case .microseconds: + roundedAmount = roundedAmount.quotientRoundedUp(dividingBy: 1000) + roundedUnit = .milliseconds + case .milliseconds: + roundedAmount = roundedAmount.quotientRoundedUp(dividingBy: 1000) + roundedUnit = .seconds + case .seconds: + roundedAmount = roundedAmount.quotientRoundedUp(dividingBy: 60) + roundedUnit = .minutes + case .minutes: + roundedAmount = roundedAmount.quotientRoundedUp(dividingBy: 60) + roundedUnit = .hours + case .hours: + roundedAmount = Timeout.maxAmount + roundedUnit = .hours + } + } + } + + self.init(amount: roundedAmount, unit: roundedUnit) + } + + private static func exceedsDigitLimit(_ value: Int64) -> Bool { + value > Timeout.maxAmount + } + + /// Creates a `GRPCTimeout`. + /// + /// - Precondition: The amount should be greater than or equal to zero and less than or equal + /// to `GRPCTimeout.maxAmount`. + internal init(amount: Int64, unit: Unit) { + precondition((0 ... Timeout.maxAmount).contains(amount)) + + self.amount = amount + self.unit = unit + } +} + +extension Int64 { + /// Returns the quotient of this value when divided by `divisor` rounded up to the nearest + /// multiple of `divisor` if the remainder is non-zero. + /// + /// - Parameter divisor: The value to divide this value by. + fileprivate func quotientRoundedUp(dividingBy divisor: Int64) -> Int64 { + let (quotient, remainder) = self.quotientAndRemainder(dividingBy: divisor) + return quotient + (remainder != 0 ? 1 : 0) + } +} + +@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) +extension Duration { + /// Construct a `Duration` given a number of minutes represented as an `Int64`. + /// + /// let d: Duration = .minutes(5) + /// + /// - Returns: A `Duration` representing a given number of minutes. + internal static func minutes(_ minutes: Int64) -> Duration { + return Self.init(secondsComponent: 60 * minutes, attosecondsComponent: 0) + } + + /// Construct a `Duration` given a number of hours represented as an `Int64`. + /// + /// let d: Duration = .hours(3) + /// + /// - Returns: A `Duration` representing a given number of hours. + internal static func hours(_ hours: Int64) -> Duration { + return Self.init(secondsComponent: 60 * 60 * hours, attosecondsComponent: 0) + } + + internal init(amount: Int64, unit: Timeout.Unit) { + switch unit { + case .hours: + self = Self.hours(amount) + case .minutes: + self = Self.minutes(amount) + case .seconds: + self = Self.seconds(amount) + case .milliseconds: + self = Self.milliseconds(amount) + case .microseconds: + self = Self.microseconds(amount) + case .nanoseconds: + self = Self.nanoseconds(amount) + } + } +} diff --git a/Tests/GRPCCoreTests/TimeoutTests.swift b/Tests/GRPCCoreTests/TimeoutTests.swift new file mode 100644 index 000000000..ddb664bf2 --- /dev/null +++ b/Tests/GRPCCoreTests/TimeoutTests.swift @@ -0,0 +1,204 @@ +/* + * Copyright 2023, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import XCTest + +@testable import GRPCCore + +final class TimeoutTests: XCTestCase { + func testDecodeInvalidTimeout_Empty() { + let timeoutHeader = "" + XCTAssertNil(Timeout(decoding: timeoutHeader)) + } + + func testDecodeInvalidTimeout_NoAmount() { + let timeoutHeader = "H" + XCTAssertNil(Timeout(decoding: timeoutHeader)) + } + + func testDecodeInvalidTimeout_NoUnit() { + let timeoutHeader = "123" + XCTAssertNil(Timeout(decoding: timeoutHeader)) + } + + func testDecodeInvalidTimeout_TooLongAmount() { + let timeoutHeader = "100000000S" + XCTAssertNil(Timeout(decoding: timeoutHeader)) + } + + func testDecodeInvalidTimeout_InvalidUnit() { + let timeoutHeader = "123j" + XCTAssertNil(Timeout(decoding: timeoutHeader)) + } + + func testDecodeValidTimeout_Hours() throws { + let timeoutHeader = "123H" + let timeout = Timeout(decoding: timeoutHeader) + let unwrappedTimeout = try XCTUnwrap(timeout) + XCTAssertEqual(unwrappedTimeout.duration, Duration.hours(123)) + XCTAssertEqual(unwrappedTimeout.wireEncoding, timeoutHeader) + } + + func testDecodeValidTimeout_Minutes() throws { + let timeoutHeader = "123M" + let timeout = Timeout(decoding: timeoutHeader) + let unwrappedTimeout = try XCTUnwrap(timeout) + XCTAssertEqual(unwrappedTimeout.duration, Duration.minutes(123)) + XCTAssertEqual(unwrappedTimeout.wireEncoding, timeoutHeader) + } + + func testDecodeValidTimeout_Seconds() throws { + let timeoutHeader = "123S" + let timeout = Timeout(decoding: timeoutHeader) + let unwrappedTimeout = try XCTUnwrap(timeout) + XCTAssertEqual(unwrappedTimeout.duration, Duration.seconds(123)) + XCTAssertEqual(unwrappedTimeout.wireEncoding, timeoutHeader) + } + + func testDecodeValidTimeout_Milliseconds() throws { + let timeoutHeader = "123m" + let timeout = Timeout(decoding: timeoutHeader) + let unwrappedTimeout = try XCTUnwrap(timeout) + XCTAssertEqual(unwrappedTimeout.duration, Duration.milliseconds(123)) + XCTAssertEqual(unwrappedTimeout.wireEncoding, timeoutHeader) + } + + func testDecodeValidTimeout_Microseconds() throws { + let timeoutHeader = "123u" + let timeout = Timeout(decoding: timeoutHeader) + let unwrappedTimeout = try XCTUnwrap(timeout) + XCTAssertEqual(unwrappedTimeout.duration, Duration.microseconds(123)) + XCTAssertEqual(unwrappedTimeout.wireEncoding, timeoutHeader) + } + + func testDecodeValidTimeout_Nanoseconds() throws { + let timeoutHeader = "123n" + let timeout = Timeout(decoding: timeoutHeader) + let unwrappedTimeout = try XCTUnwrap(timeout) + XCTAssertEqual(unwrappedTimeout.duration, Duration.nanoseconds(123)) + XCTAssertEqual(unwrappedTimeout.wireEncoding, timeoutHeader) + } + + func testEncodeValidTimeout_Hours() { + let duration = Duration.hours(123) + let timeout = Timeout(duration: duration) + XCTAssertEqual(timeout.duration.components.seconds, duration.components.seconds) + XCTAssertEqual(timeout.duration.components.attoseconds, duration.components.attoseconds) + } + + func testEncodeValidTimeout_Minutes() { + let duration = Duration.minutes(43) + let timeout = Timeout(duration: duration) + XCTAssertEqual(timeout.duration.components.seconds, duration.components.seconds) + XCTAssertEqual(timeout.duration.components.attoseconds, duration.components.attoseconds) + } + + func testEncodeValidTimeout_Seconds() { + let duration = Duration.seconds(12345) + let timeout = Timeout(duration: duration) + XCTAssertEqual(timeout.duration.components.seconds, duration.components.seconds) + XCTAssertEqual(timeout.duration.components.attoseconds, duration.components.attoseconds) + } + + func testEncodeValidTimeout_Seconds_TooLong_Minutes() { + let duration = Duration.seconds(111_111_111) + let timeout = Timeout(duration: duration) + // The conversion from seconds to minutes results in a loss of precision. + // 111,111,111 seconds / 60 = 1,851,851.85 minutes -rounding up-> 1,851,852 minutes * 60 = 111,111,120 seconds + let expectedRoundedDuration = Duration.minutes(1_851_852) + XCTAssertEqual(timeout.duration.components.seconds, expectedRoundedDuration.components.seconds) + XCTAssertEqual( + timeout.duration.components.attoseconds, + expectedRoundedDuration.components.attoseconds + ) + } + + func testEncodeValidTimeout_Seconds_TooLong_Hours() { + let duration = Duration.seconds(9_999_999_999) + let timeout = Timeout(duration: duration) + // The conversion from seconds to hours results in a loss of precision. + // 9,999,999,999 seconds / 60 = 166,666,666.65 minutes -rounding up-> + // 166,666,667 minutes / 60 = 2,777,777.78 hours -rounding up-> + // 2,777,778 hours * 60 -> 166,666,680 minutes * 60 = 10,000,000,800 seconds + let expectedRoundedDuration = Duration.hours(2_777_778) + XCTAssertEqual(timeout.duration.components.seconds, expectedRoundedDuration.components.seconds) + XCTAssertEqual( + timeout.duration.components.attoseconds, + expectedRoundedDuration.components.attoseconds + ) + } + + func testEncodeValidTimeout_Seconds_TooLong_MaxAmount() { + let duration = Duration.seconds(999_999_999_999) + let timeout = Timeout(duration: duration) + // The conversion from seconds to hours results in a number that still has + // more than the maximum allowed 8 digits, so we must clamp it. + // Make sure that `Timeout.maxAmount` is the amount used for the resulting timeout. + let expectedRoundedDuration = Duration.hours(Timeout.maxAmount) + XCTAssertEqual(timeout.duration.components.seconds, expectedRoundedDuration.components.seconds) + XCTAssertEqual( + timeout.duration.components.attoseconds, + expectedRoundedDuration.components.attoseconds + ) + } + + func testEncodeValidTimeout_SecondsAndMilliseconds() { + let duration = Duration(secondsComponent: 100, attosecondsComponent: Int64(1e+17)) + let timeout = Timeout(duration: duration) + XCTAssertEqual(timeout.duration.components.seconds, duration.components.seconds) + XCTAssertEqual(timeout.duration.components.attoseconds, duration.components.attoseconds) + } + + func testEncodeValidTimeout_SecondsAndMicroseconds() { + let duration = Duration(secondsComponent: 1, attosecondsComponent: Int64(1e+14)) + let timeout = Timeout(duration: duration) + XCTAssertEqual(timeout.duration.components.seconds, duration.components.seconds) + XCTAssertEqual(timeout.duration.components.attoseconds, duration.components.attoseconds) + } + + func testEncodeValidTimeout_SecondsAndNanoseconds() { + let duration = Duration(secondsComponent: 1, attosecondsComponent: Int64(1e+11)) + let timeout = Timeout(duration: duration) + // We can't convert seconds to nanoseconds because that would require at least + // 9 digits, and the maximum allowed is 8: we expect to simply drop the nanoseconds. + let expectedRoundedDuration = Duration.seconds(1) + XCTAssertEqual(timeout.duration.components.seconds, expectedRoundedDuration.components.seconds) + XCTAssertEqual( + timeout.duration.components.attoseconds, + expectedRoundedDuration.components.attoseconds + ) + } + + func testEncodeValidTimeout_Milliseconds() { + let duration = Duration.milliseconds(100) + let timeout = Timeout(duration: duration) + XCTAssertEqual(timeout.duration.components.seconds, duration.components.seconds) + XCTAssertEqual(timeout.duration.components.attoseconds, duration.components.attoseconds) + } + + func testEncodeValidTimeout_Microseconds() { + let duration = Duration.microseconds(100) + let timeout = Timeout(duration: duration) + XCTAssertEqual(timeout.duration.components.seconds, duration.components.seconds) + XCTAssertEqual(timeout.duration.components.attoseconds, duration.components.attoseconds) + } + + func testEncodeValidTimeout_Nanoseconds() { + let duration = Duration.nanoseconds(100) + let timeout = Timeout(duration: duration) + XCTAssertEqual(timeout.duration.components.seconds, duration.components.seconds) + XCTAssertEqual(timeout.duration.components.attoseconds, duration.components.attoseconds) + } +}