diff --git a/CHANGELOG.md b/CHANGELOG.md index a22f35b0..d8e026b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## 9.0.1 + +Fixed: +- **iOS no longer crashes when an upload's file is missing.** A background + session raises an Objective-C exception, not an error, when it cannot read + the file, and Swift cannot catch it. A file deleted after the caller checked + it ended the app. `startUpload` now rejects instead, and a chunked part that + cannot be enqueued journals an `error` with `errorKind: 'file'`. + ## 9.0.0 Chunked uploads move into the library: one file, many part requests, one upload diff --git a/ios/ChunkedCoordinator.swift b/ios/ChunkedCoordinator.swift index 20121ed5..08173fa4 100644 --- a/ios/ChunkedCoordinator.swift +++ b/ios/ChunkedCoordinator.swift @@ -498,7 +498,15 @@ final class ChunkedCoordinator { request.setValue(value, forHTTPHeaderField: key) } let session = uploader.session(wifiOnly: manifest.wifiOnly) - let task = session.uploadTask(with: request, fromFile: partFile) + let task: URLSessionUploadTask + do { + task = try RNBackgroundUpload.uploadTask(session, request, fromFile: partFile) + } catch { + stall(id, entry: errorEntry( + id: id, error: "cannot enqueue part \(index): \(error.localizedDescription)", + errorKind: "file", partIndex: index)) + return false + } task.taskDescription = ChunkedEngine.taskDescription( id: id, part: index, incarnation: manifest.incarnation) let key = TaskMap.key(session, task) diff --git a/ios/RNBGUCatchException.h b/ios/RNBGUCatchException.h new file mode 100644 index 00000000..62df9b2c --- /dev/null +++ b/ios/RNBGUCatchException.h @@ -0,0 +1,15 @@ +#import + +// Swift cannot catch an Objective-C exception. One that unwinds through a Swift +// frame ends the process. This runs `block` in an Objective-C @try and hands the +// exception back to Swift as a value. +// +// Keep this header Foundation-only. It is public, so it is part of the module +// umbrella that this module's Swift and plain Obj-C consumers both import. + +NS_ASSUME_NONNULL_BEGIN + +/// Runs `block`. Returns the exception it raised, or nil when it returned. +FOUNDATION_EXPORT NSException *_Nullable RNBGUCatchException(NS_NOESCAPE void (^block)(void)); + +NS_ASSUME_NONNULL_END diff --git a/ios/RNBGUCatchException.m b/ios/RNBGUCatchException.m new file mode 100644 index 00000000..ff244a87 --- /dev/null +++ b/ios/RNBGUCatchException.m @@ -0,0 +1,11 @@ +#import "RNBGUCatchException.h" + +NSException *_Nullable RNBGUCatchException(NS_NOESCAPE void (^block)(void)) +{ + @try { + block(); + return nil; + } @catch (NSException *exception) { + return exception; + } +} diff --git a/ios/RNBackgroundUpload.swift b/ios/RNBackgroundUpload.swift index 66515fe8..6bfff804 100644 --- a/ios/RNBackgroundUpload.swift +++ b/ios/RNBackgroundUpload.swift @@ -50,13 +50,14 @@ public class RNBackgroundUpload: NSObject, URLSessionDataDelegate { // removeUpload cancels work with no user-cancel mark. private static var removedIds = Set() // The consumer-supplied ids whose check-and-create is in flight, mapped to - // the resolves of the concurrent same-id calls. The existence check + // the promises of the concurrent same-id calls. The existence check // enumerates the session tasks asynchronously. Without this claim, two // concurrent calls could both see "no task" and enqueue duplicates. The id // is claimed synchronously, under `lock`, BEFORE the enumeration is // dispatched. The map entry drains when the first caller's create-or-find - // lands. - private static var creationsInFlight: [String: [RCTPromiseResolveBlock]] = [:] + // lands, and every parked call gets that caller's outcome. + private static var creationsInFlight: + [String: [(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock)]] = [:] private static var backgroundSession: URLSession? private static var wifiOnlySession: URLSession? @@ -249,8 +250,8 @@ public class RNBackgroundUpload: NSObject, URLSessionDataDelegate { let fileURL = URL(string: path) ?? URL(fileURLWithPath: path) let session = self.session(wifiOnly: wifiOnly) - let startNew = { - let task = session.uploadTask(with: request, fromFile: fileURL) + let startNew: () throws -> Void = { + let task = try RNBackgroundUpload.uploadTask(session, request, fromFile: fileURL) task.taskDescription = uploadId TaskMap.set(TaskMap.Meta(id: uploadId, accept: accept, partIndex: nil), forKey: self.taskMapKey(session, task)) @@ -265,30 +266,39 @@ public class RNBackgroundUpload: NSObject, URLSessionDataDelegate { // session. A generated id cannot collide, so that path does not do the // (asynchronous) task enumeration. guard options["id"] != nil else { - startNew() - resolve(uploadId) + do { + try startNew() + resolve(uploadId) + } catch { + reject("RN Uploader", error.localizedDescription, error) + } return } // Serialize the check-and-create for each id: claim the id synchronously, // before we dispatch the enumeration. The first caller runs the check and - // creates the task. A concurrent same-id caller parks its resolve here. - // When the task lands, we answer the parked calls with the id. There is no - // second task, and there is no polling. + // creates the task. A concurrent same-id caller parks its promise here. + // When the task lands, we answer the parked calls with the same outcome. + // There is no second task, and there is no polling. RNBackgroundUpload.lock.lock() if RNBackgroundUpload.creationsInFlight[uploadId] != nil { - RNBackgroundUpload.creationsInFlight[uploadId]?.append(resolve) + RNBackgroundUpload.creationsInFlight[uploadId]?.append((resolve: resolve, reject: reject)) RNBackgroundUpload.lock.unlock() return } RNBackgroundUpload.creationsInFlight[uploadId] = [] RNBackgroundUpload.lock.unlock() - let settle = { + let settle = { (failure: Error?) in RNBackgroundUpload.lock.lock() let waiters = RNBackgroundUpload.creationsInFlight.removeValue(forKey: uploadId) ?? [] RNBackgroundUpload.lock.unlock() - resolve(uploadId) - for waiter in waiters { waiter(uploadId) } + for call in [(resolve: resolve, reject: reject)] + waiters { + if let failure { + call.reject("RN Uploader", failure.localizedDescription, failure) + } else { + call.resolve(uploadId) + } + } } let group = DispatchGroup() @@ -308,8 +318,12 @@ public class RNBackgroundUpload: NSObject, URLSessionDataDelegate { } } group.notify(queue: .main) { - if !exists { startNew() } - settle() + do { + if !exists { try startNew() } + settle(nil) + } catch { + settle(error) + } } } @@ -696,6 +710,23 @@ public class RNBackgroundUpload: NSObject, URLSessionDataDelegate { if let handler { DispatchQueue.main.async { handler() } } } + // A background session raises an NSException, not an error, when the file + // cannot be read: for example, when the file was deleted after the caller + // checked it. Uncaught, the exception ends the process. This throws a + // URL-domain error instead, which errorKind(for:) classifies as 'file'. + static func uploadTask(_ session: URLSession, _ request: URLRequest, + fromFile file: URL) throws -> URLSessionUploadTask { + var task: URLSessionUploadTask? + if let exception = RNBGUCatchException({ + task = session.uploadTask(with: request, fromFile: file) + }) { + throw NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotOpenFile, userInfo: [ + NSLocalizedDescriptionKey: exception.reason ?? "Cannot read file at \(file.absoluteString)", + ]) + } + return task! + } + // Classify a transport error to match Android's errorKind taxonomy: a missing or // unreadable source file -> 'file'; other URL-domain errors -> 'network'; anything // else -> 'unknown'. It is internal because the chunked coordinator also diff --git a/package.json b/package.json index 102f21d5..2a2b3cd6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-background-upload", - "version": "9.0.0", + "version": "9.0.1", "description": "Cross platform http post file uploader with android and iOS background support", "main": "src/index", "typings": "src/index.ts", diff --git a/react-native-background-upload.podspec b/react-native-background-upload.podspec index fd01a30b..be0e6424 100644 --- a/react-native-background-upload.podspec +++ b/react-native-background-upload.podspec @@ -16,10 +16,12 @@ Pod::Spec.new do |s| s.source_files = "ios/**/*.{h,m,mm,swift}" # RNFileUploader.h imports the codegen spec header, which is Obj-C++ only. Keep - # every header out of the public umbrella so a consumer's plain Obj-C + # it out of the public umbrella so a consumer's plain Obj-C # `@import react_native_background_upload;` still compiles — that import is how # the AppDelegate reaches RNBackgroundUpload's background-session handler. - s.private_header_files = "ios/**/*.h" + s.private_header_files = "ios/RNFileUploader.h" + # Foundation-only, and public so that the Swift half of the module can see it. + s.public_header_files = "ios/RNBGUCatchException.h" s.platform = :ios, "15.1" s.swift_version = "5.0"