@@ -139,7 +139,7 @@ The fundamental entry point for working with deadlines is a single function: `wi
139139/// - Returns: The result of the operation if it completes successfully before or after the deadline expires.
140140///
141141/// - Throws: The error thrown by the operation
142- nonisolated(nonsending) public func withDeadline <Return : ~ Copyable , Failure : Error , C : Clock >(
142+ nonisolated(nonsending) public func withDeadline <Return : ~ Copyable , Failure : Error , C : Clock & Identifiable >(
143143 _ expiration : C.Instant,
144144 tolerance : C.Instant.Duration? = nil ,
145145 clock : C = ContinuousClock (),
@@ -206,7 +206,7 @@ Constructing an instant every time is not per se the most terse; so a simple ext
206206with the same compositional advantage as the primary entry point.
207207
208208``` swift
209- nonisolated(nonsending) public func withDeadline <Return : ~ Copyable , Failure : Error , C : Clock >(
209+ nonisolated(nonsending) public func withDeadline <Return : ~ Copyable , Failure : Error , C : Clock & Identifiable >(
210210 in timeout : C.Instant.Duration,
211211 tolerance : C.Instant.Duration? = nil ,
212212 clock : C = ContinuousClock (),
@@ -255,16 +255,16 @@ If the closure were `@Sendable`, it couldn't access actor-isolated state like
255255with surrounding code regardless of isolation context, while maintaining safety
256256guarantees.
257257
258- #### Cancellation
258+ #### Cancellation reasons
259259
260- This API uses the base cancellation to communicate the expiration of the deadline.
261- The information to differentiate a cancellation due to normal task cancellation is
262- expanded to handle two new forms of cancellation; a cancellation due to deadline expiration,
263- and a custom cancellation with a specified string for a reason. Since this is not a closed
264- set of possible reasons for future development, this reason is left as an open enumeration.
260+ This proposal uses the cancellation mechanism to communicate the expiration of a deadline.
265261
266- Today ` CancellationError ` is an empty type with no payload or information conveyed to indicate
267- the reasoning for cancellation. [ SE-0304] ( 0304-structured-concurrency.md ) originally noted that
262+ The cancellation error gains a new reason field which can be used to differentiate
263+ a cancellation due to normal task cancellation or due to deadline expiration.
264+ Since this is not a closed set of possible reasons,
265+ for future development, this reason is left as an open enumeration.
266+
267+ [ SE-0304] ( 0304-structured-concurrency.md ) originally noted that
268268"no information is passed to the task about why it was cancelled," treating cancellation as a
269269lightweight, uniform signal. With the introduction of deadlines, however, differentiating between
270270a cancellation due to deadline expiration and a cancellation from an explicit ` Task.cancel() ` call
@@ -273,22 +273,20 @@ added to represent the reason for the cancellation, a new initializer for `Cance
273273be added for constructing a ` CancellationError ` with a given reason, and a new property will be
274274added for determining what the reason of the cancellation was. This modification not only allows
275275for developers to express the difference between a cancellation due to deadline expiration versus
276- normal task cancellation, but also express a custom reason for indicating why something might be
277- cancelled.
276+ normal task cancellation.
278277
279278``` swift
280279public struct CancellationError : Error {
281280 @nonexhaustive
282281 public enum Reason {
283- case taskCancelled
282+ case userRequested
284283 case deadlineExpired
285- case custom (String )
286284 }
287285
288286 public var reason: Reason { get }
289287 public init (reason : Reason)
290288
291- // This is shorthand for `CancellationError(reason: .taskCancelled )`
289+ // This is shorthand for `CancellationError(reason: .userRequested )`
292290 public init ()
293291}
294292```
@@ -301,6 +299,11 @@ statements require an `@unknown default` case. Since previous cancellation was s
301299has been already written the developer already has handled the cases of cancellation without a
302300given reason; this will continue to be the case.
303301
302+ > Note: The ` Reason ` type is restricted to known simple enumeration values without any
303+ associated values. This is due to the unknown impacts of what that type of size increase to
304+ tasks would entail. Any future proposals to modify that would require research to determine
305+ specific impact.
306+
304307To aid in the population of cancellation errors, new APIs will be added. These will all be cases
305308where a task or child task is cancelled and a CancellationError would normally be created.
306309
@@ -330,6 +333,36 @@ extension ThrowingDiscardingTaskGroup {
330333}
331334```
332335
336+ This also means that when a task is cancelled it communicates with any task cancellation handlers
337+ and passes that information to the appropriate handler.
338+
339+ ``` swift
340+ public nonisolated(nonsending) func withTaskCancellationHandler <Return , Failure >(
341+ operation : nonisolated(nonsending) () async throws (Failure) -> Return,
342+ onCancel handler : sending (CancellationError.Reason) -> Void
343+ ) async throws (Failure) -> Return
344+ ```
345+
346+ This function works exactly as the existing ` withTaskCancellationHandler ` does today,
347+ except that the ` onCancel ` handler is passed the reason for cancellation.
348+
349+ Similar to how it is possible to query a task handle about its cancelled status using ` isCancelled ` ,
350+ this proposal introduces a ` cancellationReason ` static property:
351+
352+ ``` swift
353+ extension Task where Success == Never , Failure == Never {
354+ /// Returns the reason of the cancellation if the current task is cancelled, nil otherwise.
355+ /// Similar to `isCancelled`, once this field becomes non-nil, consistently returns the same value.
356+ ///
357+ /// - SeeAlso: `isCancelled`
358+ public static var cancellationReason: CancellationError.Reason? { get }
359+ }
360+
361+ extension UnsafeCurrentTask {
362+ public var cancellationReason: CancellationError.Reason? { get }
363+ }
364+ ```
365+
333366#### Failures and expiration
334367
335368The withDeadline throwing behavior is that of the operation's throwing behavior. If the operation throws a
@@ -342,6 +375,110 @@ This error is propagated from whenever the task (or child task) is cancelled via
342375The reason specified will then be available to the ` CancellationError ` and can be retrieved from the ` reason `
343376property on the cancellation error.
344377
378+ #### Accessing active deadlines
379+
380+ External systems may need to interoperate with active deadlines. This means that the
381+ applied deadline needs to be retrievable, however this particularly becomes
382+ tricky since the clock is generic for the deadlines. To that end
383+ the accessor for the active deadline accepts a generic clock instance:
384+
385+ ``` swift
386+ extension Task where Success == Never , Failure == Never {
387+ public static var hasActiveDeadline: Bool { get }
388+
389+ public static func activeDeadline <C : Clock & Identifiable >(for clock : C) -> C.Instant?
390+ }
391+ ```
392+
393+ If any deadline is active then the static property ` hasActiveDeadline ` returns true.
394+ This applies to the current task or child task if the execution of that task is within a call
395+ to ` withDeadline ` . This allows for determining the return of the ` deadline ` static
396+ function to be used to know if a known clock has a value being applied as a current
397+ deadline. This does mean that the usage must be aware of the potential clocks being
398+ used. This is however a requirement since to use the deadline itself the clock must
399+ be known for any sort of usage to an external system.
400+
401+
402+ ``` swift
403+ if Task.hasActiveDeadline {
404+ if let deadline = Task.activeDeadline (for : ContinuousClock ()) {
405+ // use the deadline as a ContinuousClock.Instant
406+ }
407+ if let deadline = Task.activeDeadline (for : SuspendingClock ()) {
408+ // use the deadline as a SuspendingClock.Instant
409+ }
410+ }
411+ ```
412+
413+ When the call to ` activeDeadline(for:) ` is made, the query looks up the most narrow
414+ application of any specified deadline with that clock, if the current nesting of
415+ ` withDeadline ` calls does not use the specified clock type then the next nesting
416+ up the call stack is used.
417+
418+ If the nesting of ` withDeadline ` is stacked with a ` ContinuousClock ` deadline of
419+ "in two seconds" and then a ` SuspendingClock ` of "in three seconds" and a new
420+ nesting is made of a ` ContinuousClock ` is made for "in 10 seconds" the last
421+ 10 seconds is known to be less narrow than the outer 2 seconds continuous clock
422+ deadline. This means that within the scope of the "in 10 seconds" deadline the query
423+ for the ` activeDeadline(for: ContinuousClock()) ` would return the deadline of within
424+ 2 seconds and the ` activeDeadline(for: SuspendingClock()) ` would return the deadline
425+ of within 3 seconds. Since clock instants cannot be compared without potentially
426+ arbitrarily lossy conversions it means that the query for the current applied deadline
427+ is only accurate to the specific clock type given.
428+
429+ ``` swift
430+ let continuous = ContinuousClock ()
431+ let suspending = SuspendingClock ()
432+
433+ let inTwoSeconds = continuous.now .advanced (by : .seconds (2 ))
434+ let inThreeSeconds = suspending.now .advanced (by : .seconds (3 ))
435+ let inTenSeconds = continuous.now .advanced (by : .seconds (10 ))
436+
437+ try await withDeadline (inTwoSeconds, clock : continuous) {
438+ try await withDeadline (inThreeSeconds, clock : suspending) {
439+ try await withDeadline (inTenSeconds, clock : continuous) {
440+ assert (Task.activeDeadline (for : continuous) == inTwoSeconds)
441+ assert (Task.activeDeadline (for : suspending) == inThreeSeconds)
442+ }
443+ }
444+ }
445+ ```
446+
447+ #### Identification of Clocks for coalescing
448+
449+ The expected behavior when setting a deadline is that any active deadline, given a specific clock,
450+ will always apply with the most narrow deadline available. Specifically if a deadline is
451+ active for an expiration of * in 10 seconds* and a new deadline is applied for * in 5 seconds*
452+ relative both relative to the continuous clock, then the applied deadline within the new
453+ scope is the * in 5 seconds* . Likewise if the reverse was applied; where it is already at * in 5 seconds*
454+ and a new scope is applied to * in 10 seconds* both on the continuous clock, then the internal
455+ logic will effectively skip the * in 10 seconds* since that deadline is known to beyond the current
456+ active deadline. This must have some way of determining if a given clock passed in to
457+ the ` withDeadline ` functions is that same specific clock. To that end, the clocks are
458+ required to be identifiable. The two major clocks; ` ContinuousClock ` and ` SuspendingClock `
459+ both will gain a new conformance to ` Identifiable ` and each of which will have a new ID
460+ type of ` SystemClockID ` .
461+
462+ > Note: Since the system clock may grow additional identifiers it is left as non-exhaustive.
463+
464+ ``` swift
465+ @nonexhaustive
466+ public enum SystemClockID : Hashable {
467+ case continuous
468+ case suspending
469+ }
470+
471+ extension ContinuousClock : Identifiable {
472+ public var id: SystemClockID { .continuous }
473+ }
474+
475+ extension SuspendingClock : Identifiable {
476+ public var id: SystemClockID { .suspending }
477+ }
478+ ```
479+
480+ Custom clocks can also adhere to ` Identifiable ` to be used for deadlines.
481+
345482### Behavioral Details
346483
3474841 . The user specified closure runs concurrently to the timing of the expiration
@@ -527,7 +664,7 @@ Since this is an additive proposal there is no change to any existing ABI.
527664The modification to ` CancellationError ` adds a new stored property and initializer
528665but preserves the existing default initializer with identical behavior - existing
529666code that constructs ` CancellationError() ` will continue to produce an error with
530- the equivalent of ` .taskCancelled ` as its reason. The proposed APIs are capable of
667+ the equivalent of ` .userRequested ` as its reason. The proposed APIs are capable of
531668being implemented in less performant manners prior to the introduction of typed throws.
532669Back porting this feature is not a proposed part of the pitch but no technical
533670limitation is added except the burden of making the implementation fragmented upon
@@ -655,6 +792,21 @@ review and debugging. Names centered on the mechanism (`withAutomaticTaskCancell
655792require the reader to infer the temporal aspect, while names centered on the concept
656793(` withDeadline ` ) let the reader infer the mechanism from context.
657794
795+ It was considered naming the default reason as ` taskCancelled ` , however this is a touch
796+ too general and felt redundant. The name of ` userRequested ` does differentiate between
797+ other cancellation reasons but still lacks some nuance to the name, this is an area where
798+ naming is open for suggestions that could convey the default nature and also differentiate
799+ between the other cancellation reasons without overloading existing terms. For now,
800+ ` userRequested ` seems like the best option available.
801+
802+ ### CancellationError custom reasons
803+
804+ It was considered to allow custom error reasons. This would mean that the tasks would need
805+ to store a custom associated type to the enum. The lifetime of this variable would then be
806+ incredibly difficult to nail down, but also potentially guide developers into parsing
807+ strings in errors. The latter would not be an ideal scenario, and likely cause string
808+ values within errors become quasi ABI.
809+
658810### Previous Incarnations
659811
660812The clock was originally suggested as a generic clock originally, however when
@@ -705,6 +857,8 @@ nesting. This approach was not taken because:
705857 explicit ` withDeadline ` API would remain useful even if such a mechanism were added.
706858
707859## Changelog
860+ - 1.2 Revised for feedback
861+ - Added accessors to add a way to access the active deadlines
708862- 1.1 Returned for revision
709863 - The typed throws signature was altered to avoid an extra error type
710864 - Removed the restriction around the instant requiring the duration type to be ` Swift.Duration `
0 commit comments