fix: crash in background with ExpiringActivity - WPB-23839#4402
fix: crash in background with ExpiringActivity - WPB-23839#4402
Conversation
Test Results86 tests 83 ✅ 2s ⏱️ Results for commit a281549. ♻️ This comment has been updated with latest results. Summary: workflow run #23857699663 |
|
Could/should this go into |
| continuation.resume(throwing: error) | ||
| finish(.failure(error)) | ||
| } | ||
| // Unblock the callback thread regardless. A double-signal (if the |
There was a problem hiding this comment.
Doesn't this mean we are not waiting until the task has finished "cancelling" anymore and we'll instead immediately exit function and let the app suspend.
Couldn't this happen:
- start expiring activity decrypting messages
- activity expires and signal to cancel the decryption task
- we exit the activity
- crash because the decryption task didn't finish cancelling and therefore didn't have time to release the transaction lock.
| func withExpiringActivity(reason: String, block: @escaping () async throws -> Void) async throws { | ||
| try await withTaskCancellationHandler { | ||
| try await withCheckedThrowingContinuation { continuation in | ||
| // Shared between both callback branches. |
There was a problem hiding this comment.
@David-Henner since you added withTaskCancellationHandler I wonder if the PR still makes sense
|



Issue
The app was being killed by the iOS watchdog with termination reason FRONTBOARD 0xBAADCA11 after running
for ~6 hours in the background. The crash was observed in TestFlight (Wire 4.16.0 / build 17993, iPhone
18,1, iOS 26.3).
0xBAADCA11 ("bad call") is the watchdog timer — iOS killed the app because it blocked a system-managed
thread indefinitely.
Root cause in ExpiringActivity.swift:
performExpiringActivity's callback must block its thread until the work is done (Apple API requirement).
The implementation used a DispatchSemaphore for this:
// expiring = false branch
Task { try await self.startWork(block: block, semaphore: semaphore).value ... }
semaphore.wait() // ← blocks the callback thread
// expiring = true branch
Task { try await self.stopWork() } // cancels the inner task
// ← semaphore is never signaled if block() ignores Task.cancel()
When expiring = true fires, stopWork() calls task.cancel(). If block() is a cooperative caller that
checks Task.checkCancellation(), the inner task exits and its defer { semaphore.signal() } fires
normally. But if block() does not cooperate with cancellation — which is a valid real-world scenario —
the inner task keeps running, semaphore.signal() is never called, and semaphore.wait() blocks the
callback thread indefinitely until the watchdog intervenes.
A secondary bug was also present: if expiring = true fires before the actor executes startWork (a race
condition), self.task is nil, stopWork() throws, and continuation.resume(throwing:) is called. When
startWork later runs and block() completes, continuation.resume() is called a second time — undefined
behaviour on CheckedContinuation.
Solution:
Two changes:
without relying on the inner task to do so:
This guarantees the callback thread is unblocked and withExpiringActivity returns to the caller
regardless of whether block() cooperates with cancellation.
ensures the continuation is resumed at most once. This eliminates the double-resume race and makes the
fix safe.
Testing
returns promptly on expiry even when block() loops with Task.yield() and never calls
Task.checkCancellation().
when the expiring = true callback is processed by the actor before startWork.
Checklist
[WPB-XXX].UI accessibility checklist
If your PR includes UI changes, please utilize this checklist: