0

I have a task aggregator which aggregates and executes tasks in a synchronized manner. It's thread safe and all that. Here's what it looks like

class DaddysMagicalTaskAggregator {
  private let tasks: ThreadSafeValueContainer<[KewlTask]>
  private let cancelled: ThreadSafeValueContainer<Bool>
  private var completion: ((Result<Bool, Error>) -> Void)?
  private var alreadyCompleted: Result<Bool, Error>?

  private var getTasks: [KewlTask] {
    return tasks.value ?? []
  }

  private var isCancelled: Bool {
    return cancelled.value ?? true
  }

  init(queue: DispatchQueue = DispatchQueue(label: "DaddysMagicalTaskAggregator")) {
    self.tasks = ThreadSafeValueContainer(value: [], queue: queue)
    self.cancelled = ThreadSafeValueContainer(value: false, queue: queue)
  }

  /// Add a task to the list of tasks
  func addTask(_ task: KewlTask) -> DaddysMagicalTaskAggregator {
    self.tasks.value = getTasks + [task]
    return self
  }

  /// Add tasks to the list of tasks
  func addTasks(_ tasks: [KewlTask]) -> DaddysMagicalTaskAggregator {
    self.tasks.value = getTasks + tasks
    return self
  }
  
  /// Start executing tasks
  @discardableResult
  func run() -> DaddysMagicalTaskAggregator {
    guard !isCancelled else {
      return self
    }
    
    guard !getTasks.isEmpty else {
      alreadyCompleted = .success(true)
      completion?(.success(true))
      return self
    }
    
    var currentTasks = getTasks
    let taskToExecute = currentTasks.removeFirst()
    self.tasks.value = currentTasks
    
    taskToExecute.execute { (result) in
      switch result {
      case .success:
        self.run()
      case.failure(let error):
        self.taskFailed(with: error)
      }
    }
    return self
  }
  
  private func taskFailed(with error: Error) {
    tasks.value = []
    alreadyCompleted = .failure(error)
    completion?(.failure(error))
    completion = nil
  }
  
  /// Add a completion block which executes after all the tasks have executed or upon failing a task.
  func onCompletion(_ completion: @escaping (Result<Bool, Error>) -> Void) {
    if let result = alreadyCompleted {
      completion(result)
    } else {
      self.completion = completion
    }
  }
  
  /// Cancel all tasks
  func cancelAllTasks(with error: Error) {
    cancelled.value = true
    taskFailed(with: error)
  }
  
}

public class KewlTask {
  private let closure: ((KewlTask) -> Void)
  private var completion: ((Result<Bool, Error>) -> Void)?
  
  public init(_ closure: @escaping (KewlTask) -> Void) {
    self.closure = closure
  }
  
  public func execute(_ completion: @escaping (Result<Bool, Error>) -> Void) {
    self.completion = completion
    closure(self)
  }
  
  /// Task succeeded
  func succeeded() {
    completion?(.success(true))
  }
  
  /// Task failed with given error
  func failed(with error: Error) {
    completion?(.failure(error))
  }
  
  /// Take action based on the result received
  func processResult(_ result: Result<Bool, Error>) {
    switch result {
    case .success:
      succeeded()
    case .failure(let error):
      failed(with: error)
    }
  }
  
}

public class ThreadSafeContainer {
  fileprivate let queue: DispatchQueue
  
  public init(queue: DispatchQueue) {
    self.queue = queue
  }
  
}

public class ThreadSafeValueContainer<T>: ThreadSafeContainer {
  private var _value: T
  
  public init(value: T, queue: DispatchQueue) {
    self._value = value
    super.init(queue: queue)
  }
  
  public var value: T? {
    get {
      return queue.sync { [weak self] in
        self?._value
      }
    }
    set(newValue) {
      queue.sync { [weak self] in
        guard let newValue = newValue else { return }
        self?._value = newValue
      }
    }
  }
  
}

It works as expected. However, when I write a test to make sure it deallocates, the test keeps failing even after the expectation is fulfilled.

Please look at the test code below

import XCTest

class AggregatorTests: XCTestCase {
 
  func testTaskAggregatorShouldDeallocatateUponSuccess() {
    
    class TaskContainer {
      let aggregator: CustomKewlAggregator
      
      init(aggregator: CustomKewlAggregator = CustomKewlAggregator()) {
        self.aggregator = aggregator
      }
    }

    class CustomKewlAggregator: DaddysMagicalTaskAggregator {
      var willDeinit: (() -> Void)?
      
      deinit {
        willDeinit?()
      }
    }
    
    let myExpecation = self.expectation(description: "Task Aggregator should deallocate")
    var container: TaskContainer? = TaskContainer()
    
    let t1 = KewlTask { t in
        t.succeeded()
    }
    
    let t2 = KewlTask {
      $0.succeeded()
    }
    
    container?.aggregator.willDeinit = {
      myExpecation.fulfill()
    }
    
    container?.aggregator
      .addTasks([t1, t2])
      .run()
      .onCompletion { (result) in
        container = nil
      }
    
    waitForExpectations(timeout: 4, handler: nil)
  }
}

I've added breakpoints and everything to ensure the expectation fulfillment code does execute.

It doesn't seem to be an xCode issue since I've tested it on XCode 11.7, 12.1, 12.2, and 12.4.

Any idea what's going on here? To me, it looks like a bug in XCTests.

Singh Raman
  • 103
  • 1
  • 5
  • "the test keeps failing even after the expectation is fulfilled" - how do they fail? – Cristik Jun 26 '21 at 17:52
  • Asynchronous wait failed: Exceeded timeout of 4 seconds, with unfulfilled expectations: "Task Aggregator should deallocate". – Singh Raman Jun 26 '21 at 18:13
  • Are you sure the expectation is fulfilled before those 4 seconds? – Cristik Jun 26 '21 at 18:35
  • This probably isn't your problem, but `addTask` and `addTasks` aren't thread safe since they fetch the array and then append an item and this isn't an atomic operation. – Paulw11 Jun 26 '21 at 23:00
  • What code is in `self.expectation`? How is the local `myExpectation` accessed by `waitForExpectations`? – Paulw11 Jun 26 '21 at 23:09

0 Answers0