# How a canceled Task still ran to the end in a review prompt

> A review prompt scheduled 2 seconds after a win could still run after the screen was left. A cancel after Task.sleep returns does not come back as an error.

- Canonical: https://jaemyeong.com/en/blog/task-sleep-cancellation-review-prompt-gate/
- Published: 2026.08.18
- Updated: 2026.10.04
- Category: IT/개발
- Tags: #iOS, #Swift, #StoreKit, #Concurrency, #Android

I added a feature to DailySudoku, a personal project that gives one Sudoku game a day, that asks for a store rating after a win. The store and web addresses are the [App Store](https://apps.apple.com/app/id1149229748), [Google Play](https://play.google.com/store/apps/details?id=so.object.sudoku), and the [web](https://dailysudoku.app/ko/).

[AppStore.requestReview(in:)](https://developer.apple.com/documentation/storekit/appstore/requestreview(in:)-1q8qs) on iOS does not report whether the prompt actually appeared, and there is no completion callback. The system limits how often it shows, and the app also limits requests to one per version. If an attempt is recorded at a moment when the screen is not suitable for a request, the chance for that release is used up. Two code reviews each found one such path.

An earlier version of this post gave the wrong name for Swift's cancellation error and described the behavior of `Task.sleep` incorrectly. On October 4, 2026, I ran the cancellation behavior again in a standalone program on Swift 6.4 and corrected it. The app's tests and the real race were not rerun this time.

## The flow that schedules a request

When a game ends in a win, `markPending()` checks eligibility and only marks it pending. StoreKit is not called at that point. When the completion screen is shown, `screenDidShowCompletion()` schedules a run 2 seconds later only if it is pending, and when the screen is left, `screenWillDisappear()` cancels the schedule. Using a delay follows the sample in [Apple's guide to requesting reviews](https://developer.apple.com/documentation/storekit/requesting-app-store-reviews).

`fire`, which does the work, does not keep the scene from the time of scheduling. It reads `presenter.view.window?.windowScene` right before running.

```swift
private func fire(presenter: UIViewController?, repository: ReviewPromptRepository) {
    scheduledTask = nil
    guard let presenter, let scene = presenter.view.window?.windowScene else { return }
    let version = ReviewPromptConfig.currentAppVersion
    let epochDay = EpochDays.today(millis: Int64(Date().timeIntervalSince1970 * 1000))
    // 시도 기록은 여기에서만 합니다.
    repository.recordAttempt(epochDay: epochDay, version: version)
    AppStore.requestReview(in: scene)
}
```

The Korean comment says the attempt is recorded only here.

Pending is not saved as an attempt so that a canceled schedule does not start the cooldown. The code assumed that the window is nil once the screen is left, but that assumption may not hold during a transition or in the background.

## First finding: the window stays in the background

The first review found the case where the app goes to the background during the 2-second wait. UIKit can keep the window attached in the background, so the presence of a window alone does not tell whether a request is appropriate.

I added a condition that the scene is `.foregroundActive`.

```swift
guard let presenter, let scene = presenter.view.window?.windowScene,
      scene.activationState == .foregroundActive else { return }
```

During a screen transition, this condition does not guarantee a suitable state for a request either.

## Second finding: a path that reached fire after cancel

A review two days later found a path where `fire` can run even though the schedule was canceled when the screen was left. The schedule looked like this.

```swift
scheduledTask = Task { [weak presenter] in
    do { try await Task.sleep(for: .seconds(2)) }
    catch { return }   // 취소됨 — 여기서 끝난다고 생각했습니다
    fire(presenter: presenter, repository: repository)
}
```

The comment says "canceled, and I thought it ended here."

I thought that on cancel, [Task.sleep](https://developer.apple.com/documentation/swift/task/sleep(for:tolerance:clock:)) throws and the `catch` ends the task. That is only half right. The earlier version called this error `CancellationException`, which is the Kotlin name. In Swift it is `CancellationError`.

This is the behavior I confirmed by running code (Swift 6.4, a standalone program).

- Calling `try await Task.sleep` inside a task that is already canceled gives `CancellationError`.
- Canceling in the middle of a 30-second wait also gives `CancellationError`.
- Canceling after `Task.sleep` has returned normally lets the next statement run. `Task.isCancelled` is true at that point.
- Wrapping the call in `try?` only turns the error into nil, and the next statement runs. The canceled state remains.
- Calling `Task.checkCancellation()` in the canceled state gives `CancellationError`.

This is the output of running the three cases. `before` is calling sleep after cancel, `during` is canceling in the middle of the wait, and `after` is canceling after sleep returned.

```text
before: isCancelled=true
before: error=CancellationError, CancellationError=true
try?: nil=true, continued=true, isCancelled=true
check: CancellationError=true
during: CancellationError=true
after: sleep returned
after: continued=true, isCancelled=true
after: check CancellationError=true
```

The `after` result is the path in question. Once sleep has returned, a late cancel does not turn that call into a failure. The `catch` is skipped, so execution reaches `fire`. The earlier version stated this broadly as "an expired sleep does not throw," but what was confirmed goes only as far as "a cancel after a normal return." The exact race between the moment the timer ends and the moment the task resumes could not be settled by this experiment.

The fix is one line. After handling the error from sleep, the code checks once more whether it is canceled now.

```swift
do { try await Task.sleep(for: .seconds(2)) }
catch { return }
guard !Task.isCancelled else { return }   // 이 줄이 있어야 합니다
fire(presenter: presenter, repository: repository)
```

The comment says "this line is needed."

The `catch` covers a cancel during the wait, and the `guard` covers a cancel after the return. Handling the error from `Task.checkCancellation()` is another way to do it. What this `guard` sees is the state at that instant. It does not remove the chance of a cancel arriving from another execution context after the check. Whether the screen events and this code run one after another in the same context cannot be confirmed from the excerpt alone.

## The comparison with Android

My judgment back then was that the same problem does not exist on the Android side, for two reasons. According to [the Kotlin cancellation documentation](https://kotlinlang.org/docs/coroutines-cancellation.html), suspending functions such as `delay` check for cancellation when they suspend. [The API documentation of `delay`](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/delay.html) says that if it was cancelled while suspended, `CancellationException` is thrown even when it is ready to return. And `recordPrompt` is placed after `suspendCancellableCoroutine`. That guarantee covers a cancellation that arrives while the coroutine is suspended, though. If the cancellation arrives after `suspendCancellableCoroutine` has returned normally and before `recordPrompt` is called, the next statement can still run, as in the `after` case of the measurement above. Whether the Android code checks in between, for example with `ensureActive()`, and a result of reproducing the cancellation are not in this post, so the Android side cannot be called safe. `recordAttempt` on iOS is a synchronous call and has no point at all where cancellation is checked. The flow of [Google Play in-app reviews](https://developer.android.com/guide/playcore/in-app-review) is not part of this comparison.

The earlier version added here that "Swift's sleep checks for cancellation only while waiting." As `before` in the output above shows, a task that is already canceled also gets the error, so that explanation does not hold. The Kotlin side was not confirmed by running code. What an API guarantees and whether the whole feature is safe are different questions, and each platform has to be checked separately.

## Eligibility is a pure function

The part that computes whether a request is allowed is separated into a function that does not depend on StoreKit.

```swift
static func shouldRequestReview(
    wonCompletionCount: Int,
    lastAttemptEpochDay: Int64,
    todayEpochDay: Int64,
    lastPromptedVersion: String?,
    currentVersion: String,
    forceOverride: Bool
) -> Bool {
    if forceOverride { return true }
    guard wonCompletionCount >= ReviewPromptConfig.wonCompletionThreshold else { return false }
    guard todayEpochDay - lastAttemptEpochDay >= ReviewPromptConfig.cooldownDays else { return false }
    if let lastPromptedVersion, lastPromptedVersion == currentVersion { return false }
    return true
}
```

If `forceOverride` is on, the request is allowed first. After that, the number of wins is compared with the threshold, the days since the last attempt are compared with the cooldown, and the request is refused when the current version equals the last prompted version. Since it does not call StoreKit's `AppStore`, it can be tested with a truth table.

iOS and Android each read the same 12 inputs from one JSON file and test against them. The Android check on the number of cases was changed from `>= 10` to exactly 12, because `>= 10` did not catch the shared fixture changing or the two sides drifting apart. How the date boundary is handled connects to [the post on leaderboard date boundaries](/en/blog/daily-leaderboard-date-boundary-submission-reliability/).

I chose the win threshold and the cooldown myself. By my own assessment, the values allow a request earlier than the intent of [Apple's guidelines on ratings and reviews](https://developer.apple.com/design/human-interface-guidelines/ratings-and-reviews), which is not to ask before the user has formed an opinion. I treat the system limit as extra protection and wrote the reason for the choice in a comment next to the constants. Nothing guarantees that the system limit stands in for the recommendation in the guidelines.

## What was verified

The original verification was based on commits `eeb4d90d`, `9884d36f`, and `fa69105c` on the develop branch as of August 15, 2026. `xcodebuild test` on iOS passed with 287 tests in 60 suites, and `testDebugUnitTest` on Android passed with 297 tests in 42 classes. Eligibility has 12 cases on both platforms. These numbers are the record from that time and were not rerun this time.

Much is not covered by automated tests. The API does not report whether the prompt was shown, so automated tests cannot confirm that the system prompt actually appeared. Neither platform has a test that automatically checks execution after cancel. iOS has no such test file, and only the part that marks pending is within unit tests. Even with a test file, the kill switch in the xctestplan would have blocked the real call. The cancellation path was reviewed by tracing the code and with a static call graph, and both defects came out of code review.

On a device, the check uses a debug build from Xcode with a launch argument that skips the eligibility decision. According to [the `requestReview(in:)` documentation](https://developer.apple.com/documentation/storekit/appstore/requestreview(in:)-1q8qs), this method has no effect in apps distributed through TestFlight.

What I confirmed by running code this time goes only as far as cancellation behavior in a standalone Swift program. The real race involving UIKit, StoreKit, and the MainActor queue, and whether that path is closed in the app after the fix, could not be reproduced.
