How a canceled Task still ran to the end in a review prompt
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, Google Play, and the web.
AppStore.requestReview(in:) 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.
fire, which does the work, does not keep the scene from the time of scheduling. It reads presenter.view.window?.windowScene right before running.
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.
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.
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 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.sleepinside a task that is already canceled givesCancellationError. - Canceling in the middle of a 30-second wait also gives
CancellationError. - Canceling after
Task.sleephas returned normally lets the next statement run.Task.isCancelledis 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 givesCancellationError.
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.
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.
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, suspending functions such as delay check for cancellation when they suspend. The API documentation of delay 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 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.
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.
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, 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, 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.
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.