Skip to content

Commit 209f93a

Browse files
phauslerktosoFranzBusch
authored
[SE-0526] Feedback and refinements for withTaskCancellationHandler, and CancellationError.Reason (#3306)
* Remove the .custom case for CancellationError * Adjust a few code blocks to highlight as swift, add a withTaskCancellationHandler overload and add some notes about the CancellationError.Reason * Add a clock interface for an earliest conversion routine for continuous clocks and deadlines * Rework the conversions into a active clock accessor and add clock ids * Remove cruft file * Update proposals/0526-deadline.md Co-authored-by: Konrad `ktoso` Malawski <konrad.malawski@project13.pl> * Update proposals/0526-deadline.md Co-authored-by: Konrad `ktoso` Malawski <konrad.malawski@project13.pl> * Update proposals/0526-deadline.md Co-authored-by: Konrad `ktoso` Malawski <konrad.malawski@project13.pl> * Update proposals/0526-deadline.md Co-authored-by: Konrad `ktoso` Malawski <konrad.malawski@project13.pl> * Update proposals/0526-deadline.md Co-authored-by: Konrad `ktoso` Malawski <konrad.malawski@project13.pl> * Update proposals/0526-deadline.md Co-authored-by: Konrad `ktoso` Malawski <konrad.malawski@project13.pl> * Update proposals/0526-deadline.md Co-authored-by: Konrad `ktoso` Malawski <konrad.malawski@project13.pl> * Update proposals/0526-deadline.md Co-authored-by: Konrad `ktoso` Malawski <konrad.malawski@project13.pl> * Address some additional feedback with regards to naming of taskCancelled and adjust some wording/formatting to be clearer * Some extra feedback * Apply suggestions from code review Co-authored-by: Franz Busch <privat@franz-busch.de> * Integrate ktoso's changes without collapsing the task group APIs --------- Co-authored-by: Konrad `ktoso` Malawski <konrad.malawski@project13.pl> Co-authored-by: Franz Busch <privat@franz-busch.de>
1 parent ef786f5 commit 209f93a

1 file changed

Lines changed: 170 additions & 16 deletions

File tree

proposals/0526-deadline.md

Lines changed: 170 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -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
206206
with 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
255255
with surrounding code regardless of isolation context, while maintaining safety
256256
guarantees.
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
269269
lightweight, uniform signal. With the introduction of deadlines, however, differentiating between
270270
a 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
273273
be added for constructing a `CancellationError` with a given reason, and a new property will be
274274
added for determining what the reason of the cancellation was. This modification not only allows
275275
for 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
280279
public 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
301299
has been already written the developer already has handled the cases of cancellation without a
302300
given 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+
304307
To aid in the population of cancellation errors, new APIs will be added. These will all be cases
305308
where 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

335368
The 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
342375
The reason specified will then be available to the `CancellationError` and can be retrieved from the `reason`
343376
property 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

347484
1. 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.
527664
The modification to `CancellationError` adds a new stored property and initializer
528665
but preserves the existing default initializer with identical behavior - existing
529666
code 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
531668
being implemented in less performant manners prior to the introduction of typed throws.
532669
Back porting this feature is not a proposed part of the pitch but no technical
533670
limitation is added except the burden of making the implementation fragmented upon
@@ -655,6 +792,21 @@ review and debugging. Names centered on the mechanism (`withAutomaticTaskCancell
655792
require 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

660812
The 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

Comments
 (0)