[Pitch] withDeadline

Previously @FranzBusch pitched a proposal for AsyncAlgorithms for a deadline API. We came to the conclusion that not only was the shape pretty much defined but it is a very useful algorithm that would broadly apply to Swift. To that end, we reworked the proposal to be more suitable for a broad implementation with runtime level interactions.

Introduction

This proposal introduces withDeadline, a function that executes asynchronous
operations with a composable absolute time limit. The function accepts a
continuous clock instant representing the deadline by which the operation must
complete. If the operation completes before the deadline, the function returns
the result; if the deadline expires first, the operation is cancelled.

Motivation

Asynchronous operations in Swift can run indefinitely, which creates several
problems in real-world applications:

  1. Network operations may not complete when servers become unresponsive,
    consuming resources and degrading user experience.
  2. Server-side applications need predictable request handling times to maintain
    service level agreements and prevent resource exhaustion.
  3. Batch processing requires mechanisms to prevent individual tasks from
    blocking entire workflows.
  4. Resource management becomes difficult when operations lack time bounds,
    leading to connection pool exhaustion and memory leaks.
  5. Coordinating multiple operations to complete by a shared deadline requires
    passing absolute instants, not relative durations that drift through the call
    stack.

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.

Proposed solution

This proposal introduces withDeadline, a function that executes an
asynchronous operation with an absolute time limit specified as a clock instant.
The solution provides a clean, composable API that handles cancellation and
error propagation automatically:

let deadline = ContinuousClock().now.advanced(by: .seconds(5))

do {
    let result = try await withDeadline(deadline) {
        try await fetchDataFromServer()
    }
    print("Data received: \(result)")
} catch {
    switch error.cause {
    case .deadlineExceeded(let operationError):
        print("Request exceeded deadline: \(operationError)")
    case .operationFailed(let operationError):
        print("Request failed: \(operationError)")
    }
}

The solution is safer than manual implementations because it handles all race
conditions between the operation and deadline timer, ensures proper cleanup
through structured concurrency, and provides clear semantics for cancellation
behavior.

Detailed design

Executing work with a given deadline

The fundamental entry point for working with deadlines is a single function: withDeadline.

/// Executes an asynchronous operation with a specified deadline.
///
/// Use this function to limit the execution time of an asynchronous operation to a specific instant.
/// If the operation completes before the deadline expires, this function returns the result. If the
/// deadline expires first, this function cancels the operation and if the operation then throws the 
/// error then will be used to construct a ``DeadlineError`` with the ``.deadlineExceeded`` cause. 
///
/// The following example demonstrates using a deadline to limit a network request:
///
/// ```swift
/// let clock = ContinuousClock()
/// let deadline = clock.now.advanced(by: .seconds(5))
/// do {
///     let result = try await withDeadline(deadline) {
///         try await fetchDataFromServer()
///     }
///     print("Data received: \(result)")
/// } catch {
///     switch error.cause {
///     case .deadlineExceeded:
///         print("Deadline exceeded and operation threw: \(error.underlyingError)")
///     case .operationFailed:
///         print("Operation failed before deadline: \(error.underlyingError)")
///     }
/// }
/// ```
///
/// ## Behavior
///
/// The function exhibits the following behavior based on deadline and operation completion:
///
/// - If the operation completes successfully before deadline: Returns the operation's result.
/// - If the operation throws an error before deadline: Throws ``DeadlineError`` with cause
///  ``DeadlineError/Cause/operationFailed``.
/// - If deadline expires and operation completes successfully: Returns the operation's result.
/// - If deadline expires and operation throws an error: Throws ``DeadlineError`` with cause
///  ``DeadlineError/Cause/deadlineExceeded.
///
/// ## Coordinating multiple operations
///
/// Use `withDeadline` when coordinating multiple operations to complete by the same instant:
///
/// ```swift
/// let clock = ContinuousClock()
/// let deadline = clock.now.advanced(by: .seconds(10))
///
/// async let result1 = withDeadline(deadline) {
///     try await fetchUserData()
/// }
/// async let result2 = withDeadline(deadline) {
///     try await fetchPreferences()
/// }
///
/// let (user, prefs) = try await (result1, result2)
/// ```
///
/// This ensures both operations share the same absolute deadline, avoiding duration drift that can occur
/// when timeouts are passed through multiple call layers.
///
/// - Important: This function cancels the operation when the deadline expires, but waits for the
/// operation to return. The function may run longer than the time until the deadline if the operation
/// doesn't respond to cancellation immediately.
///
/// - Parameters:
///   - deadline: The instant by which the operation must complete.
///   - tolerance: The tolerance used for the sleep.
///   - clock: The clock to use for measuring time. The default is `ContinuousClock`.
///   - body: The asynchronous operation to execute before the deadline.
///
/// - Returns: The result of the operation if it completes successfully before or after the deadline expires.
///
/// - Throws: A ``DeadlineError`` indicating whether the operation failed before deadline
/// (``DeadlineError/Cause/operationFailed``) or was cancelled due to deadline expiration
/// (``DeadlineError/Cause/deadlineExceeded``).
nonisolated(nonsending) public func withDeadline<Return, Failure: Error>(
  _ expiration: ContinuousClock.Instant,
  tolerance: ContinuousClock.Instant.Duration? = nil,
  body: nonisolated(nonsending) () async throws(Failure) -> Return
) async throws(DeadlineError<Failure>) -> Return

