Skip to content

Latest commit

 

History

History
200 lines (161 loc) · 7.17 KB

0374-clock-sleep-for.md

File metadata and controls

200 lines (161 loc) · 7.17 KB

Add sleep(for:) to Clock

Introduction

The Clock protocol introduced in Swift 5.7 provides a way to suspend until a future instant, but does not provide a way to sleep for a duration. This differs from the static sleep methods on Task, which provide both a way to sleep until an instant or for a duration.

This imbalance in APIs might be reason enough to add a sleep(for:) method to all clocks, but the real problem occurs when dealing with Clock existentials. Because the Instant associated type is fully erased, and only the Duration is preserved via the primary associated type, any API that deals with instants is inaccessible to an existential. This means one cannot invoke sleep(until:) on an existential clock, and hence you can't really do anything with an existential clock.

Motivation

Existentials provide a convenient way to inject dependencies into features so that you can use one kind of dependency in production, and another kind in tests. The most prototypical version of this is API clients. When you run your feature in production you want the API client to make real life network requests, but when run in tests you may want it to just return some mock data.

Due to the current design of Clock, it is not possible to inject a clock existential into a feature so that you can use a ContinuousClock in production, but some other kind of controllable clock in tests.

For example, suppose you have an observable object for the logic of some feature that wants to show a welcoming message after waiting 5 seconds. That might look like this:

class FeatureModel: ObservableObject {
  @Published var message: String?
  func onAppear() async {
    do {
      try await Task.sleep(until: .now.advanced(by: .seconds(5)))
      self.message = "Welcome!"
    } catch {}
  }
}

If you wrote a test for this, your test suite would have no choice but to wait for 5 real life seconds to pass before it could make an assertion:

let model = FeatureModel()

XCTAssertEqual(model.message, nil)
await model.onAppear() // Waits for 5 seconds
XCTAssertEqual(model.message, "Welcome!")

This affects people who don't even write tests. If you put your feature into an Xcode preview, then you would have to wait for 5 full seconds to pass before you get to see the welcome message. That means you can't quickly iterate on the styling of that message.

The solution to these problems is to not reach out to the global, uncontrollable Task.sleep, and instead inject a clock into the feature. And that is typically done using an existential, but unfortunately that does not work:

class FeatureModel: ObservableObject {
  @Published var message: String?
  let clock: any Clock<Duration>

  func onAppear() async {
    do {
      try await self.clock.sleep(until: self.clock.now.advanced(by: .seconds(5))) // 🛑
      self.message = "Welcome!"
    } catch {}
  }
}

One cannot invoke sleep(until:) on a clock existential because the Instant has been fully erased, and so there is no way to access .now and advance it.

For similar reasons, one cannot invoke Task.sleep(until:clock:) with a clock existential:

try await Task.sleep(until: self.clock.now.advanced(by: .seconds(5)), clock: self.clock) // 🛑

What we need instead is the sleep(for:) method on clocks that allow you to sleep for a duration rather than sleeping until an instant:

class FeatureModel: ObservableObject {
  @Published var message: String?
  let clock: any Clock<Duration>

  func onAppear() async {
    do {
      try await self.clock.sleep(for: .seconds(5)) // ✅
      self.message = "Welcome!"
    } catch {}
  }
}

Without a sleep(for:) method on clocks, one cannot use a clock existential in the feature, and that forces you to introduce a generic:

class FeatureModel<C: Clock<Duration>>: ObservableObject {
  @Published var message: String?
  let clock: C

  func onAppear() async {
    do {
      try await self.clock.sleep(until: self.clock.now.advanced(by: .seconds(5)))
      self.message = "Welcome!"
    } catch {}
  }
}

But this is problematic. This will force any code that touches FeatureModel to also introduce a generic if you want that code to be testable and controllable. And it's strange that the class is statically announcing its dependence on a clock when its mostly just an internal detail of the class.

By adding a sleep(for:) method to Clock we can fix all of these problems, and give Swift users the ability to control time-based asynchrony in their applications.

Proposed solution

A single extension method will be added to the Clock protocol:

extension Clock {
  /// Suspends for the given duration.
  ///
  /// Prefer to use the `sleep(until:tolerance:)` method on `Clock` if you have access to an 
  /// absolute instant.
  public func sleep(
    for duration: Duration,
    tolerance: Duration? = nil
  ) async throws {
    try await self.sleep(until: self.now.advanced(by: duration), tolerance: tolerance)
  }
}

This will allow one to sleep for a duration with a clock rather than sleeping until an instant.

Further, to make the APIs between clock.sleep(for:) and Task.sleep(for:) similar, we will also add a clock and tolerance argument to Task.sleep(for:):

extension Task where Success == Never, Failure == Never {
  /// Suspends the current task for the given duration on a continuous clock.
  ///
  /// If the task is cancelled before the time ends, this function throws 
  /// `CancellationError`.
  ///
  /// This function doesn't block the underlying thread.
  ///
  ///       try await Task.sleep(for: .seconds(3))
  ///
  /// - Parameter duration: The duration to wait.
  public static func sleep<C: Clock>(
    for duration: C.Duration,
    tolerance: C.Duration? = nil,
    clock: C = ContinuousClock()
  ) async throws {
    try await sleep(until: clock.now.advanced(by: duration), tolerance: tolerance, clock: clock)
  }
}

And we will add a default value for the clock argument of Task.sleep(until:):

extension Task where Success == Never, Failure == Never {
  public static func sleep<C: Clock>(
    until deadline: C.Instant,
    tolerance: C.Instant.Duration? = nil,
    clock: C = ContinuousClock()
  ) async throws {
    try await clock.sleep(until: deadline, tolerance: tolerance)
  }
}

Source compatibility, effect on ABI stability, effect on API resilience

As this is an additive change, it should not have any compatibility, stability or resilience problems.

Alternatives considered

We could leave things as is, and not add this method to the standard library, as it is possible for people to define it themselves.