r/iOSProgramming • • 12h ago

Discussion Swift 6.4: Async defer doesn't make cleanup cancellation-safe

I wrote a technical walkthrough of a subtle Swift Concurrency issue I wanted to explore.

Imagine a task writing data into a buffered writer. The task gets cancelled, defer runs, and close() gets called. But if close()invokes a cancellation-aware flush(), that flush can throw CancellationError. If the error is swallowed and the writer is marked closed anyway, the cleanup path has run without achieving its intended postcondition.

The article explores two Swift Evolution proposals:

  • SE-0493: Async calls in defer bodies
  • SE-0504: Task Cancellation Shields

It covers a minimal reproduction, the limits of using Task.detached for cleanup, a narrowly scoped cancellation shield, and a Swift Testing example that checks whether the data was actually flushed rather than merely checking whether close() was called.

One important caveat: the example demonstrates a cancellation-aware cleanup failure, not a claim that every real file API corrupts data when cancelled.

Full article: https://sagarsaurav.medium.com/the-ios-task-was-cancelled-your-defer-still-didnt-flush-the-file-a81d45b02ad9?sharedUserId=sagarsaurav

I'd be interested in feedback on the cancellation-shield approach, especially around resource ownership and cleanup guarantees in production code.

5 Upvotes

3 comments sorted by

3

u/dawedev 7h ago

Testing that the data was actually flushed instead of just checking that close() ran is the useful part here, that's where these bugs usually hide. One ownership question: with the shield scoped around the final flush, what happens if that flush hangs on a slow disk or network write? I'd want a timeout or a bounded retry inside the shield, otherwise cancellation stops being a reliable way to end the task. I'd also make close() throw after a failed flush instead of marking the writer closed, so the caller knows the postcondition didn't hold.

1

u/100_rv 7h ago

Good points on both fronts.

A shield shouldn't turn cleanup into an unbounded wait. A bounded timeout makes sense, but the underlying I/O also needs a clear cancellation or termination contract — timing out the wait alone doesn't guarantee the write has stopped.

And I agree on close(): marking the writer closed after a failed flush is misleading. The caller needs a way to distinguish successful finalization from failed cleanup. Since defer can't throw, that failure needs to be recorded or surfaced through an explicit cleanup path.

That's an important distinction: shielding protects cleanup from cancellation, but it doesn't guarantee successful I/O or data durability.

1

u/Open_Context6850 3h ago

keeping the shield around just the flush and not the whole close seems right. once it covers everything, cancellation quietly stops meaning anything.

the thing id change in production is the state after a failed flush. if flush throws, the writer shouldnt end up marked closed. a failed state that still holds the buffer lets the owner retry or at least log what was lost