The deadline-based API accepts a ContinuousClock.Instant, allowing multiple operations
to share the same absolute deadline:

let deadline = ContinuousClock().now.advanced(by: .seconds(10))

async let user = withDeadline(deadline) {
    try await fetchUser()
}
async let prefs = withDeadline(deadline) {
    try await fetchPreferences()
}

let (userData, prefsData) = try await (user, prefs)

These absolute deadlines are composable and nestable to any set scope of a deadline. This means that when more than one withDeadline is nested the minimum of the expiration is taken.

let userAndPrefsDeadline = ContinuousClock().now.advanced(by: .seconds(5))

let userAndPrefs = try await withDeadline(deadline) {
  let user = try await fetchUser()
  let prefs = try await fetchPrefs()
}

func fetchPrefs() async throws(FetchFailure) -> Prefs {
  let prefsDeadline = ContinuousClock().now.advanced(by: .seconds(10))
  do {
    return try await withDeadline(deadline) {
      try await fetchPreferences()
    }
  } catch {
    throw error.underlyingError
  }
}

Particularly in this case the composition can be made such that two independent regions can participate in a composed deadline across library boundaries and still result in the correct
deadline for the composed expectation of the caller. This is the underlying reason for the clock to be distinctly used as the continuous clock since those instants can be composed within the process across those boundaries. Any case that needs to communicate beyond that boundary needs to have some sort of serialization anyways so those uses of the communications channels need to manage the conversions between the expiration measured against the continuous clock and whatever other clock mechanism that is suitable for that communication.

In short the deadline is composed by the minimum. The previous example would execute with the minimum of 5 seconds from now and 10 seconds from now (being 5 seconds from now as the "current" deadline).

Shorthand for quickly using common deadline construction

Constructing an instant every time is not per-se the most terse; so a simple extension offers the ease of construction with the same compositional advantage as the primary entry point.

nonisolated(nonsending) public func withDeadline<Return, Failure: Error>(
  in timeout: ContinuousClock.Instant.Duration,
  tolerance: ContinuousClock.Instant.Duration? = nil,
  body: nonisolated(nonsending) () async throws(Failure) -> Return
) async throws(DeadlineError<Failure>) -> Return

The implementation of this is trivially:

try await withDeadline(ContinuousClock().now.advanced(by: timeout), tolerance: tolerance, body: body)

Non-escaping nonisolated(nonsending) operation closure

Many existing deadline/timeout implementations require a @Sendable and
@escaping closure which makes it hard to compose in isolated context and use
non-Sendable types. This design ensures that the closure is both non-escaping
and nonisolated(nonsending) for composability:

actor DataProcessor {
    var cache: [String: Data] = [:]

    func fetchWithDeadline(url: String) async throws {
        // The closure can access actor-isolated state because it's nonisolated(nonsending)
        let data = try await withDeadline(in: .seconds(5)) {
            if let cached = cache[url] {
                return cached
            }
            return try await URLSession.shared.data(from: URL(string: url)!)
        }
        cache[url] = data
    }
}

If the closure were @Sendable, it couldn't access actor-isolated state like
cache. The nonisolated(nonsending) annotation allows the closure to compose
with surrounding code regardless of isolation context, while maintaining safety
guarantees.

Failures and expiration

The mechanism this API uses to communicate the expiration or the failure of an executing deadline
is through a generic concrete error type: DeadlineError. This allows the throwing of the specific
underlying error but also containing the applied deadline and reasoning for the failure.

/// An error that indicates whether an operation failed due to deadline expiration or threw an error during
/// normal execution.
///
/// This error type distinguishes between two failure scenarios:
/// - The operation threw an error before the deadline expired.
/// - The operation was cancelled due to deadline expiration and then threw an error.
///
/// Use pattern matching to handle each case appropriately:
///
/// ```swift
/// do {
///     let result = try await withDeadline(in: .seconds(5)) {
///         try await fetchDataFromServer()
///     }
///     print("Data received: \(result)")
/// } catch {
///     switch error.cause {
///     case .deadlineExpired:
///         print("Deadline exceeded and operation threw: \(error.underlyingError)")
///     case .operationFailed:
///         print("Operation failed before deadline: \(error.underlyingError)")
///     }
/// }
/// ```
public struct DeadlineError<OperationError: Error>: Error, CustomStringConvertible, CustomDebugStringConvertible {
  /// The underlying cause of the deadline error.
  public enum Cause: Sendable, CustomStringConvertible, CustomDebugStringConvertible {
    /// The operation was cancelled due to deadline expiration and subsequently threw an error.
    case deadlineExpired

    /// The operation threw an error before the deadline expired.
    case operationFailed
  }

  /// The underlying cause of the deadline error, indicating whether the operation
  /// failed before the deadline or was cancelled due to deadline expiration.
  public var cause: Cause

  /// The deadline expiration that was specified for the operation.
  public var expiration: ContinuousClock.Instant 

  /// The error thrown by the operation either in cases of expiration or failure
  public var underlyingError: OperationError

  /// Creates a deadline error with the specified cause and deadline expiration.
  public init(cause: Cause, expiration: ContinuousClock.Instant, underlyingError: OperationError)
}

DeadlineError is a struct that contains the cause of the failure, the clock
used for time measurement, and the deadline instant. The Cause enum
distinguishes between two failure scenarios:

  • The operation threw an error before the deadline expired
    (Cause.operationFailed)
  • The operation was cancelled due to deadline expiration and then threw an error
    (Cause.deadlineExceeded)

This allows callers to determine whether an error occurred due to deadline
expiration or due to the operation failing on its own, enabling different
recovery strategies. The additional expiration and underlyingError properties provide
context about the time measurement used and the specific deadline that was set.

Accessing the current Task's deadline expiration

extension Task where Success == Never, Failure == Never {
  public static var currentDeadline: ContinuousClock.Instant? { get }
}

extension UnsafeCurrentTask {
  public var deadline: ContinuousClock.Instant? { get }
}

The safe current deadline accessor is trivially the following:

extension Task where Success == Never, Failure == Never {
  public static var currentDeadline: ContinuousClock.Instant? { 
    unsafe withUnsafeCurrentTask { unsafeTask in
      if let unsafeTask = unsafe unsafeTask {
        return unsafe unsafeTask.deadline
      }
      return nil
    }
}

The deadline property of the UnsafeCurrentTask is an accessor to the task specific
data for the deadline. When a scope of a withDeadline is active that property will represent
the minimum of the current (if present) and the applied expiration of the deadline.

Both of these APIs have the intent to be used for composition, for example if a system needs
to communicate a deadline to some other system it can use these properties to relay that information
without needing the deadline to be directly passed.

Implementation Details

The implementation uses structured concurrency with task groups to race the
operation against a deadline timer:

  1. Two tasks are created: one executes the operation, the other sleeps until the
    deadline.
  2. The first task to complete determines the result.
  3. When either task completes, cancelAll() cancels the other task.
  4. If the deadline expires first, the operation is cancelled but the function
    waits for it to return.
  5. The function handles both the operation's result and any errors thrown.

Important behavioral note: The function cancels the operation when the
deadline expires, but waits for the operation to return. This means
withDeadline may run longer than the time until the deadline if the operation
doesn't respond to cancellation immediately. This design ensures proper cleanup
and prevents resource leaks from abandoned tasks.

Users who wish to adjust behaviors can use the task cancellation shields to
alter the behavior of the return values along with task cancellation handlers.
These in conjunction with manual processing of do/catch clauses can compose
to complex behaviors needed for many specialized scenarios.

Behaviors for Cancellation and Expiration

The following examples should outline common composition and cancellation behaviors.

struct LocalError: Error { }

print("====== EXAMPLE 0 ======")
do {
  let value = try await withDeadline(in: .seconds(2), tolerance: .microseconds(2)) {
    return "Success"
  }
} catch {
  print("caught \(error)")
}
// ====== EXAMPLE 0 ======

print("====== EXAMPLE 1 ======")
do {
  try await withDeadline(in: .seconds(2), tolerance: .microseconds(2)) {
    throw LocalError()
  }
} catch {
  print("caught \(error)")
}
// ====== EXAMPLE 1 ======
// caught DeadlineError(cause: .operationFailed, expiration: Instant(_value: 1737016.436590875 seconds), underlyingError: LocalError()

print("====== EXAMPLE 2 ======")
do {
  try await withDeadline(in: .seconds(3), tolerance: .microseconds(2)) {
    try await withTaskCancellationHandler {
      try await withDeadline(in: .seconds(2), tolerance: .microseconds(2)) {
        try await withTaskCancellationHandler {
          let elapsed = await ContinuousClock().measure {
            try? await Task.sleep(for: .seconds(10))
          }
          print("\(elapsed) elapsed")
          throw LocalError()
        } onCancel: {
          print("cancel inner")
        }
      }
    } onCancel: {
      print("cancel outer")
    }
  }
} catch {
  print("caught \(error)")
}
// ====== EXAMPLE 2 ======
// cancel inner
// 2.001315 seconds elapsed
// caught DeadlineError(cause: .operationFailed, expiration: Instant(_value: 1736722.0348198751 seconds), underlyingError: DeadlineError(cause: .deadlineExpired, expiration: Instant(_value: 1736721.0351736662 seconds), underlyingError: LocalError()

print("====== EXAMPLE 3 ======")
do {
  try await withDeadline(in: .seconds(2), tolerance: .microseconds(2)) {
    try await withTaskCancellationHandler {
      try await withDeadline(in: .seconds(3), tolerance: .microseconds(2)) {
        try await withTaskCancellationHandler {
          let elapsed = await ContinuousClock().measure {
            try? await Task.sleep(for: .seconds(10))
          }
          print("\(elapsed) elapsed")
          throw LocalError()
        } onCancel: {
          print("cancel inner")
        }
      }
    } onCancel: {
      print("cancel outer")
    }
  }
} catch {
  print("caught \(error)")
}
// ====== EXAMPLE 3 ======
// cancel inner
// cancel outer
// 2.00507375 seconds elapsed
// caught DeadlineError(cause: .deadlineExpired, expiration: Instant(_value: 1736723.037342833 seconds), underlyingError: DeadlineError(cause: .deadlineExpired, expiration: Instant(_value: 1736723.037342833 seconds), underlyingError: LocalError()

print("====== EXAMPLE 4 ======")
do {
  try await withDeadline(in: .seconds(2), tolerance: .microseconds(2)) {
    try await withTaskCancellationHandler {
      try await withDeadline(in: .seconds(10), tolerance: .microseconds(2)) {
        try await withTaskCancellationHandler {
          let elapsed = await ContinuousClock().measure {
            try? await Task.sleep(for: .seconds(3))
          }
          print("\(elapsed) elapsed")
          throw LocalError()
        } onCancel: {
          print("cancel inner")
        }
      }
    } onCancel: {
      print("cancel outer")
    }
  }
} catch {
  print("caught \(error)")
}
// ====== EXAMPLE 4 ======
// cancel inner
// cancel outer
// 2.005246625 seconds elapsed
// caught DeadlineError(cause: .deadlineExpired, expiration: Instant(_value: 1736725.042865291 seconds), underlyingError: DeadlineError(cause: .deadlineExpired, expiration: Instant(_value: 1736725.042865291 seconds), underlyingError: LocalError()

print("====== EXAMPLE 5 ======")
do {
  try await withDeadline(in: .seconds(3), tolerance: .microseconds(2)) {
    try await withTaskCancellationHandler {
      try await withDeadline(in: .seconds(2), tolerance: .microseconds(2)) {
        try await withTaskCancellationHandler {
          let clock = ContinuousClock()
          let elapsed = await clock.measure {
            let start = clock.now
            while clock.now < start + .seconds(10) {
              await Task.yield()
            }
          }
          print("\(elapsed) elapsed")
          throw LocalError()
        } onCancel: {
          print("cancel inner")
        }
      }
    } onCancel: {
      print("cancel outer")
    }
  }
} catch {
  print("caught \(error)")
}
// ====== EXAMPLE 5 ======
// cancel inner
// cancel outer
// 10.000002291000001 seconds elapsed
// caught DeadlineError(cause: .deadlineExpired, expiration: Instant(_value: 1736728.048390583 seconds), underlyingError: DeadlineError(cause: .deadlineExpired, expiration: Instant(_value: 1736727.048450916 seconds), underlyingError: LocalError()

Source compatibility

The proposed APIs are additive and the behavior of deadlines are composed
without a need for intermediary participation. Existing systems that handle
cancellation or throwing of errors will compose with this without the need
to adjust for the new deadline semantics.

Effect on ABI compatibility

Since this is an additive proposal there is no change to any existing ABI.
The proposed APIs are capable of being implemented in less performant manners
to the introduction of typed throws. Back porting this feature is not a proposed
part of the pitch but no technical limitation is added except the burden of
making the implementation fragmented upon deployment.

Effect on API resilience

This is an additive API and no existing systems are changed, however it will
introduce a few new types that will need to be maintained as ABI interfaces.

Location and availability

Previously this feature was pitched for swift-async-algorithms. However due
to the large demand and existing requests for this feature it was considered
perhaps not specialized enough to live in that particular package. It would
be a fairly common occurrence to need this functionality and it would be better
served living in the Concurrency module.

The availability has particular consideration listed in the ABI section.

Future directions

This can have an impact upon executors. The current implementation does not
need executors to do anything different than they do as this is pitched, but
some modification around cancellation of jobs could be added to allow
executors to more efficiently handle deadlines.

There are potentials of exposing the underlying structured concurrency primitives
to enable APIs like withDeadline. This has a precident in other langauges
and is often called race. Introducing the withDeadline API does not preclude
that as an eventuality and lends credence to the utility of having that as
a general purpose feature, however offering the more primitive functionality
would still likely have the same considerations and motivation for introducing
a withDeadline API. So these concepts are not mutually exclusive or blocking.
If at some point in time the concurrency library grows a new race type
primitive, then withDeadline would likely be a strong candidate for using that.

Alternatives considered

Timeout-based API instead of Deadline-based API

An earlier design considered naming the primary API withTimeout and having it
accept a duration parameter instead of focusing on deadline-based
(instant-based) semantics:

public func withTimeout<Return, Failure: Error>(
  in duration: Duration,
  body: () async throws(Failure) -> Return
) async throws(TimeoutError<Failure>) -> Return

This approach was rejected because deadline-based APIs provide better
composability and semantics. Duration-based timeouts accumulate drift when
passed through multiple call layers, making it impossible to guarantee that
nested operations complete within a precise time window, whereas absolute
deadlines allow multiple operations to coordinate on the same completion
instant.

The rejection however does not apply when the funnel point of the deadline
functionality is sent to an entry point handling the composition by using
instants and composing with the current deadline by a minimum function.

@Sendable and @escaping Closure

An earlier design considered using @Sendable and @escaping for the closure
parameter. This approach was rejected because it severely limited composability. The
@Sendable requirement prevented accessing actor-isolated state, making it
difficult to use in isolated contexts. The final design uses
nonisolated(nonsending) to enable better composition while maintaining safety.

12 Likes

Is there a reason why withDeadline in this pitch does not have a generic clock or was it omitted for brevity?

5 Likes

Ditto. I'd need to use it with a suspending clock, myself.

2 Likes

The pitch removes the generic clock and uses a ContinuousClock (I believe stated in the proposal). Composition does not work for a generic clock; you can't compare an instant from one clock to another, but furthermore you can't really access the concept of a current deadline without knowing the type.

What is the use for the suspending clock as opposed to measuring elapsed continuous time?

Time-sharing environments like CI where I might only have 0.1 seconds of CPU time per 1 second of real time (or some other such arrangement). I have an operation and need it to take 10 seconds of compute time or less, but I don't control how much CPU time I'm actually allocated as a percentage of real time. The suspending clock won't (shouldn't) count the time I spend not scheduled, but the continuous clock will.

