Skip to content

Comments

Introduce a withDeadline method#396

Open
FranzBusch wants to merge 3 commits intoapple:mainfrom
FranzBusch:fb-timeout
Open

Introduce a withDeadline method#396
FranzBusch wants to merge 3 commits intoapple:mainfrom
FranzBusch:fb-timeout

Conversation

@FranzBusch
Copy link
Member

## Motivation

Asynchronous operations in Swift can run indefinitely, which creates several problems in real-world applications. Currently, developers must implement timeout logic manually using task groups and clock sleep operations, resulting in verbose, error-prone code that's difficult to compose with surrounding async contexts. Each implementation must carefully handle cancellation, error propagation, and race conditions between the operation and timer.

Modifications

This PR introduces a withTimeout method.

Result

An ecosystem wide timeout solution for asynchronous operations.

@FranzBusch FranzBusch force-pushed the fb-timeout branch 2 times, most recently from 84783c1 to c119d0d Compare January 29, 2026 10:37
}
group.addTask {
do {
try await clock.sleep(for: timeout, tolerance: tolerance)
Copy link

@ph1ps ph1ps Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we've talked about this in the other PR already, but I don't remember the outcome: would it make sense to also add the withTimeout overload that takes an Instant rather than the Duration? This timeout comes with the "impreciseness" of adding the little "spinning off child task" delay on top of the timout Duration.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this was raised in the forums thread again. Having a withDeadline method.

nonisolated(nonsending) public func withTimeout<Return, Failure: Error, Clock: _Concurrency.Clock>(
in timeout: Clock.Instant.Duration,
tolerance: Clock.Instant.Duration? = nil,
clock: Clock = .continuous,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the Duration variant of withTimeout though could take any Clock<Duration> rather than some.

Imagine this model:

class Model {

  func doSomething() async throws {
    try await withTimeout(in: .seconds(5)) { ... }
  }
}

If you were to unit test doSomething, you would normally try to inject a custom clock, to not make the unit test wait for 5 seconds.

Either with some sort of dependency injection, or with simply passing it along the initializer you could do this:

class Model {

  let clock: any Clock<Duration>

  func doSomething() async throws {
    try await withTimeout(in: .seconds(5), clock: clock) { ... }
  }
}

At call site:

let model1 = Model(clock: ContinuousClock())
let model2 = Model(clock: TestClock())

Copy link
Member Author

@FranzBusch FranzBusch Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can do this already with the caveat that Duration cannot be used on an arbitrary clock so you have to constrain it. This works with the existing code:

@Test(arguments: [ContinuousClock()])
func dependencyInjection(clock: any Clock<Duration>) async throws {
  try await self.concreteClock(clock: clock)
}

private func concreteClock(clock: some Clock<Duration>) async throws {
  try await withTimeout(
    in: .seconds(10),
    clock: clock
  ) {
    try await Task.sleep(for: .milliseconds(10))
  }
}

## Motivation

Asynchronous operations in Swift can run indefinitely, which creates several problems in real-world applications. Currently, developers must implement timeout logic manually using task groups and clock sleep operations, resulting in verbose, error-prone code that's difficult to compose with surrounding async contexts. Each implementation must carefully handle cancellation, error propagation, and race conditions between the operation and timer.

## Modifications

This PR introduces a `withTimeout` method.

## Result

An ecosystem wide timeout solution for asynchronous operations.
Comment on lines 14 to 26
struct Disconnected<Value>: Sendable {
// This is safe since we take the value as sending and take consumes it
// and returns it as sending.
private nonisolated(unsafe) var value: Value?

init(value: consuming sending Value) {
self.value = .some(value)
}

consuming func take() -> sending Value {
self.value.takeSending()!
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are we sure this is safe?
since both Disconnected and Value are copyable types, I don't see what would prevent copies of this ending up in different isolations.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's right. This type would ideally need to be ~Copyable for this to be entirely safe. Sadly withTaskGroup doesn't support ~Copyable types right now. A way around this would be to box the Disconnected but that incurs an allocation. I wanna pitch the non-copyable Disconnected type in a follow up pitch to this package.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe you could add a few "do not try this at home" comments to make sure this does not spread into use cases where bad thing will happen.

…ration failed on its own or due to a timeout
tolerance: Clock.Instant.Duration? = nil,
clock: Clock,
body: nonisolated(nonsending) () async throws(Failure) -> Return
) async throws(TimeoutError<Failure>) -> Return {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On Swift Swift version 6.2.3 (swift-6.2.3-RELEASE), I get a compilation error:

Cannot convert '@Sendable () async throws(Failure) -> Return' to 'nonisolated(nonsending) @Sendable () async throws(Failure) -> Return' because crossing of an isolation boundary requires parameter and result types to conform to 'Sendable' protocol

Do I miss something?

@FranzBusch FranzBusch changed the title Introduce a withTimeout method Introduce a withDeadline method Jan 31, 2026
case .deadlineExceeded(let error):
return "\(error)"
case .operationFailed(let error):
return "\(error)"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the description indicate which case was the cause?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants