Parse-Swift icon indicating copy to clipboard operation
Parse-Swift copied to clipboard

withThrowingTaskGroup causes crash

Open vdkdamian opened this issue 3 years ago • 7 comments
trafficstars

New Issue Checklist

Issue Description

When using withThrowingTaskGroup on a try await query.find(), the app crashes after a certain limit is reached.

This code works fine:

let query = PRLIcon.query()
                        .where(containedIn(key: "url", array: flatFullWebsiteList))
                        .limit(5)

But this code crashes the app

let query = PRLIcon.query()
                        .where(containedIn(key: "url", array: flatFullWebsiteList))
                        .limit(25)

My full code block:

    fileprivate func fetchImages() async throws -> ImageResult {
        let fileManager = FileManager.default
        let websiteListPath = getWebsiteFilesPath().appendingPathComponent("WebsiteList.json")
        var flatFullWebsiteList: [String] = []
        if fileManager.fileExists(atPath: websiteListPath.path) {
            do {
                let data: Data = try Data(contentsOf: websiteListPath)
                if !data.isEmpty {
                    let fullWebsiteList = try JSONSerialization.jsonObject(with: data, options: []) as! [String : [String]]
                    for userId in fullWebsiteList.keys {
                        if let userIdFullWebsiteList = fullWebsiteList[userId] {
                            flatFullWebsiteList.append(contentsOf: userIdFullWebsiteList)
                        }
                    }
                    let query = PRLIcon.query()
                        .where(containedIn(key: "url", array: flatFullWebsiteList))
                        .limit(5)
                    
                    do {
                        let icons = try await query.find()
                        
                        return try await withThrowingTaskGroup(of: ImageResult.self) { group -> ImageResult in
                            for icon in icons {
                                let websiteFolder = getWebsiteIconsPath().appendingPathComponent(icon.url!)
                                if !fileManager.fileExists(atPath: websiteFolder.path) {
                                    try fileManager.createDirectory(atPath: websiteFolder.path, withIntermediateDirectories: false, attributes: nil)
                                    
                                    group.addTask {
                                        try await self.downloadIcon(icon: icon, websiteFolder: websiteFolder, fileManager: fileManager)
                                        return .downloaded
                                    }
                                } else {
                                    let websiteFolderPeralFile = websiteFolder.appendingPathComponent("Peral.json")
                                    if let websiteFolderPeralFileData = try? Data(contentsOf: websiteFolderPeralFile) {
                                        let websiteFolderPeralFileJson = try JSONSerialization.jsonObject(with: websiteFolderPeralFileData, options: []) as! [String : String]
                                        if let localUpdatedAt = websiteFolderPeralFileJson["upatedAt"] {
                                            if let cloudUpdatedAtDate = icon.updatedAt {
                                                let cloudUpdatedAt = Formatter.nvmDateFormatterStatic.string(from: cloudUpdatedAtDate)
                                                if cloudUpdatedAt != localUpdatedAt {
                                                    group.addTask {
                                                        try await self.downloadIcon(icon: icon, websiteFolder: websiteFolder, fileManager: fileManager)
                                                        return .updated
                                                    }
                                                }
                                            }
                                        }
                                    } else {
                                        group.addTask {
                                            try await self.downloadIcon(icon: icon, websiteFolder: websiteFolder, fileManager: fileManager)
                                            return .downloaded
                                        }
                                    }
                                }
                            }
                            
                            var updatedIconCount: Int = 0
                            var downloadedIconCount: Int = 0
                            
                            for try await imageResult in group {
                                if imageResult == .updated {
                                    updatedIconCount = updatedIconCount + 1
                                } else if imageResult == .downloaded {
                                    downloadedIconCount = downloadedIconCount + 1
                                }
                            }
                            
                            if ((updatedIconCount > 0) && (downloadedIconCount > 0)) {
                                return .both
                            } else if (updatedIconCount > 0) {
                                return .updated
                            } else if (downloadedIconCount > 0) {
                                return .downloaded
                            } else {
                                return .none
                            }
                        }
                    } catch let queryError {
                        if !queryError.equalsTo(.objectNotFound) {
                            throw queryError
                        } else {
                            return .none
                        }
                    }
                } else {
                    return .none
                }
            } catch {
                throw error
            }
        } else {
            return .none
        }
    }
    
    fileprivate func downloadIcon(icon: PRLIcon, websiteFolder: URL, fileManager: FileManager) async throws {
        var peralDict: [String: String] = [:]
        
        
        let newPeralDict = try await withThrowingTaskGroup(of: [String : String]?.self) { group -> [String : String] in
            group.addTask {
                if let iconIcon = icon.iconImage,
                    let fetchedFile = try? await iconIcon.fetch(options: [.cachePolicy(.reloadIgnoringLocalCacheData)]) {
                    
                    var imageName = PRLWebsiteImageType.icon.rawValue
                    if let imageExtension = fetchedFile.name.fileExtension {
                        imageName = imageName.append(fileExtension: imageExtension)
                    } else {
                        imageName = imageName.append(fileExtension: PRLImageType.png.rawValue)
                    }
                    
                    if let fetchedFileURL = fetchedFile.localURL {
                        let websiteIcon = websiteFolder.appendingPathComponent(imageName)
                        
                        if fileManager.fileExists(atPath: websiteIcon.path) {
                            try? fileManager.removeItem(at: websiteIcon)
                        }
                        do {
                            try fileManager.moveItem(at: fetchedFileURL, to: websiteIcon)
                        } catch {
                            assertionFailure("Error writing Icon image: \(error)")
                        }
                    } else {
                        print("Error: couldn't get data from file.")
                    }
                    
                    return [PRLWesbiteInfoKey.iconNameExtension.rawValue : imageName]
                } else {
                    return nil
                }
            }
            group.addTask {
                if let iconQuick = icon.quickImage,
                    let fetchedFile = try? await iconQuick.fetch(options: [.cachePolicy(.reloadIgnoringLocalCacheData)]) {
                    
                    var imageName = PRLWebsiteImageType.quick.rawValue
                    if let imageExtension = fetchedFile.name.fileExtension {
                        imageName = imageName.append(fileExtension: imageExtension)
                    } else {
                        imageName = imageName.append(fileExtension: PRLImageType.png.rawValue)
                    }
                    
                    if let fetchedFileURL = fetchedFile.localURL {
                        let websiteIcon = websiteFolder.appendingPathComponent(imageName)
                        
                        if fileManager.fileExists(atPath: websiteIcon.path) {
                            try? fileManager.removeItem(at: websiteIcon)
                        }
                        do {
                            try fileManager.moveItem(at: fetchedFileURL, to: websiteIcon)
                        } catch {
                            assertionFailure("Error writing Quick image: \(error)")
                        }
                    } else {
                        print("Error: couldn't get data from file.")
                    }
                    
                    return [PRLWesbiteInfoKey.quickNameExtension.rawValue : imageName]
                } else {
                    return nil
                }
            }
            group.addTask {
                if let iconBackground = icon.backgroundImage,
                    let fetchedFile = try? await iconBackground.fetch(options: [.cachePolicy(.reloadIgnoringLocalCacheData)]) {
                    
                    var imageName = PRLWebsiteImageType.background.rawValue
                    if let imageExtension = fetchedFile.name.fileExtension {
                        imageName = imageName.append(fileExtension: imageExtension)
                    } else {
                        imageName = imageName.append(fileExtension: PRLImageType.png.rawValue)
                    }
                    
                    if let fetchedFileURL = fetchedFile.localURL {
                        let websiteIcon = websiteFolder.appendingPathComponent(imageName)
                        
                        if fileManager.fileExists(atPath: websiteIcon.path) {
                            try? fileManager.removeItem(at: websiteIcon)
                        }
                        do {
                            try fileManager.moveItem(at: fetchedFileURL, to: websiteIcon)
                        } catch {
                            assertionFailure("Error writing Background image: \(error)")
                        }
                    } else {
                        print("Error: couldn't get data from file.")
                    }
                    
                    return [PRLWesbiteInfoKey.backgroundNameExtension.rawValue : imageName]
                } else {
                    return nil
                }
            }
            group.addTask {
                if let iconCard = icon.cardImage,
                    let fetchedFile = try? await iconCard.fetch(options: [.cachePolicy(.reloadIgnoringLocalCacheData)]) {
                    
                    var imageName = PRLWebsiteImageType.card.rawValue
                    if let imageExtension = fetchedFile.name.fileExtension {
                        imageName = imageName.append(fileExtension: imageExtension)
                    } else {
                        imageName = imageName.append(fileExtension: PRLImageType.png.rawValue)
                    }
                    
                    if let fetchedFileURL = fetchedFile.localURL {
                        let websiteIcon = websiteFolder.appendingPathComponent(imageName)
                        
                        if fileManager.fileExists(atPath: websiteIcon.path) {
                            try? fileManager.removeItem(at: websiteIcon)
                        }
                        do {
                            try fileManager.moveItem(at: fetchedFileURL, to: websiteIcon)
                        } catch {
                            assertionFailure("Error writing Card image: \(error)")
                        }
                    } else {
                        print("Error: couldn't get data from file.")
                    }
                    
                    return [PRLWesbiteInfoKey.cardNameExtension.rawValue : imageName]
                } else {
                    return nil
                }
            }
            
            var newPeralDict: [String: String] = [:]
            
            for try await imageName in group {
                if let imageName = imageName {
                    newPeralDict.merge(dict: imageName)
                }
            }
            return newPeralDict
        }
        peralDict.merge(dict: newPeralDict)
        
        if let iconTintColor = icon.tintColor, !iconTintColor.isEmpty {
            peralDict.updateValue(iconTintColor, forKey: PRLWesbiteInfoKey.tintColor.rawValue)
        }
        if let iconTextColor = icon.textColor, !iconTextColor.isEmpty {
            peralDict.updateValue(iconTextColor, forKey: PRLWesbiteInfoKey.textColor.rawValue)
        }
        if let iconButtonColor = icon.buttonColor, !iconButtonColor.isEmpty {
            peralDict.updateValue(iconButtonColor, forKey: PRLWesbiteInfoKey.buttonColor.rawValue)
        }
        if let iconBorderColor = icon.borderColor, !iconBorderColor.isEmpty {
            peralDict.updateValue(iconBorderColor, forKey: PRLWesbiteInfoKey.borderColor.rawValue)
        }
        if let iconBackgroundColor = icon.backgroundColor, !iconBackgroundColor.isEmpty {
            peralDict.updateValue(iconBackgroundColor, forKey: PRLWesbiteInfoKey.backgroundColor.rawValue)
        }
        if let iconFillColor = icon.fillColor, !iconFillColor.isEmpty {
            peralDict.updateValue(iconFillColor, forKey: PRLWesbiteInfoKey.fillColor.rawValue)
        }
        if let iconHeaderColor = icon.headerColor, !iconHeaderColor.isEmpty {
            peralDict.updateValue(iconHeaderColor, forKey: PRLWesbiteInfoKey.headerColor.rawValue)
        }
        
        peralDict.updateValue(Formatter.nvmDateFormatterStatic.string(from: icon.updatedAt!), forKey: PRLWesbiteInfoKey.upatedAt.rawValue)
        
        do {
            let jsonPeralDict = try JSONSerialization.data(withJSONObject: peralDict, options: .prettyPrinted)
            let websitePeralDict = websiteFolder.appendingPathComponent("Peral.json")
            if fileManager.fileExists(atPath: websitePeralDict.path) {
                do {
                    try jsonPeralDict.write(to: websitePeralDict)
                } catch {
                    throw error
                }
            } else {
                _ = fileManager.createFile(atPath: websitePeralDict.path, contents: jsonPeralDict, attributes: nil)
            }
        } catch {
            throw error
        }
    }
    
    enum ImageResult {
        
        /**
         One or more icons are updated
         */
        case updated
        
        /**
         One or more icons are downloaded
         */
        case downloaded
        
        /**
         Multiple icons are either downloaded or updated
         */
        case both
        
        /**
         Icons are up-to-date
         */
        case none
    }
}