That sorta thing.

3 Likes

Im not sure that is how suspending clocks work on any platform - the suspending part isn't about the time slice scale of the clock but instead the suspension of the system (Ala sleep).

How would deadline handling be tested? ie. if the system under test uses withDeadline , how can the deadline be tested, without waiting? With the previous pitch that was generic over Clock, it would be possible to pass in a fake clock, but that’s not possible now.

Go has an ingenious solution to this, with co-operation between the synctest package and the time package.

Time, in contrast, does only one thing: It moves forward. Tests need to control the rate at which time progresses, but a timer scheduled to fire ten seconds in the future should always fire ten (possibly fake) seconds in the future.

synctest can create ā€˜bubbles’ where time stands still, and can be advanced by time.Sleep. Would something similar be possible in Swift? Would structured concurrency make this easier, because child tasks have to complete before parents? It looks like Trio has a similar-looking testing option.


Having DeadlineError take the wrapped error as a generic parameter means it’s not possible to catch the error if the type is unknown. This was noted in the earlier pitch thread. Maybe DeadlineError<T> needs a DeadlineErrorProtocol (swiftFiddle) that would allow it to be caught without knowing the type of the underlying error?

public func downloadData() async throws {
  do {
    try await withDeadline(...) {
       // Previous code
    }
  catch let deadlineError as DeadlineErrorProtocol {
    switch deadlineError.cause { ... }
  }
 }

Similarly, what is the expected pattern for passing the underlying error back to callers, if there’s no distinction between causes (as shown in one of the examples):

do {
  return try await withDeadline(deadline) {
    try await fetchPreferences()
  }
} catch {
  throw error.underlyingError
}

Should there be an API that exposes this behaviour directly?

return try await withDeadlineButNoDeadlineError(deadline) {
  try await fetchPreferences()
}

Trio has the distinction between fail_at and move_on_at, which seems similar to this.


There are a few things that look like they were missed in the changes since the first pitch:

  • The first example has the nested errors as enum associated values, not the new underlyingError
  • The description for DeadlineError still mentions ā€˜the clock used for time measurement’
4 Likes

Then Iā€˜d like to raise the concern about testability. Consider this code:

func fetchWithFallback(cache: Cache) async throws -> Data {
    do {
        return try await withDeadline(in: .seconds(5)) {
            try await fetchFromNetwork()
        }
    } catch let error as DeadlineError<any Error> {
        switch error.cause {
        case .deadlineExpired:
            return try cache.loadCachedData()
        case .operationFailed:
            throw error.underlyingError
        }
    }
}

… to test the fallback code, youā€˜d have to actually wait time in tests, no?

10 Likes

I'll follow up with you privately about the specific scenarios I'm dealing with. I don't need to derail the thread with my nonsense. :upside_down_face:

Sadly so.

This proposal is unfortunately hindered by a subpar testing story when it comes to controllable clocks. There was a discussion about this very thing not long ago. I do hope this aspect of Swift gets tackled the way it deserves sometime soon.

3 Likes

The version of swift-async-algorithms still allows for that even though there is no official test clock yet.

Apparently this is all I ever post about :upside_down_face:

Agree that the non-configurable clock is a non-starter for testing, and would probably be sufficient to get this banned from actual use in real codebases. The implicit task-local state of "the current deadline" is similarly problematic, in that you can't set it for testing without imposing an actual real-time deadline, which is unlikely to be reliable within Swift Testing tests.

7 Likes

I see the pitched version of the doc contains "Implementation Details", but I'd like to request to not consider them a source of definite truth. We'd like to consider if we're able to implement this more efficiently, without having to schedule 2 tasks e.g. on the happy path preferably we wouldn't have to incur additional scheduling costs.

1 Like

I think if it had a function signature something like this:
func withDeadline<C: Clock>(clock: C, completion: () async -> Void) async where C.Duration == Duration

It would allow for mocking of the clock, ContinuousClock and (I think) SuspendingClock although I’m less familiar with it.

1 Like

That would have some fallout - it would mean that the error type would store an existential of the deadline, and the algorithm to calculate the minimum would only be an approximation if the types differ but it would require that both clocks would have to constrain their Instant.Duration types as Swift.Duration.

Not out of the question; it is worth consideration - but that definitely has some drawbacks.

Footnote: I tried implementing this and it doesn't seem so bad - I need to still verify this is actually reasonable.

Could there be a two-closure variant like:

    let result: Result? = await withDeadline(deadline) {
        try await fetchDataFromServer()
    } else: { error in
        switch error.cause {
        case .deadlineExceeded(let operationError):
            print("Request exceeded deadline: \(operationError)")
        case .operationFailed(let operationError):
            print("Request failed: \(operationError)")
        }
        return nil // Could re-throw or return default value
    }

Note that for this specific case, there’s not a try on the first line because the closure handles the error. (Granted making this work is a pretty easy utility function so maybe it should be outside the scope of this Pitch?)

2 Likes

TestClock does not improve testing

I'm not sure how injecting a TestClock improves testability. Let's say that the scenario we want to check is ā€œoperation responds correctly to the cancellation caused by timeoutā€:

  1. Create a TestClock.
  2. Run the operation using withDeadline and TestClock.
  3. Operation starts.
  4. The clock triggers the timeout and cancels the operation (assumption: operation is still running).
  5. Operation aborts.

The problem is the part in bold: if the operation has already finished, then the test will not do its job. This is a race condition.

The real question is: what exactly are we testing? withDeadline implementation? No, this is FranzBusch (proposal author) job. What we actually need to test is:

  • Code calls withDeadline with proper Instant and operation arguments.
  • Operation correctly handles the cancellation.

For that I would propose to structure the code in the following way:

// Write test to check if 'withDeadline' was called with the proper arguments.
// This function is a 1 liner - you may skip this test.
public func sendRequest(url: String, deadline: Instant) {
  withDeadline(deadline) { sendRequest(url: url) }
}

// Write tests to check the cancellation handling at various execution points.
internal func sendRequest(url: String) { … }

What we discovered here is that the Clock is irrelevant for testing the business code (namely the sendRequest(url:) cancellation). At its core withDeadline is just a cancellation with some extra steps. Our job is to test the ā€œcancellationā€, not the Clock dependent ā€œextra stepsā€.

Double closure

If this is an addition (new signature), then we have to look at the added mental complexity. The more overloads we add, the more docs our users have to read. And the code completion becomes less useful. I would be ā€œsoftā€ against that.

If you propose to replace the signatures in the proposal with the ones returning Result then I'm ā€œhardā€ against it.

Btw. As far as returning the default value: very often the operation itself decides what the default value should be. For example, we may run the deadline as an upper bound on how long the computation can take. When we reach the deadline, the operation just returns the ā€œbest value foundā€, even if some parts of the search space were not considered.

Proper error

In the previous thread I asked about:

/// ## Behavior
///
/// The function exhibits the following behavior based on deadline and operation completion:
/// - If deadline expires and operation throws an error: Throws ``DeadlineError`` with cause
/// ``DeadlineError/Cause/deadlineExceeded(_:).

Let's say we have the following sequence of events:

  1. We start a database update with deadline.
  2. Timeout occurs.
  3. We cancel the update.
  4. Database throws a constraint error.

The pitch will throw a DeadlineError.Cause.deadlineExceeded, but we also have a DatabaseError. Which one is the correct one to throw? For me it would be the DatabaseError.

Timeout without time

Another thing that I asked in the previous thread, but I have not received any answer is when we have a Deadline that does not depend on any Clock.

In general, ANY timeout expressed using Clock derivative (duration/instant) can be wonky:

  • The scheduler can starve the task, making things unreliable.
  • There is a big difference between ā€œ5 secondsā€ on the latest Apple silicon and some old Intel CPU.

Sometimes it is better to express the deadline in a manner that does not depend on any external resources (like time or CPU speed). For example, in combinatorics you may set the upper limit of tested permutations, etc. When we hit this number, it is still a timeout, just not a ā€œtimeā€ related timeout. Similar to how the Swift compiler sometimes says: ā€œExpression was too complex to be solved in reasonable timeā€ - AFAIK this message is not based on time, but on the number of tested permutations. (I have more examples if anyone needs.)

Not every timeout/deadline is ā€œtimeā€ related, but the DeadlineError just assumes that we have an Instant. This may put us in an unfortunate situation where we have DeadlineErrorWithInstant and DeadlineErrorWithoutInstant, and changing how the deadline is expressed (arguably an implementation detail) would change the error type (breaking change).

2 Likes

I’m going to say the same thing I said in the first pitch:

The functionality is great, and I’m looking forward to using it. However I expect the name will mislead many developers and lead to misuse.

I think the name must use cancel/cancellation somewhere or it will lead developers to misuse it and think they solved a problem without actually solving it.

8 Likes

I second the need for allowing a suspending clock! Users close their lid during app execution all the time, and there are absolutely scenarios where I would not want the deadline to creep forward while the system is suspended.

Why not make the API shape more similar to Task.sleep(deadline:tolerance:clock:), so you don’t need to provide a concrete clock? This would allow the use of any Clock!

The reason why that restriction cannot occur is that we need to be able to have a concept of the minimum of the current deadline being applied and the requested deadline - that composition needs to have at minimum an approximate conversion between clocks. Restricting the conformance to where Instant.Duration = Swift.Duration means that there is a way to convert (albeit in a lossy manner).