From a1877a55b260a06782f394bf9baa11ebfd5a5e2d Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Mon, 28 Sep 2026 16:53:06 -0400 Subject: [PATCH] iOS: survive a file the session cannot read A background URLSession raises an NSInvalidArgumentException, not an error, when uploadTask(with:fromFile:) cannot read the file. Swift cannot catch it, so it ends the app. The session transport now catches it with the same Objective-C helper as the 9.0.1 fix and throws. A simple attempt settles 'file' only when its staged body is gone, and otherwise issues again after a backoff. A chunked part refills later, like a failed part build. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ ios/ChunkedCoordinator.swift | 20 +++++++++++----- ios/QueueCoordinator+Simple.swift | 32 ++++++++++++++++++------- ios/RNBGUCatchException.h | 15 ++++++++++++ ios/RNBGUCatchException.m | 11 +++++++++ ios/RNBackgroundUpload.swift | 16 ++++++++++--- ios/Tests/CoordinatorChunkedTests.swift | 15 ++++++++++++ ios/Tests/CoordinatorSimpleTests.swift | 27 +++++++++++++++++++++ ios/Tests/TestSupport.swift | 6 ++++- ios/Transport.swift | 4 ++-- react-native-background-upload.podspec | 6 +++-- 11 files changed, 134 insertions(+), 22 deletions(-) create mode 100644 ios/RNBGUCatchException.h create mode 100644 ios/RNBGUCatchException.m diff --git a/CHANGELOG.md b/CHANGELOG.md index 4816febb..8f604e7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/ios/ChunkedCoordinator.swift b/ios/ChunkedCoordinator.swift index 92b98c3a..0494d151 100644 --- a/ios/ChunkedCoordinator.swift +++ b/ios/ChunkedCoordinator.swift @@ -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 { diff --git a/ios/QueueCoordinator+Simple.swift b/ios/QueueCoordinator+Simple.swift index 7659f1e0..0571d79f 100644 --- a/ios/QueueCoordinator+Simple.swift +++ b/ios/QueueCoordinator+Simple.swift @@ -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?) { 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 6e63f890..ed0dac48 100644 --- a/ios/RNBackgroundUpload.swift +++ b/ios/RNBackgroundUpload.swift @@ -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) diff --git a/ios/Tests/CoordinatorChunkedTests.swift b/ios/Tests/CoordinatorChunkedTests.swift index 2e00e0e5..ca58e131 100644 --- a/ios/Tests/CoordinatorChunkedTests.swift +++ b/ios/Tests/CoordinatorChunkedTests.swift @@ -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))) diff --git a/ios/Tests/CoordinatorSimpleTests.swift b/ios/Tests/CoordinatorSimpleTests.swift index 4b0a3d31..ea1f014d 100644 --- a/ios/Tests/CoordinatorSimpleTests.swift +++ b/ios/Tests/CoordinatorSimpleTests.swift @@ -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)) diff --git a/ios/Tests/TestSupport.swift b/ios/Tests/TestSupport.swift index 66fb6742..1c06aa5b 100644 --- a/ios/Tests/TestSupport.swift +++ b/ios/Tests/TestSupport.swift @@ -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 diff --git a/ios/Transport.swift b/ios/Transport.swift index 768577d9..af92b8b3 100644 --- a/ios/Transport.swift +++ b/ios/Transport.swift @@ -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) diff --git a/react-native-background-upload.podspec b/react-native-background-upload.podspec index 5c1f0d7d..a3d6696a 100644 --- a/react-native-background-upload.podspec +++ b/react-native-background-upload.podspec @@ -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"