Pitch: clock.sleep(for:)

Update: I have created a formal proposal, which can be seen here:


Hi all,

there is currently a slight imbalance between the sleep APIs for clocks and tasks, and it causes some troubles when dealing with Clock existentials.

Currently there are two ways to sleep with the Task type. An instant-based way and a duration-based way:

// Instant-based
try await Task.sleep(until: .now.advanced(by: .seconds(1)), clock: .continuous)

// Duration-based
try await Task.sleep(for: .seconds(1))

The duration-based style has its advantages. It's fewer characters and easier to make use of since you only have to think of a single relative unit rather than transforming an absolute unit.

Unfortunately, Clock does not have this convenience:

let clock = ContinuousClock()

// Instant-based
try await clock.sleep(until: .now.advanced(by: .seconds(1))

// Duration-based
try await clock.sleep(for: .seconds(1)) // šŸ›‘ API does not exist

You are forced to use the longer, instant-based sleep(until:) method.

It may not seem like a big deal, after all you can always just use the longer API. However, that is not true if you are dealing with Clock existentials. Because any Clock<Duration> erases the Instant associated type, any API dealing with instants is going to be inaccessible to an existential:

let clock: any Clock<Duration> = ContinuousClock()

// Instant-based
try await clock.sleep(until: .now.advanced(by: .seconds(1)) // šŸ›‘

This cannot compile because the compiler doesn't know what to do with the instant associated type.

If we had a sleep(for:) method that only dealt with durations, and not instants, then we could invoke that API on an existential:

let clock: any Clock<Duration> = ContinuousClock()

// Duration-based
try await clock.sleep(for: .seconds(1)) // āœ… if the API existed

And a big reason why you want to be able to invoke sleep on a clock existential is for controlling the clock so that you are not at the whims of time passing in order to see how your features behave.

For example, suppose you had a view that showed a welcome message after 5 seconds:

struct ContentView: View {
  @State var isWelcomeVisible = false

  var body: some View {
    VStack {
      if self.isWelcomeVisible { Text("Welcome!") }
      ā€¦
    }
    .task {
      do {
        try await Task.sleep(until: .now.advanced(by: .seconds(5)))
        self.isWelcomeVisible = true
      } catch {}
    }
  }
}

In order to see what this welcome message looks like you will need to literally wait for 5 seconds to pass. This makes it difficult to iterate on the design with a SwiftUI preview.

Alternatively, you can inject an any Clock into the view so that you can use a continuous/suspending clock when running on a device, but for SwiftUI previews you can use an "immediate" clock that does not do any suspending. However, that is not possible since sleep(until:) does not work with existentials:

struct ContentView: View {
  @State var isWelcomeVisible = false
  let clock: any Clock<Duration>

  var body: some View {
    VStack {
      if self.isWelcomeVisible { Text("Welcome!") }
      ā€¦
    }
    .task {
      do {
        try await self.clock.sleep(until: .now.advanced(by: .seconds(5))) šŸ›‘
        self.isWelcomeVisible = true
      } catch {}
    }
  }
}

This is also a problem with writing unit tests that involve time-based asynchrony. If you extracted the logic from the above view into an observable object, then you wouldn't be able to test it without forcing your test suite to wait for 5 seconds to pass. But, if you could inject an any Clock<Duration> into the observable object, then you could substitute an immediate clock for tests, making the test suite run instantly.

I have a branch where this simple method has been added to Clock:

Is this worthwhile to add to the standard library?

26 Likes

For testing isn't it perhaps more testable to use a step-wise duration? That way things are discreetly testable?

Can you explain more what you mean by "step-wise duration"?

Also, testing is a big reason to want this, but it's not the only reason. Consider the FeatureModel observable object I sketched above. If you want to see that feature in a SwiftUI preview you would literally have to wait for 5 seconds to elapse before you see the welcome message. That means you can't easily iterate on the design of that message.

However, if you could inject an any Clock<Duration> (and if sleep(for:) were available), then you could substitute an "immediate" clock that doesn't do any actual suspending, causing the welcome message to appear immediately.

Sure; so stepwise durations are durations between integral instants. So if you have a Step(0) as an instant and a Step(5) as another - the duration between the two would be discretely a set number of steps (perhaps expressible by an integer). The advantage of which is that you can run the clock step wise and advance time in discrete chunks. This can be done with Swift.Duration but lots of care need to be done with making values that are controlled carefully. Using integral steps that are not time based but step based helps the design of testing to suss out race conditions and other such failures.

It is not a "must be this way" but instead something that I've encountered that is really helpful. Both approaches rely on that concept of an immediate clock or a controlled incrementing clock.

On the other side of things with that type of testing; we need to make sure folks are careful using that because it fetches the .now from the clock - which can result in time of check races. Again that isn't a feature killer but something that should be documented clearly so folks are not surprised.

Overall I agree that having this is useful, but id say more so that it is interesting for general ergonomics in the cases where deadlines are not fully needed than say the existential form. (Perhaps that is my bias against the performance impacts of existentials over concrete generic values)

1 Like

I believe the AsyncSequenceValidation implements a step wise Clock, right Philippe?

@mbrandonw have you seen it already maybe? Maybe you are already using it in TCA Tests?

Correct that is full stepwise clocks where each symbol in the diagrams end up being 1 discrete step.

Ah yes, I see. This is what you do to test async operators in async-algorithms.

While it is very interesting, I'm not sure I see it being used at the application level. It seems most useful for testing abstract operators, such as debounce, throttle, etc.

The reason I believe this is because it forces the clock to have a duration that is not Swift.Duration, which is what ContinuousClock and SuspendingClock use, and those are the clocks you want while running on a device/simulator. This will force your feature's domain to be generic so that you can handle clocks with different duration types:

class FeatureModel<Duration: DurationProtocol>: ObservableObject {
  let clock: any Clock<Duration>
  ā€¦
}

And those generics start to infect everything. This is the main reason to turn to the any Clock<Duration> existential in the first place, in order to not have to introduce a generic to the feature's domain. If you're willing to live with a generic, then you might as well make the domain generic over the clock.

If I'm missing something, perhaps you can show how a step-based clock would help with the FeatureModel example I presented above.

I believe that is already an issue with the API today. Nearly every (if not every) usage of sleep(until:) I've seen does .now.advanced in order to compute the deadline.

Is performance a problem in this use case? We are already talking about sleeping a task for a duration, and so any cost of an existential certainly pales in comparison to that duration.

2 Likes

This unfortunately is not helpful in application code because its Duration associated type is custom to the clock. This means the clock cannot be type erased and then swapped out for a ContinuousClock or SuspendingClock. It works great for testing operators in the abstract because there is no need to use a "live" clock in one use case and a "test" clock in another use case.

Probably not; it is an unfair application of a legitimately founded (from other areas) bias. You are correct that the scheduling of the sleep probably vastly outweighs the impact of the existential.

I have updated the pitch to explain that clock.sleep(for:) has uses outside of tests. It is also useful any time you want to use time-based asynchrony that you want to control in certain situations, such as SwiftUI previews, running your feature in an isolated app, and more.

1 Like

Unless Iā€™m misinterpreting the situation, does this change really need a pitch/proposal? Contributing to Swift sounds incredibly daunting, draining and intimidating if one has to spend so much time and effort for so small a change that fixes such an obvious inconsistency in the API. Shirley the PR (and some tests) is enough?

The discrepancy was originally brought up in this issue, wherein it was said that evolution was required: Should Clock.sleep(for:) be added? Ā· Issue #59914 Ā· apple/swift Ā· GitHub

New public API pretty much always requires an evolution proposal, unless deemed otherwise by the relevant authorities. However, if this could be considered a bug I think it would mean it could be released much more quickly. Otherwise it would likely not be until Swift 5.8 that this could be released.

Yes, it will need to go through an Evolution process, but the language workgroup will walk you through the steps :slight_smile:

1 Like

Thanks for the replies, y'all. I appreciate the correction of my misunderstanding.I do not mean to derail the conversation further.

I support the pitch and would like to see a first party implementation rather than relying on a third party one. My coworkers and I are using Swift Concurrency more and more and we (as typically expected of app developers in the industry) tend to write tests and previews for our features. So we expect them to be fast as not to break the rhythm of our productivity. The tests are run often on CI systems that charge by the minute, so literally waiting that time is expensive on multiple levels. And making an entire codebase generic over the clock is not feasible. So I'm sure this gap in the API will come to bite us soon.

Something good that has come out of this conversation which I personally would like to see explored elsewhere is a declarative way of verifying state changes over time. As a coarse, poorly thought through example:

// Given
let testClock = TestClock(...)
let initialState = State(...)
let feature = ModelObject(state: initialState, clock: testClock)
testClock.observeStateHandler = { () -> State in 
   feature.state
}

// When
feature.startChangingStateOverTime()

// Then
XCTAssertEquals(testClock.state(at: .seconds(1)), State(...))
XCTAssertEquals(testClock.state(at: .minute(1), .seconds(30)), State(...))
XCTAssertEquals(testClock.state(at: .minute(2)), State(...))
1 Like

Regarding stepwise clocks, the most reductive kind of stepwise clock is simply a clock whose step is Duration. This is a common kind of clock used when testing networked applications as it allows to you test behaviours that may not naturally occur in your test scenario or that may require unjustifiably long test times (including task reordering or timeout).

SwiftNIO has an example of this in our AsyncTestingEventLoop, though we don't spell this on the Clock protocol (as these types predate the existence of Clock).

This would remain a useful construction in your case. In particular, in testing scenarios it allows you to artificially complete these sleeps and, if you're careful, you can even choose to reorder Task wakes.

Hi @lukasa, thanks for sharing!

I didn't mean to give the impression that I think controllable, test clocks are not useful. I definitely think they are. In fact, test clocks are exactly why I think clock.sleep(for:) is necessary for us to have since without it you cannot substitute a test clock in your feature code. And in the past, @stephencelis and I have opened sourced a TestScheduler to accomplish exactly what you have shown in your AsyncTestingEventLoop, but for Combine.

The kind of "stepwise" clock I think is less useful for application code is the one found in the async-algorithms package since it defines its own duration that is an integer that represents a discrete step of a process, not a unit of time. That allows you to write interesting tests for async sequences in a diagrammatic style, but I think that style is only handy for testing async sequences in the abstract. It works because you explicitly describe the final diagram you expect to be generated from a sequence.

And because it has its own duration, you can't inject that kind of clock into feature code that needs to use a ContinuousClock in production. At least not without making your entire feature generic over the type of clock used on the inside, which is strange.

2 Likes

Yeah, a kind of TestClock similar to the TestScheduler we open sourced for Combine would be very handy. I imagine its usage would look more like:

let clock = TestClock()
let model = FeatureModel(clock: clock)

XCTAssertEqual(model.message, nil)
await model.onAppear()
await clock.advanced(by: .seconds(5))
XCTAssertEqual(model.message, "Welcome!")

That would allow you to test that the welcome message shows without having to force you test suite to literally wait for 5 seconds to pass.

2 Likes

I have created an official proposal on the swift-evolution repo, and updated the top post to reflect that. Please check it out here:

10 Likes

To circle back on this: I think it definitely is worth pursuing. The proposed default implementation does not seem like it really needs to be a customization point and the default behavior is on point and consistent. This is definitely an ergonomic/consistency improvement and we should probably take it from the pitch phase and actually float the proposal for proper review.

10 Likes