An image-processing workflow downloads an image, converts it, then writes the output to a directory. Yet the conversion still gets scheduled after the download fails, or files keep being written after the user taps Cancel. These symptoms usually do not mean the queue is broken. They come from treating dependency completion, business success, and cancellation requests as the same thing. This article uses a standalone Swift program on a cloud Mac to reproduce those boundaries, then moves the checks into an iOS test target. No network access or signing credentials are required.
First, define what the queue does not handle
Dependencies in OperationQueue require only that prerequisite operations reach the finished state. The queue does not understand business outcomes such as a failed download or conversion. Likewise, cancel() sets a cancellation flag; it does not forcibly stop a running closure.
Define four terminal outcomes for each business step: success, failure, skipped, and cancelled. Record a step as skipped when a dependency fails. If a step has already started, have it exit at a safe boundary after receiving a cancellation request. Do not use a single Boolean to represent both “did not run” and “ran but failed.”
isFinishedmeans the operation’s lifecycle has ended, not that the business step succeeded.isCancelledmeans cancellation was requested, not that the work has stopped.
This example uses only synchronous BlockOperation instances. If a closure starts an asynchronous request and returns immediately, the queue considers the closure complete. The dependencies shown here cannot then be used to wait for the request’s callback. Asynchronous operations need their own correctly implemented lifecycle states.
Use explicit outcomes to stop downstream work after failure
Save the following as QueueContract.swift. A lock protects reads and writes to the results, preventing data races if concurrent steps are added later. The example does not rely on an incidental scheduling order.
import Foundation
enum Outcome: Equatable {
case pending
case succeeded
case failed
case skipped
}
final class State: @unchecked Sendable {
private let lock = NSLock()
private var values: [String: Outcome] = [:]
func set(_ key: String, _ value: Outcome) {
lock.lock()
defer { lock.unlock() }
values[key] = value
}
func get(_ key: String) -> Outcome {
lock.lock()
defer { lock.unlock() }
return values[key] ?? .pending
}
}
let state = State()
let queue = OperationQueue()
queue.maxConcurrentOperationCount = 2
let download = BlockOperation {
state.set("download", .failed)
}
let convert = BlockOperation {
guard state.get("download") == .succeeded else {
state.set("convert", .skipped)
return
}
state.set("convert", .succeeded)
}
convert.addDependency(download)
queue.addOperations([convert, download], waitUntilFinished: true)
precondition(state.get("download") == .failed)
precondition(state.get("convert") == .skipped)
precondition(download.isFinished && convert.isFinished)
Compile and run it in Terminal:
xcrun swiftc QueueContract.swift -o QueueContract
./QueueContract
An exit code of zero means the assertions passed; there is no need to print a success message. Add a second case in which the download succeeds, and update the corresponding assertion to check that conversion succeeds as well. Together, these cover both the success and failure paths. @unchecked Sendable is only a manual promise of thread safety. That promise holds only if all access to shared state goes through the lock and the internal dictionary is never exposed.
Test cancellation before and during execution separately
First, verify cancellation of a task that has not started. Append the following snippet to the same file and compile it again. A separate queue keeps this case from affecting the previous one.
let heldQueue = OperationQueue()
heldQueue.isSuspended = true
let cancelledState = State()
let waiting = BlockOperation {
cancelledState.set("body", .succeeded)
}
heldQueue.addOperation(waiting)
waiting.cancel()
heldQueue.isSuspended = false
heldQueue.waitUntilAllOperationsAreFinished()
precondition(waiting.isCancelled)
precondition(waiting.isFinished)
precondition(cancelledState.get("body") == .pending)
Suspending the queue ensures cancellation happens before execution, without guessing at millisecond delays. The assertions check the cancellation flag, eventual completion, and the fact that the closure never ran. Omitting any one of these leaves a blind spot.
Use a separate case for a running task. Have the task send a “started” signal once it enters its processing loop. The test should wait for that signal before requesting cancellation, then allow the next iteration to proceed. Check isCancelled on every iteration, both before reading data and before committing results. Use timed waits for signals and fail the test on timeout rather than letting it hang indefinitely.
A closure that has not been scheduled when cancellation is requested will not record a terminal business outcome for you. The orchestration layer must record the cancellation instead. Do not make a closure that never ran responsible for cleanup.
Preserve the commit boundary when writing results
Cooperative cancellation takes effect only at checkpoints. If a conversion takes a long time, cancellation latency may span at least the current conversion step. Splitting expensive work into sensible batches is more useful than repeatedly polling in an empty loop.
Write files to a staging path dedicated to the task, then close the handles before committing the output. On failure or cancellation, remove only that task’s staging files, not shared directories. If cancellation arrives after a successful commit, keep the outcome as “success” rather than changing it retroactively to “cancelled.” Otherwise, callers may incorrectly assume that no output exists.
Cancellation and commit can happen at the same time. If the business rules require them to be strictly mutually exclusive, route both decisions through the same lock or serial state machine. Checking the flag just once before writing does not eliminate the race that can occur afterward.
Check all terminal outcomes in regression tests
When moving these assertions into XCTest, replace precondition with test assertions. Waiting for the queue to finish is fine in a command-line task on a cloud Mac. Do not wait synchronously on the app’s main thread, especially if a queued task might itself wait for a callback on that thread.
Each test run should cover at least these cases: prerequisite success, prerequisite failure, cancellation before execution, cancellation during execution, and cancellation after commit. For each case, check the terminal business outcome, whether output exists, and the number of completion notifications. Completion must be reported exactly once; both missing and duplicate callbacks should fail the test.
Use controlled signals to establish a deterministic order, not sleep to manufacture supposedly stable timing. The result is a repeatable contract: a failed dependency must not lead to an invalid commit, cancelled work must be able to finish, and cleanup must happen only after it has finished. Establish those guarantees before increasing concurrency.
Frequently asked questions
Does a failed prerequisite automatically skip its dependent operation?
No. A dependency only controls execution order. Publish a thread-safe result state and make the dependent operation explicitly skip its work when that state indicates failure.
Can I delete working files immediately after cancelAllOperations?
No. Running operations may still access them. Request cancellation, wait for cooperative completion off the main thread, and clean up afterward.
Get a Cloud Mac Ready for Your Next Build
Choose a model and rental term that fit your workload. Get exclusive access to a physical Apple Silicon node, not a virtual machine.
Rent a Cloud Mac