Steps to reproduce

Using withThrowingTaskGroup concurrency with a for-loop on a query result.

Actual Outcome

A crash on this line Schermafbeelding 2022-02-27 om 12 01 08

Schermafbeelding 2022-02-27 om 14 29 24

Expected Outcome

No crash, that my icons are downloaded without issues.

Environment

Client

  • Parse Swift SDK version: 4.2.0
  • Xcode version: 13.2
  • Operating system (iOS, macOS, watchOS, etc.): macOS, (iOS)
  • Operating system version: 12.0.1

Server

  • Parse Server version: 4.5.0
  • Operating system: Back4App
  • Local or remote host (AWS, Azure, Google Cloud, Heroku, Digital Ocean, etc): AWS

Database

  • System (MongoDB or Postgres): MongoDB
  • Database version: Back4App
  • Local or remote host (MongoDB Atlas, mLab, AWS, Azure, Google Cloud, etc): Back4App

Logs

This might have to do something with it, I noticed Schermafbeelding 2022-02-27 om 11 22 40 it a time when it was working though.

vdkdamian avatar Feb 27 '22 13:02 vdkdamian

Thanks for opening this issue!

  • 🚀 You can help us to fix this issue faster by opening a pull request with a failing test. See our Contribution Guide for how to make a pull request, or read our New Contributor's Guide if this is your first time contributing.

