Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Operation went isFinished=YES without being started by the queue it is in

Overview

  • There is an asynchronous operation subclass
  • Added this operation to the queue.
  • I cancelled this operation before it starts.

Runtime Error / Warning:

SomeOperation went isFinished=YES without being started by the queue it is in

Question:

  1. Is this something that can be ignored or it is something serious ?
  2. How to resolve this ?
  3. Is the workaround / solution provided at the end valid ?

Code:

public class SomeOperation : AsyncOperation {

    //MARK: Start

    public override func start() {

        isExecuting = true

        guard !isCancelled else {
            markAsCompleted() //isExecuting = false, isFinished = true
            return
        }

        doSomethingAsynchronously { [weak self] in

            self?.markAsCompleted() //isExecuting = false, isFinished = true
        }
    }

    //MARK: Cancel

    public override func cancel() {

        super.cancel()
        markAsCompleted() //isExecuting = false, isFinished = true
    }
}

Adding to Queue and cancelling:

//someOperation is a property in a class
if let someOperation = someOperation {
    queue.addOperation(someOperation)
}

//Based on some condition cancelling it
someOperation?.cancel()

Is this valid a solution ?

public override func cancel() {

    isExecuting = true //Just in case the operation was cancelled before starting

    super.cancel()
    markAsCompleted()
}

Note:

  • markAsCompleted sets isExecuting = false and isFinished = true
  • isExecuting, isFinished are properties which are synchronised KVO
like image 647
user1046037 Avatar asked Dec 03 '22 20:12

user1046037


1 Answers

The key problem is that your markAsCompleted is triggering isFinished when the operation is not isExecuting. I'd suggest you just fix that markAsCompleted to only do this if isExecuting is true. This reduces the burden on subclasses doing any complicated state tests to figure out whether they need to transition to isFinished or not.

That having been said, I see three basic patterns when writing cancelable asynchronous operations:

  1. If I'm dealing with some pattern where the canceling of the task will prevent it from transitioning executing operations to a isFinished state.

    In that case, I must have cancel implementation manually finish the executing operations. For example:

    class FiveSecondOperation: AsynchronousOperation {
        var block: DispatchWorkItem?
    
        override func main() {
            block = DispatchWorkItem { [weak self] in
                self?.finish()
                self?.block = nil
            }
    
            DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: block!)
        }
    
        override func cancel() {
            super.cancel()
    
            if isExecuting {
                block?.cancel()
                finish()
            }
        }
    }
    

    Focusing on the cancel implementation, because if I cancel the DispatchWorkItem it won't finish the operation, I therefore need to make sure that cancel will explicitly finish the operation itself.

  2. Sometimes, when you cancel some asynchronous task, it will call its completion handler automatically for you, in which case cancel doesn't need to do anything other than cancel the that task and call super. For example:

    class GetOperation: AsynchronousOperation {
        var url: URL
        weak var task: URLSessionTask?
    
        init(url: URL) {
            self.url = url
            super.init()
        }
    
        override func main() {
            let task = URLSession.shared.dataTask(with: url) { data, _, error in
                defer { self.finish() }  // make sure to finish the operation
    
                // process `data` & `error` here
            }
            task.resume()
            self.task = task
        }
    
        override func cancel() {
            super.cancel()
            task?.cancel()
        }
    }
    

    Again, focusing on cancel, in this case we don't touch the "finished" state, but just cancel dataTask (which will call its completion handler even if you cancel the request) and call the super implementation.

  3. The third scenario is where you have some operation that is periodically checking isCancelled state. In that case, you don't have to implement cancel at all, as the default behavior is sufficient. For example:

    class DisplayLinkOperation: AsynchronousOperation {
        private weak var displayLink: CADisplayLink?
        private var startTime: CFTimeInterval!
        private let duration: CFTimeInterval = 2
    
        override func main() {
            startTime = CACurrentMediaTime()
            let displayLink = CADisplayLink(target: self, selector: #selector(handleDisplayLink(_:)))
            displayLink.add(to: .main, forMode: .commonModes)
            self.displayLink = displayLink
        }
    
        @objc func handleDisplayLink(_ displayLink: CADisplayLink) {
            let percentComplete = (CACurrentMediaTime() - startTime) / duration
    
            if percentComplete >= 1.0 || isCancelled {
                displayLink.invalidate()
                finish()
            }
    
            // now do some UI update based upon `elapsed`
        }
    }
    

    In this case, where I've wrapped a display link in an operation so I can manage dependencies and/or encapsulate the display link in a convenient object, I don't have to implement cancel at all, because the default implementation will update isCancelled for me, and I can just check for that.

Those are three basic cancel patterns I generally see. That having been said, updating markAsCompleted to only trigger isFinished if isExecuting is a good safety check to make sure you can never get the problem you described.


By the way, the AsynchronousOperation that I used for the above examples is as follows, adapted from Trying to Understand Asynchronous Operation Subclass. BTW, what you called markAsCompleted is called finish, and it sounds like you're triggering the isFinished and isExecuting KVO via a different mechanism, but the idea is basically the same. Just check the current state before you trigger isFinished KVO:

open class AsynchronousOperation: Operation {

    /// State for this operation.

    @objc private enum OperationState: Int {
        case ready
        case executing
        case finished
    }

    /// Concurrent queue for synchronizing access to `state`.

    private let stateQueue = DispatchQueue(label: Bundle.main.bundleIdentifier! + ".rw.state", attributes: .concurrent)

    /// Private backing stored property for `state`.

    private var rawState: OperationState = .ready

    /// The state of the operation

    @objc private dynamic var state: OperationState {
        get { return stateQueue.sync { rawState } }
        set { stateQueue.sync(flags: .barrier) { rawState = newValue } }
    }

    // MARK: - Various `Operation` properties

    open         override var isReady:        Bool { return state == .ready && super.isReady }
    public final override var isExecuting:    Bool { return state == .executing }
    public final override var isFinished:     Bool { return state == .finished }
    public final override var isAsynchronous: Bool { return true }

    // MARK: - KVN for dependent properties

    open override class func keyPathsForValuesAffectingValue(forKey key: String) -> Set<String> {
        if ["isReady", "isFinished", "isExecuting"].contains(key) {
            return [#keyPath(state)]
        }

        return super.keyPathsForValuesAffectingValue(forKey: key)
    }

    // MARK: - Foundation.Operation

    public final override func start() {
        if isCancelled {
            state = .finished
            return
        }

        state = .executing

        main()
    }

    /// Subclasses must implement this to perform their work and they must not call `super`. The default implementation of this function throws an exception.

    open override func main() {
        fatalError("Subclasses must implement `main`.")
    }

    /// Call this function to finish an operation that is currently executing

    public final func finish() {
        if isExecuting { state = .finished }
    }
}
like image 94
Rob Avatar answered May 07 '23 21:05

Rob