Skip to content

2.1.0 — Promise cancellation

Latest

Choose a tag to compare

@haoking haoking released this 28 Jul 21:06
· 6 commits to master since this release

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 is public enum PromiseError: Error, Sendable, Equatable, with two cases:
    • .cancelled — the operation backing a task was cancelled before its block started running. The usual source is SwiftyThreadPool.removeAll(), which the shared pool calls for you when a memory warning arrives.
    • .noValue — the chain settled without any task ever having called update(...), so there is no value to hand back. Promise.value() never throws this: it returns nil in that situation, as it always has. The only producer in the library is UIImage.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) — an async variant of the existing callback-based colors(_:). 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 the colors(_:) 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 where fire() 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, always tasks still run, and your catch(_:) handler — or an outstanding value() — receives .cancelled.
    Note that start() still gives the error nowhere to go: it registers no handler by design, so a cancelled chain terminated with start() simply stops. Terminate with catch(_:) or value() if you need to hear about it. The same is true of an always task of your own that gets cancelled: it is silently skipped, because by the time always tasks 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 own async bridge, so it has only ever existed in 2.0.0. value() is built on top of always: it appends an always task whose entire job is to resume the continuation. In 2.0, fire() dispatched always tasks with no completion block at all, so a cancelled one simply never ran and never resumed anything. The await was left suspended with no timeout, and it does not respond to Swift Task cancellation 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. always tasks now carry the same cancellation-aware completion block as chain tasks, and the one value() installs resumes the continuation with the chain's final value. It deliberately does not throw .cancelled there. always tasks are dispatched once the chain has settled — by erroring as much as by succeeding — but every error path calls markError, and so resumes the continuation, strictly before that dispatch. So if the chain errored, the await has 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 .cancelled would discard a finished result to report a cancellation of nothing but the library's own bookkeeping, which is exactly the mistake the isCancelled && !didRun rule 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 make value() 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, and value()'s documentation comment lists them too:
    1. the pool was stop()ped and never restart()ed, so the queue is not running at all;
    2. the pool was cancel()ed at any point before the chain's next background task was dispatched — cancel() drops the internal queue, and add silently 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: a cancel() landing mid-chain strands whatever step comes next;
    3. the pool was removeAll()ed or cancel()ed while suspended — a suspended queue finishes nothing, cancelled operations included, so no completion block fires. A removeAll() 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 to nil, and restart() is currentQueue?.isSuspended = false — a no-op on nil. Nothing can bring that queue back, and every await waiting on it stays suspended for the life of the process.
      Case 3 is reachable in ordinary use — an app that calls SwiftyThreadPool.default.stop() when it goes to the background and then takes a memory warning hangs every in-flight await. Making cancel() / stop() drain the queue, or giving value() a deadline, is a behaviour change beyond this release's scope. It is stated here rather than fixed, and is tracked as #6.
  • 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: an always task 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 found internalErrorHandler still nil — the error reached nobody, and fire() then ran the always task and resumed the await with 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() then value(), catch(_:) then value(), two concurrent value() calls). The second path was simpler: calling value() 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, the always append and the handler registration now happen inside a single critical section, and the error that terminated a chain is kept, so a late value() 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 then or always to a chain that has already settled now trips an assertion in debug builds. (issue #4) A Promise is single-use: once start(), catch(_:) or value() 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 calls assertionFailure. Release builds keep 2.0's no-op behaviour, and both methods still return self, so chaining syntax is unaffected.
    Rejected appends were deliberately not made to execute immediately instead. That would stop isFinished being a terminal state, which the whole design uses as a one-way latch, and an immediately executed then that threw would have no one left to receive the error — trading one silent drop for another.

Changed

  • Cancellation is detected as isCancelled && !didRun, not isCancelled alone. 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, and colors() async throws exists for callers who need failure and cancellation reported rather than swallowed. Be precise about what that buys, though: the async variant is Promise.value() plus a nil check, so it inherits all three of value()'s hang cases listed above, case 3 included. It converts silence into a thrown error; it does not guarantee the await returns. 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: a catch(_:) handler that used to never fire on a memory warning may now fire with PromiseError.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 on then / always appended 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.sh and Scripts/uitest.sh used 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 the SWIFTYUI_SIMULATOR environment 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.sh was updated to use the identical resolution logic: it verifies the simulator preference uitest.sh has to change is restored afterwards, and with a different rule it could have been checking a device uitest.sh never touched.