From your logs and the fact that a query with a lower limit seems to work, I suspect your issue is based on your server configuration or back4app service. Could also be related to how you are setting up your tasks. From your crash, this does't look Swift SDK related to me.

If you think it's SDK related and can replicate this with a test case in a PR, I will take a look at it.

cbaker6 avatar Feb 27 '22 13:02 cbaker6

I'll need some time to invest this more, but I don't directly see what I could be doing wrong with my tasks.

vdkdamian avatar Feb 27 '22 22:02 vdkdamian

@cbaker6 Is it possible it could have something to do with the fact that ParseSwift is not truly async, but still uses completionBlocks?

I came across this issue: https://developer.apple.com/forums/thread/682446 where they state it's a thread-safety issue. I get similar errors.

vdkdamian avatar Feb 28 '22 18:02 vdkdamian

Is it possible it could have something to do with the fact that ParseSwift is not truly async, but still uses completionBlocks?

How is ParseSwift not truly async?

I came across this issue: https://developer.apple.com/forums/thread/682446 where they state it's a thread-safety issue. I get similar errors.

The problem can be related related to thread safety because a URLSessionTask is a reference type and it looks like your code is deinitializing the task in-between setting downloadDelegates and taskCallbackQueues. If you want to open up a PR adding barrier, feel free to add it and I can review. If this works, barriers should probably be added in other places that use URLSession as well such as LiveQuery, etc. An example is in the keychain code: https://github.com/parse-community/Parse-Swift/blob/a121bde14587491bf8797d32ede43ac6e049979f/Sources/ParseSwift/Storage/KeychainStore.swift#L168-L174

If you need to look more into barriers, this blog can help: https://medium.com/cubo-ai/concurrency-thread-safety-in-swift-5281535f7d3a

cbaker6 avatar Mar 02 '22 14:03 cbaker6

How is ParseSwift not truly async?

Cause the async code is all converted with withCheckedThrowingContinuation but I don't know how that works exactly.

If you need to look more into barriers, this blog can help: https://medium.com/cubo-ai/concurrency-thread-safety-in-swift-5281535f7d3a

I'll take a look at this.

vdkdamian avatar Mar 02 '22 16:03 vdkdamian

Cause the async code is all converted with withCheckedThrowingContinuation but I don't know how that works exactly.

ParseSwift was asynchronous before async/await was released. Using withCheckedThrowingContinuation is a way of making already asynchronous code use async/await which shouldn't cause any issues unless there's a bug.

cbaker6 avatar Mar 02 '22 22:03 cbaker6