Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,10 @@ Fixed:
dead. v9 ran the cooldown on an in-process timer.
- iOS: task-map keys whose completion never arrives are pruned at the end of
the relaunch grace wait and when their entry is gone. v9 kept them forever.
- iOS: a file the session cannot read no longer crashes the app. The session
raises an Objective-C exception, which Swift cannot catch. The attempt now
gets no task. It settles `file` only when the staged body is gone, and
otherwise issues again after a backoff.

## 9.0.0

Expand Down
20 changes: 14 additions & 6 deletions ios/ChunkedCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -318,12 +318,20 @@ final class ChunkedCoordinator {
id: id, partIndex: index, incarnation: e.incarnation, attempt: e.attempts,
requestId: requestId, headerGeneration: q.settings.headerGeneration,
generation: e.generation, purpose: .attempt)
let task = q.transport.upload(
q.buildRequest(e, url: url, requestId: requestId, partHeaders: part.headers),
fromFile: file, wifiOnly: q.settings.wifiOnly(e.wifiOnly),
description: ChunkedEngine.taskDescription(id: id, part: index, incarnation: e.incarnation),
beginAt: delayMs.map { Date(timeIntervalSince1970: (q.now() + Double($0)) / 1000) },
beforeResume: { key in self.q.taskMap.set(meta, forKey: key) })
let task: UploadTask
do {
task = try q.transport.upload(
q.buildRequest(e, url: url, requestId: requestId, partHeaders: part.headers),
fromFile: file, wifiOnly: q.settings.wifiOnly(e.wifiOnly),
description: ChunkedEngine.taskDescription(id: id, part: index, incarnation: e.incarnation),
beginAt: delayMs.map { Date(timeIntervalSince1970: (q.now() + Double($0)) / 1000) },
beforeResume: { key in self.q.taskMap.set(meta, forKey: key) })
} catch {
// The blob is whole (the part was just built from it), so the next
// build can pass.
refillLater(e, part: part, delayMs: delayMs)
return false
}
inFlight[id, default: [:]][index] = task.key
q.liveTasks[task.key] = (id, task)
if let delayMs {
Expand Down
32 changes: 24 additions & 8 deletions ios/QueueCoordinator+Simple.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,18 +76,34 @@ extension QueueCoordinator {
let meta = TaskMap.Meta(
id: id, attempt: e.attempts, requestId: requestId,
headerGeneration: settings.headerGeneration, generation: e.generation, purpose: .attempt)
let task = transport.upload(
buildRequest(e, url: url, requestId: requestId), fromFile: body,
wifiOnly: settings.wifiOnly(e.wifiOnly),
description: ChunkedEngine.taskDescription(id: id, attempt: e.attempts, generation: e.generation),
beginAt: beginAt.map { Date(timeIntervalSince1970: $0 / 1000) },
beforeResume: { key in self.taskMap.set(meta, forKey: key) })
let task: UploadTask
do {
task = try transport.upload(
buildRequest(e, url: url, requestId: requestId), fromFile: body,
wifiOnly: settings.wifiOnly(e.wifiOnly),
description: ChunkedEngine.taskDescription(id: id, attempt: e.attempts, generation: e.generation),
beginAt: beginAt.map { Date(timeIntervalSince1970: $0 / 1000) },
beforeResume: { key in self.taskMap.set(meta, forKey: key) })
} catch {
// The same verdict as a file error at completion: terminal only when
// the staged body is gone. Otherwise issue again after a backoff; an
// immediate re-issue would fail the same way.
guard FileIO.exists(body) else {
settle(id, .fileError("the staged body is missing"))
return
}
e.state = .queued
e.nextAttemptAt = nil
commit(e)
deferIssue(e, delayMs: delayMs)
return
}
liveTasks[task.key] = (id, task)
throttle.reset(id)
}

/// The attempt could not be written ahead (disk full, protected data).
/// The entry stays as the disk has it, queued in memory, with no task.
/// The attempt got no task: its write-ahead failed (disk full, protected
/// data), or the session could not read the body. The entry stays queued.
/// Issue again after the wait it asked for, or a backoff, whichever is
/// longer, unless something else moved the entry first.
private func deferIssue(_ e: QueueEntry, delayMs: Int?) {
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;
}
}
16 changes: 13 additions & 3 deletions ios/RNBackgroundUpload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -342,10 +342,20 @@ private final class SessionTransport: Transport {
}

func upload(_ request: URLRequest, fromFile file: URL, wifiOnly: Bool, description: String,
beginAt: Date?, beforeResume: (String) -> Void) -> UploadTask {
beginAt: Date?, beforeResume: (String) -> Void) throws -> UploadTask {
let session = self.session(wifiOnly: wifiOnly)
// A background session uploads from a file only.
let task = session.uploadTask(with: request, fromFile: file)
// A background session uploads from a file only. It raises an NSException,
// not an error, when it cannot read the file. Uncaught, that ends the
// process.
var created: URLSessionUploadTask?
if let exception = RNBGUCatchException({
created = session.uploadTask(with: request, fromFile: file)
}) {
throw NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotOpenFile, userInfo: [
NSLocalizedDescriptionKey: exception.reason ?? "Cannot read file at \(file.absoluteString)",
])
}
let task = created!
task.taskDescription = description
if let beginAt { task.earliestBeginDate = beginAt }
let handle = SessionTask(session: session, task: task)
Expand Down
15 changes: 15 additions & 0 deletions ios/Tests/CoordinatorChunkedTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,21 @@ final class CoordinatorChunkedTests: XCTestCase {
XCTAssertNotNil(partTask(3), "built and sent after the backoff")
}

