Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
10 changes: 9 additions & 1 deletion ios/ChunkedCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions ios/RNBGUCatchException.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#import <Foundation/Foundation.h>

// 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
11 changes: 11 additions & 0 deletions ios/RNBGUCatchException.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#import "RNBGUCatchException.h"

NSException *_Nullable RNBGUCatchException(NS_NOESCAPE void (^block)(void))
{
@try {
block();
return nil;
} @catch (NSException *exception) {
return exception;
}
}
63 changes: 47 additions & 16 deletions ios/RNBackgroundUpload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,14 @@ public class RNBackgroundUpload: NSObject, URLSessionDataDelegate {
// removeUpload cancels work with no user-cancel mark.
private static var removedIds = Set<String>()
// 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?
Expand Down Expand Up @@ -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))
Expand All @@ -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()
Expand All @@ -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)
}
}
}

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
6 changes: 4 additions & 2 deletions react-native-background-upload.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading