Test bounded iOS task scheduling on a cloud Mac
When processing twenty attachments in a batch, putting every task into a task group and reading the results one by one does not limit concurrency. Network connections, decoding buffers, and temporary files can still grow at the same time. When validating this logic on a cloud Mac, the goal is not to show how fast it runs. It is to prove that, with a limit of three, the fourth operation must wait for a free slot.
Here, we separate the scheduler from the networking implementation. First, use controllable operations to verify slot behavior, then connect the scheduler to your iOS application code. This lets you distinguish scheduling errors from external service instability without repeatedly rerunning tests in search of a conclusion.
Define a testable scheduling contract first
Output must follow input order, even if operations finish in a different order. Empty input returns an empty array, and the concurrency limit must be greater than zero. If any child task throws, the entire call fails without returning partial results. Once the parent task is cancelled, the scheduler should not actively submit more pending work.
The limit applies to the number of active operations passed to the scheduler—not to thread count or request rate. If an operation starts multiple requests internally, those requests are outside the outer scheduler’s count. A per-second request quota needs a separate implementation; concurrency slots are not a substitute.
Cancellation is a cooperative signal, not forced termination. A task group still waits for its child tasks to finish before leaving its scope. An operation that ignores cancellation will hold up the entire call.
Refill a slot when an operation completes
Save the following code as BoundedMap.swift. Run it on a cloud Mac with a Swift toolchain installed, first recording the version with swift --version. The example uses the Sendable constraints required for strict concurrency and does not depend on a graphical session or simulator.
import Foundation
func boundedMap<Input: Sendable, Output: Sendable>(
_ inputs: [Input],
limit: Int,
operation: @escaping @Sendable (Input) async throws -> Output
) async throws -> [Output] {
precondition(limit > 0)
return try await withThrowingTaskGroup(
of: (Int, Output).self
) { group in
var nextIndex = 0
var completed: [(Int, Output)] = []
completed.reserveCapacity(inputs.count)
for index in 0..<min(limit, inputs.count) {
try Task.checkCancellation()
let input = inputs[index]
group.addTask {
try Task.checkCancellation()
return (index, try await operation(input))
}
nextIndex += 1
}
while let result = try await group.next() {
completed.append(result)
try Task.checkCancellation()
if nextIndex < inputs.count {
let index = nextIndex
let input = inputs[index]
group.addTask {
try Task.checkCancellation()
return (index, try await operation(input))
}
nextIndex += 1
}
}
try Task.checkCancellation()
return completed.sorted { $0.0 < $1.0 }.map { $0.1 }
}
}
The initial submission is capped at the limit. After that, each completed result allows one new task to be submitted. A long input therefore does not create an equally long queue of waiting tasks. Each result carries its index so the final step can restore input order, rather than mistaking completion order for output order.
Errors propagate through group.next(). When the closure exits by throwing, the task group cancels the remaining child tasks and waits for them to finish. This implementation neither catches errors and keeps submitting work nor uses independent background tasks detached from the parent’s lifetime.
Measure active work with an actor probe
Append the following code to the same file, then run swift BoundedMap.swift. The probe increments its counter when an operation enters and decrements it when the operation exits. While an operation is sleeping, the actor can handle other calls, so it does not accidentally serialize all the work.
actor Probe {
private var active = 0
private var peak = 0
func run(_ value: Int) async throws -> Int {
active += 1
peak = max(peak, active)
defer { active -= 1 }
let delay = UInt64(5 - value % 5) * 10_000_000
try await Task.sleep(nanoseconds: delay)
return value * 2
}
func snapshot() -> (active: Int, peak: Int) {
(active, peak)
}
}
let probe = Probe()
let inputs = Array(0..<20)
let output = try await boundedMap(inputs, limit: 3) {
try await probe.run($0)
}
let state = await probe.snapshot()
precondition(output == inputs.map { $0 * 2 })
precondition(state.active == 0)
precondition(state.peak <= 3)
The different delays are there to vary completion order, not to benchmark performance. Do not assert that a particular task must finish at a particular millisecond, or treat a peak below three on a busy machine as a failure. The required guarantees are that the peak stays within the limit, the results are complete, and the active count returns to zero after the call exits.
Also run separate checks with an empty array, a single element, and a limit of one. If the caller reads the limit from a configuration file, reject zero and negative values first. The example’s precondition is suitable for exposing programming errors, not for handling user input directly.
Validate failure and cancellation separately
A successful test does not prove that cancellation works correctly. First, create an operation that always throws for a specific input, then assert that the call fails and the probe’s active count eventually returns to zero. Do not assert an exact number of tasks started before failure: other tasks may finish and trigger slot refills before the error reaches the task group.
For cancellation tests, use a controllable gate. Have the first batch of operations report that they have entered. Once the test receives those signals, cancel the parent task, then release the gate. Each operation should check for cancellation after passing the gate. The test then waits for the parent task to finish and verifies that every operation that entered has also exited.
Do not use a fixed sleep to guess whether tasks have started. The gate itself must be able to release every waiter; otherwise, the test may mistake a deadlocked fixture for a stalled scheduler. The validation report should distinguish the time cancellation was requested, the time operations exited, and the time the parent task returned.
Preserve slot boundaries when integrating application code
For network downloads, keep the download, any necessary decoding, and temporary-file cleanup within the same operation scope. If you release the slot as soon as a response arrives but hand large-image decoding to a background task, network concurrency may be bounded while peak memory usage remains uncontrolled.
This implementation retains every output, so its storage requirements grow with the total size of the results. For large files, prefer returning file locations and small summaries rather than loading all file contents into an array. A pipeline that consumes results as they complete needs a separate output backpressure contract. Do not claim that this implementation uses constant memory.
Finally, retain the toolchain version, test inputs, limit, peak active count, and operation exit count. Put the scheduler tests in a regular unit test target, then add cancellation and resource-release checks for the real operations. Once you can consistently demonstrate the three boundaries—slots, ordering, and cleanup—you have a reproducible behavioral baseline for moving to another cloud Mac.
Frequently asked questions
Does delaying each task limit concurrency?
No. Staggering start times does not bound active work. Submit only the permitted number of tasks, then add one whenever a task completes.
Does a cancelled task group return immediately?
Not necessarily. A structured task group waits for its children to finish. Operations must cooperate with cancellation and stop their underlying work.
Need a dedicated Mac for your next build?
Choose a rental term that fits your needs. Get exclusive use of a physical Mac with full access to the macOS GUI and command line. Models, locations, and availability are shown live in the console.