Repository navigation
A cancellation release for SwiftyPromise. Before this version, Sources/SwiftyPromise.swift never once looked at isCancelled, so a chain had no idea the operations backing it had been cancelled — while SwiftyThreadPool cancels every operation in its queue the moment the app takes a memory warning. Cancellation now produces an error the caller can see, instead of a silently wrong result or a permanent hang.
Scope: GitHub issues #3, #4 and #5, plus two value() defects that no issue recorded — the hang path in the second entry under Fixed, which is the most serious defect in this release, and the discarded-error paths in the third.
There are no breaking changes. Every existing signature is unchanged; the behaviour changes are confined to paths that previously had no contract worth depending on.
Added
PromiseError— the errors the promise machinery produces itself, as distinct from the errors your own task bodies throw. It ispublic enum PromiseError: Error, Sendable, Equatable, with two cases:.cancelled— the operation backing a task was cancelled before its block started running. The usual source isSwiftyThreadPool.removeAll(), which the shared pool calls for you when a memory warning arrives..noValue— the chain settled without any task ever having calledupdate(...), so there is no value to hand back.Promise.value()never throws this: it returnsnilin that situation, as it always has. The only producer in the library isUIImage.colors(), where a missing value can only mean the image was deallocated before the analysis began.
UIImage.colors() async throws -> (background: UIColor, primary: UIColor, secondary: UIColor, detail: UIColor)— anasyncvariant of the existing callback-basedcolors(_:). Same analysis, same four colours, in the same order. Two things differ: failure and cancellation are thrown rather than swallowed, and the result arrives on whatever context you awaited from — the callback version hops to the main queue first, this one has no such hop and needs none. The callback version's signature and behaviour are untouched — see thecolors(_:)entry under Changed for why you might want the new one.
Fixed
- A cancelled task now fails its chain with
PromiseError.cancelled. (issue #3) A cancelled operation does not run its block, but it does run its completion block — and the completion block is exactly wherefire()attaches its "advance to the next step" logic. So the chain advanced as though nothing had happened: it skipped the cancelled step, carried on with whatever value the previous step had left in place, ran every remaining step against that stale value, and settled successfully. Nothing in the caller-visible outcome distinguished this from a chain that ran in full. Cancellation is now detected in that same completion block and routed through the existing error path: the remaining tasks are discarded,alwaystasks still run, and yourcatch(_:)handler — or an outstandingvalue()— receives.cancelled.
Note thatstart()still gives the error nowhere to go: it registers no handler by design, so a cancelled chain terminated withstart()simply stops. Terminate withcatch(_:)orvalue()if you need to hear about it. The same is true of analwaystask of your own that gets cancelled: it is silently skipped, because by the timealwaystasks run the chain has already settled and there is no error path left to put anything into. try await promise.value()no longer hangs forever when the chain is cancelled. This one is not in issue #3 — it was found while implementing the fix above, and it was introduced by 2.0's ownasyncbridge, so it has only ever existed in 2.0.0.value()is built on top ofalways: it appends analwaystask whose entire job is to resume the continuation. In 2.0,fire()dispatchedalwaystasks with no completion block at all, so a cancelled one simply never ran and never resumed anything. Theawaitwas left suspended with no timeout, and it does not respond to SwiftTaskcancellation either (it never has — that is documented). A single memory warning was enough to reach it, since the shared pool cancels its whole queue when one arrives.alwaystasks now carry the same cancellation-aware completion block as chain tasks, and the onevalue()installs resumes the continuation with the chain's final value. It deliberately does not throw.cancelledthere.alwaystasks are dispatched once the chain has settled — by erroring as much as by succeeding — but every error path callsmarkError, and so resumes the continuation, strictly before that dispatch. So if the chain errored, theawaithas already been given the error and this cancellation can no longer change the outcome; and if it has not been resumed by then, the chain succeeded and the result is computed and correct. Throwing.cancelledwould discard a finished result to report a cancellation of nothing but the library's own bookkeeping, which is exactly the mistake theisCancelled && !didRunrule under Changed exists to prevent. (2.0 suspended permanently in this situation, so there is no behaviour anyone can have depended on.)
This does not makevalue()hang-proof, and it should not be read as "cancellation is solved". Cancelling an operation does not by itself end it — the queue ending it is what triggers the completion block — so the fix holds only while the queue can still drain. There are three known cases in which it still will not return, andvalue()'s documentation comment lists them too:- the pool was
stop()ped and neverrestart()ed, so the queue is not running at all; - the pool was
cancel()ed at any point before the chain's next background task was dispatched —cancel()drops the internal queue, andaddsilently discards everything after that, so that task never enters a queue and never gets a completion block. This is not limited to a chain that was already doomed when it started: acancel()landing mid-chain strands whatever step comes next; - the pool was
removeAll()ed orcancel()ed while suspended — a suspended queue finishes nothing, cancelled operations included, so no completion block fires. AremoveAll()in this state is at least recoverable:restart()resumes the queue, it finishes the cancelled operations, and the completion blocks fire.cancel()is not. It sets the internal queue tonil, andrestart()iscurrentQueue?.isSuspended = false— a no-op onnil. Nothing can bring that queue back, and everyawaitwaiting on it stays suspended for the life of the process.
Case 3 is reachable in ordinary use — an app that callsSwiftyThreadPool.default.stop()when it goes to the background and then takes a memory warning hangs every in-flightawait. Makingcancel()/stop()drain the queue, or givingvalue()a deadline, is a behaviour change beyond this release's scope. It is stated here rather than fixed, and is tracked as #6.
- the pool was
try await promise.value()no longer discards an error the chain has already raised. Two separate paths lost one.value()registers two things when it is called: analwaystask that will resume the continuation, and an internal error handler that will resume it by throwing. Those were two independent critical sections, so a chain that errored in between them foundinternalErrorHandlerstillnil— the error reached nobody, andfire()then ran thealwaystask and resumed theawaitwith whatever value the chain was holding. The caller got a plausible-looking result from a chain that had failed. The window is narrow, but it widens under contention on the state queue, and reaching it needs nothing exotic: any second terminator that has already put the chain in flight will do (start()thenvalue(),catch(_:)thenvalue(), two concurrentvalue()calls). The second path was simpler: callingvalue()on a chain that had already settled with an error returned the chain's last value, because the error object was never retained anywhere. Both are fixed by the same change — the terminated check, the errored check, thealwaysappend and the handler registration now happen inside a single critical section, and the error that terminated a chain is kept, so a latevalue()can rethrow it. This predates 2.1 (the ordering is unchanged since 2.0.0) and no issue recorded it, but it is a silently-wrong-result path inside the one method this release rewrote, which is the class of defect the release exists to remove.- Appending
thenoralwaysto a chain that has already settled now trips an assertion in debug builds. (issue #4) APromiseis single-use: oncestart(),catch(_:)orvalue()has terminated it and the chain has finished,fire()never drains those queues again, so anything appended afterwards can never run. In 2.0 that was completely silent — no error, no return value, no log; the task just went into a queue nobody would ever look at again. The "has it settled?" check and the append now happen inside a single critical section (so a chain settling concurrently cannot be slipped past), and a rejected append callsassertionFailure. Release builds keep 2.0's no-op behaviour, and both methods still returnself, so chaining syntax is unaffected.
Rejected appends were deliberately not made to execute immediately instead. That would stopisFinishedbeing a terminal state, which the whole design uses as a one-way latch, and an immediately executedthenthat threw would have no one left to receive the error — trading one silent drop for another.
Changed
- Cancellation is detected as
isCancelled && !didRun, notisCancelledalone.BlockOperation's block does not check for cancellation, so a cancel that lands while a task is executing does not stop it — that work runs to completion. Treating it as cancelled would throw away a perfectly good result. So the rule is: cancel means the chain fails, unless the work had already finished, in which case the chain carries on with it. Each task carries a small lock-guarded flag its block sets on entry; Foundation runs the completion block after the operation ends, so the flag read there always reflects whether the block ran. UIImage.colors(_:)— the callback version — does not call your callback if the analysis fails or is cancelled. This has been true since 2.0, and it was written down nowhere — the user-visible failure is a spinner that never stops. The mechanism has changed underneath, though the outcome has not: in 2.0 a cancelled chain raised no error at all, and the callback was skipped because the step that would have invoked it found no value to pass on; from 2.1 cancellation is an error, and the bare.catch()this method ends in discards it. It is now documented on the method, andcolors() async throwsexists for callers who need failure and cancellation reported rather than swallowed. Be precise about what that buys, though: theasyncvariant isPromise.value()plus a nil check, so it inherits all three ofvalue()'s hang cases listed above, case 3 included. It converts silence into a thrown error; it does not guarantee theawaitreturns. A caller who switches to it and still needs a spinner to stop must impose its own deadline.- Compatibility. Cancellation now produces an error where it previously produced a silently wrong result (a chain that "succeeded" on stale data) or an unrecoverable hang (
value()). Neither of those was a contract anyone could reasonably have depended on, so nothing here is treated as breaking. What can change for existing code: acatch(_:)handler that used to never fire on a memory warning may now fire withPromiseError.cancelled, and a chain that used to settle "successfully" with stale data now fails. Both are the point of the release. Debug builds will additionally trap onthen/alwaysappended after termination — code that was doing that was already dead code; check for it before shipping a debug build to testers. - Repository tooling (issue #5).
Scripts/test.shandScripts/uitest.shused to take whatever the last available iPhone simulator happened to be, which makes the colour-extraction tests (sensitive to screen scale) and the geometry assertions (sensitive to window size) depend on what a given machine has installed. Both now pin a model — overridable with theSWIFTYUI_SIMULATORenvironment variable, falling back to any available iPhone with a warning — and print the device they actually selected, so a failure that will not reproduce starts with a known machine.Scripts/check-uitest-restore.shwas updated to use the identical resolution logic: it verifies the simulator preferenceuitest.shhas to change is restored afterwards, and with a different rule it could have been checking a deviceuitest.shnever touched.