From a806ba97a9c06f8dd10129992dd9f154e32d520f Mon Sep 17 00:00:00 2001 From: Anthony Drendel Date: Sun, 3 May 2026 21:46:28 +0200 Subject: [PATCH 1/4] Add Publishers.AgainAt.Timer.time(at:) --- Sources/CombineExtensions/AgainAt.swift | 22 +++++ .../CombineExtensionsTests/AgainAtTests.swift | 85 +++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/Sources/CombineExtensions/AgainAt.swift b/Sources/CombineExtensions/AgainAt.swift index 8e56c74..c8249ee 100644 --- a/Sources/CombineExtensions/AgainAt.swift +++ b/Sources/CombineExtensions/AgainAt.swift @@ -50,19 +50,27 @@ public extension Publishers.AgainAt { final class Timer: @unchecked Sendable { public let now: Context.SchedulerTimeType + private let scheduler: Context private let onRepublishAt: @Sendable (Context.SchedulerTimeType) -> Void fileprivate init( now: Context.SchedulerTimeType, + scheduler: Context, onRepublishAt: @escaping @Sendable (Context.SchedulerTimeType) -> Void ) { self.now = now + self.scheduler = scheduler self.onRepublishAt = onRepublishAt } public func republish(at time: Context.SchedulerTimeType) { onRepublishAt(time) } + + public func time(at date: Date) -> Context.SchedulerTimeType { + let nanoseconds = date.timeIntervalSinceNow.nanoseconds + return scheduler.now.advanced(by: .nanoseconds(nanoseconds)) + } } } @@ -300,6 +308,7 @@ private final class AgainAtSubscription: case let .value(downstream, output): let timer = Publishers.AgainAt.Timer( now: scheduler.now, + scheduler: scheduler, onRepublishAt: { [weak self] time in self?.republish(at: time) } @@ -342,3 +351,16 @@ private final class AgainAtSubscription: } } } + +private extension TimeInterval { + var nanoseconds: Int { + guard self > 0 else { return 0 } + let nanoseconds = self * 1_000_000_000 + guard nanoseconds < Double(maxNanoseconds) else { + return maxNanoseconds + } + return Int(nanoseconds) + } +} + +private let maxNanoseconds = Int.max - 1024 diff --git a/Tests/CombineExtensionsTests/AgainAtTests.swift b/Tests/CombineExtensionsTests/AgainAtTests.swift index 71f0f89..6ea7326 100644 --- a/Tests/CombineExtensionsTests/AgainAtTests.swift +++ b/Tests/CombineExtensionsTests/AgainAtTests.swift @@ -115,6 +115,90 @@ final class AgainAtTests: XCTestCase { XCTAssertEqual(values, [1, 1]) } + func testRepublishAtFutureDatePublishesAfterConvertedTime() { + let scheduler = DispatchQueue.test + let subject = PassthroughSubject() + var values = [Int]() + var republishers = [DateRepublisher]() + + let subscription = subject + .againAt(scheduler: scheduler) + .sink( + receiveCompletion: { _ in }, + receiveValue: { value, timer in + values.append(value) + republishers.append { timer.republish(at: timer.time(at: $0)) } + } + ) + defer { subscription.cancel() } + + subject.send(1) + scheduler.advance() + + republishers[0](Date(timeIntervalSinceNow: 1)) + + scheduler.advance(by: .milliseconds(500)) + XCTAssertEqual(values, [1]) + + scheduler.advance(by: .seconds(1)) + XCTAssertEqual(values, [1, 1]) + } + + func testRepublishAtPastDateUsesCurrentSchedulerTime() { + let scheduler = DispatchQueue.test + let subject = PassthroughSubject() + var values = [Int]() + var republishers = [DateRepublisher]() + var timerNows = [DispatchQueue.SchedulerTimeType]() + + let subscription = subject + .againAt(scheduler: scheduler) + .sink( + receiveCompletion: { _ in }, + receiveValue: { value, timer in + values.append(value) + timerNows.append(timer.now) + republishers.append { timer.republish(at: timer.time(at: $0)) } + } + ) + defer { subscription.cancel() } + + subject.send(1) + scheduler.advance() + + let firstNow = scheduler.now + scheduler.advance(by: .seconds(5)) + let republishNow = scheduler.now + + republishers[0](.distantPast) + scheduler.advance() + + XCTAssertEqual(values, [1, 1]) + XCTAssertEqual(timerNows, [firstNow, republishNow]) + } + + func testTimeAtDistantFutureDoesNotOverflow() { + let scheduler = DispatchQueue.test + let subject = PassthroughSubject() + var convertedTimes = [DispatchQueue.SchedulerTimeType]() + + let subscription = subject + .againAt(scheduler: scheduler) + .sink( + receiveCompletion: { _ in }, + receiveValue: { _, timer in + convertedTimes.append(timer.time(at: .distantFuture)) + } + ) + defer { subscription.cancel() } + + subject.send(1) + scheduler.advance() + + XCTAssertEqual(convertedTimes.count, 1) + XCTAssertTrue(convertedTimes[0] > scheduler.now) + } + func testRepublishPublishesNewestUpstreamOutputAtFireTime() { let scheduler = DispatchQueue.test let subject = PassthroughSubject() @@ -642,6 +726,7 @@ final class AgainAtTests: XCTestCase { } private typealias Republisher = (DispatchQueue.SchedulerTimeType) -> Void +private typealias DateRepublisher = (Date) -> Void private enum TestError: Error, Equatable { case failed From 1f18e710f8fa491f7ef1a0c126970c026fcc9616 Mon Sep 17 00:00:00 2001 From: Anthony Drendel Date: Sun, 3 May 2026 21:52:33 +0200 Subject: [PATCH 2/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Tests/CombineExtensionsTests/AgainAtTests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/CombineExtensionsTests/AgainAtTests.swift b/Tests/CombineExtensionsTests/AgainAtTests.swift index 6ea7326..33c7327 100644 --- a/Tests/CombineExtensionsTests/AgainAtTests.swift +++ b/Tests/CombineExtensionsTests/AgainAtTests.swift @@ -135,12 +135,12 @@ final class AgainAtTests: XCTestCase { subject.send(1) scheduler.advance() - republishers[0](Date(timeIntervalSinceNow: 1)) + republishers[0](Date(timeIntervalSinceNow: 2)) - scheduler.advance(by: .milliseconds(500)) + scheduler.advance(by: .seconds(1)) XCTAssertEqual(values, [1]) - scheduler.advance(by: .seconds(1)) + scheduler.advance(by: .seconds(2)) XCTAssertEqual(values, [1, 1]) } From 6e636c96e9b09f4c3347ca1a952cbb275b32067a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 3 May 2026 19:53:59 +0000 Subject: [PATCH 3/4] Round up nanoseconds conversion and document maxNanoseconds constant Agent-Logs-Url: https://github.com/shareup/combine-extensions/sessions/55d385ef-6ef1-45a3-9be2-b02ca7035e41 Co-authored-by: atdrendel <202402+atdrendel@users.noreply.github.com> --- Sources/CombineExtensions/AgainAt.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/CombineExtensions/AgainAt.swift b/Sources/CombineExtensions/AgainAt.swift index c8249ee..b183a17 100644 --- a/Sources/CombineExtensions/AgainAt.swift +++ b/Sources/CombineExtensions/AgainAt.swift @@ -355,7 +355,7 @@ private final class AgainAtSubscription: private extension TimeInterval { var nanoseconds: Int { guard self > 0 else { return 0 } - let nanoseconds = self * 1_000_000_000 + let nanoseconds = (self * 1_000_000_000).rounded(.up) guard nanoseconds < Double(maxNanoseconds) else { return maxNanoseconds } @@ -363,4 +363,7 @@ private extension TimeInterval { } } +// Int.max - 1024 provides headroom so that advancing a scheduler time by this +// many nanoseconds cannot overflow when the scheduler adds its own internal +// offset to the value. private let maxNanoseconds = Int.max - 1024 From 97f216fa0f5a0bc041999b824f35690e9d2d8001 Mon Sep 17 00:00:00 2001 From: Anthony Drendel Date: Sun, 3 May 2026 22:01:28 +0200 Subject: [PATCH 4/4] Apply suggestion from @atdrendel --- Sources/CombineExtensions/AgainAt.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Sources/CombineExtensions/AgainAt.swift b/Sources/CombineExtensions/AgainAt.swift index b183a17..ee92a78 100644 --- a/Sources/CombineExtensions/AgainAt.swift +++ b/Sources/CombineExtensions/AgainAt.swift @@ -363,7 +363,4 @@ private extension TimeInterval { } } -// Int.max - 1024 provides headroom so that advancing a scheduler time by this -// many nanoseconds cannot overflow when the scheduler adds its own internal -// offset to the value. private let maxNanoseconds = Int.max - 1024