func testUnreadablePartFileAtIssueRefillsLater() throws {
_ = try h.enqueue(h.chunkedRaw(id: "cap", size: 50, parts: 5)).get()
let transport = h.transport
transport.beforeUpload = { [weak transport] _ in
transport?.beforeUpload = nil
throw NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotOpenFile)
}
h.complete(try XCTUnwrap(partTask(0)))
XCTAssertTrue(h.sink.settled.isEmpty, "not a file terminal")
XCTAssertEqual(h.entry("cap")?.state, .running)
XCTAssertNil(partTask(3))
h.advance(1_000)
XCTAssertNotNil(partTask(3), "sent after the backoff")
}

func testPart401ParksTheWholeEntryAndHeadersResumeIt() throws {
_ = try h.enqueue(h.chunkedRaw(id: "cap", size: 50, parts: 5)).get()
h.complete(try XCTUnwrap(partTask(0)))
Expand Down
27 changes: 27 additions & 0 deletions ios/Tests/CoordinatorSimpleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,33 @@ final class CoordinatorSimpleTests: XCTestCase {
XCTAssertEqual((h.sink.settled.last?["error"] as? [String: Any])?["errorKind"] as? String, "file")
}

func testUnreadableBodyAtIssueCreatesNoTaskAndIssuesAfterABackoff() throws {
let transport = h.transport
transport.beforeUpload = { [weak transport] _ in
transport?.beforeUpload = nil
throw NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotOpenFile)
}
XCTAssertEqual(try h.enqueue(h.dataRaw(id: "a")).get(), "a")
XCTAssertTrue(h.transport.live.isEmpty)
XCTAssertEqual(h.entry("a")?.state, .queued)
XCTAssertTrue(h.sink.settled.isEmpty, "not a terminal while the body exists")
h.advance(1_000)
_ = onlyTask()
XCTAssertEqual(h.entry("a")?.state, .running)
XCTAssertEqual(h.entry("a")?.attempts, 2)
}

func testBodyDeletedAsTheTaskIsCreatedIsTerminal() throws {
h.transport.beforeUpload = { file in
try FileManager.default.removeItem(at: file)
throw NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotOpenFile)
}
_ = try h.enqueue(h.dataRaw(id: "a")).get()
XCTAssertTrue(h.transport.live.isEmpty)
XCTAssertEqual(h.entry("a")?.state, .error)
XCTAssertEqual((h.sink.settled.last?["error"] as? [String: Any])?["errorKind"] as? String, "file")
}

func testUnreadableFileIsTransient() throws {
_ = try h.enqueue(h.dataRaw(id: "a")).get()
h.complete(onlyTask(), error: NSError(domain: NSURLErrorDomain, code: NSURLErrorNoPermissionsToReadFile))
Expand Down
6 changes: 5 additions & 1 deletion ios/Tests/TestSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,13 @@ final class FakeTransport: Transport {
/// completion can land before reconcile.
var deferAllTasks = false
private var pendingAllTasks: (() -> Void)?
/// Runs before each task is created. A throw creates no task, as when a
/// session cannot read the file.
var beforeUpload: ((URL) throws -> Void)?

func upload(_ request: URLRequest, fromFile file: URL, wifiOnly: Bool, description: String,
beginAt: Date?, beforeResume: (String) -> Void) -> UploadTask {
beginAt: Date?, beforeResume: (String) -> Void) throws -> UploadTask {
try beforeUpload?(file)
let task = FakeTask(key: "\(wifiOnly ? "wifi" : "any"):\(Self.next)", description: description,
request: request, file: file, beginAt: beginAt, wifiOnly: wifiOnly)
Self.next += 1
Expand Down
4 changes: 2 additions & 2 deletions ios/Transport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ protocol UploadTask: AnyObject {
protocol Transport: AnyObject {
/// Creates an upload task, sets its description and begin date, calls
/// `beforeResume` with its key (the caller writes the TaskMap there), then
/// resumes it.
/// resumes it. Throws, with no task created, when `file` cannot be read.
func upload(_ request: URLRequest, fromFile file: URL, wifiOnly: Bool, description: String,
beginAt: Date?, beforeResume: (String) -> Void) -> UploadTask
beginAt: Date?, beforeResume: (String) -> Void) throws -> UploadTask

/// Every task of both sessions. The completion may run on any queue.
func allTasks(_ completion: @escaping ([UploadTask]) -> Void)
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 @@ -19,10 +19,12 @@ Pod::Spec.new do |s|
# They and SwiftPM's build output must not compile into the pod.
s.exclude_files = ["ios/Package.swift", "ios/Tests/**", "ios/.build/**", "ios/.swiftpm/**"]
# 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