From 0998791e2e129091f9e9b62a313525d4ac5fa551 Mon Sep 17 00:00:00 2001 From: Donny Wals Date: Mon, 24 Nov 2025 12:20:07 +0100 Subject: [PATCH 1/5] File error delegate method now includes an optional file error --- README.md | 12 +++---- Sources/TUSKit/TUSClient.swift | 16 ++++++--- .../TUSKitExample/Helpers/TUSWrapper.swift | 2 +- Tests/TUSKitTests/Mocks.swift | 7 +++- .../TUSClient/TUSClientInternalTests.swift | 34 +++++++++++++++++++ 5 files changed, 59 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 03d2d084..bc702a9e 100644 --- a/README.md +++ b/README.md @@ -39,12 +39,12 @@ You can conform to the `TUSClientDelegate` to receive updates from the `TUSClien ```swift extension MyClass: TUSClientDelegate { - func didStartUpload(id: UUID, client: TUSClient) { + func didStartUpload(id: UUID, context: [String: String]?, client: TUSClient) { print("TUSClient started upload, id is \(id)") print("TUSClient remaining is \(client.remainingUploads)") } - func didFinishUpload(id: UUID, url: URL, client: TUSClient) { + func didFinishUpload(id: UUID, url: URL, context: [String: String]?, client: TUSClient) { print("TUSClient finished upload, id is \(id) url is \(url)") print("TUSClient remaining is \(client.remainingUploads)") if client.remainingUploads == 0 { @@ -52,12 +52,12 @@ extension MyClass: TUSClientDelegate { } } - func uploadFailed(id: UUID, error: Error, client: TUSClient) { + func uploadFailed(id: UUID, error: Error, context: [String: String]?, client: TUSClient) { print("TUSClient upload failed for \(id) error \(error)") } - func fileError(error: TUSClientError, client: TUSClient) { - print("TUSClient File error \(error)") + func fileError(id: UUID?, error: TUSClientError, client: TUSClient) { + print("TUSClient File error \(error) for id \(id?.uuidString ?? "unknown")") } @@ -65,7 +65,7 @@ extension MyClass: TUSClientDelegate { } - func progressFor(id: UUID, bytesUploaded: Int, totalBytes: Int, client: TUSClient) { + func progressFor(id: UUID, context: [String: String]?, bytesUploaded: Int, totalBytes: Int, client: TUSClient) { } diff --git a/Sources/TUSKit/TUSClient.swift b/Sources/TUSKit/TUSClient.swift index b40fb23b..d3c956b0 100644 --- a/Sources/TUSKit/TUSClient.swift +++ b/Sources/TUSKit/TUSClient.swift @@ -22,6 +22,10 @@ public protocol TUSClientDelegate: AnyObject { /// Receive an error related to files. E.g. The `TUSClient` couldn't store a file or remove a file. func fileError(error: TUSClientError, client: TUSClient) + /// Receive an error related to files. E.g. The `TUSClient` couldn't store a file or remove a file. + /// - Parameters: + /// - id: The upload identifier related to the error when available, otherwise nil. + func fileError(id: UUID?, error: TUSClientError, client: TUSClient) /// Get the progress of all ongoing uploads combined /// @@ -40,6 +44,10 @@ public extension TUSClientDelegate { func progressFor(id: UUID, context: [String: String]?, progress: Float, client: TUSClient) { // Optional } + + func fileError(id: UUID?, error: TUSClientError, client: TUSClient) { + fileError(error: error, client: client) + } } protocol ProgressDelegate: AnyObject { @@ -510,7 +518,7 @@ public final class TUSClient { } catch let error { let tusError = TUSClientError.couldnotRemoveFinishedUploads(underlyingError: error) reportingQueue.async { - self.delegate?.fileError(error: tusError , client: self) + self.delegate?.fileError(id: nil, error: tusError , client: self) } } } @@ -629,7 +637,7 @@ public final class TUSClient { } catch (let error) { let tusError = TUSClientError.couldNotLoadData(underlyingError: error) reportingQueue.async { - self.delegate?.fileError(error: tusError, client: self) + self.delegate?.fileError(id: nil, error: tusError, client: self) } return [] } @@ -683,7 +691,7 @@ extension TUSClient: SchedulerDelegate { } catch let error { let tusError = TUSClientError.couldNotDeleteFile(underlyingError: error) reportingQueue.async { - self.delegate?.fileError(error: tusError, client: self) + self.delegate?.fileError(id: uploadTask.metaData.id, error: tusError, client: self) } } @@ -745,7 +753,7 @@ extension TUSClient: SchedulerDelegate { } catch let error { let tusError = TUSClientError.couldNotStoreFileMetadata(underlyingError: error) reportingQueue.async { - self.delegate?.fileError(error: tusError, client: self) + self.delegate?.fileError(id: metaData.id, error: tusError, client: self) } } diff --git a/TUSKitExample/TUSKitExample/Helpers/TUSWrapper.swift b/TUSKitExample/TUSKitExample/Helpers/TUSWrapper.swift index 11b726c3..10362cb9 100644 --- a/TUSKitExample/TUSKitExample/Helpers/TUSWrapper.swift +++ b/TUSKitExample/TUSKitExample/Helpers/TUSWrapper.swift @@ -123,7 +123,7 @@ extension TUSWrapper: TUSClientDelegate { } } - func fileError(error: TUSClientError, client: TUSClient) { } + func fileError(id: UUID?, error: TUSClientError, client: TUSClient) { } func totalProgress(bytesUploaded: Int, totalBytes: Int, client: TUSClient) { print("total progress: \(bytesUploaded) / \(totalBytes) => \(Int(Double(bytesUploaded) / Double(totalBytes) * 100))%") } diff --git a/Tests/TUSKitTests/Mocks.swift b/Tests/TUSKitTests/Mocks.swift index 4839a866..40638076 100644 --- a/Tests/TUSKitTests/Mocks.swift +++ b/Tests/TUSKitTests/Mocks.swift @@ -15,6 +15,7 @@ final class TUSMockDelegate: TUSClientDelegate { var startedUploads = [UUID]() var finishedUploads = [(UUID, URL)]() var failedUploads = [(UUID, Error)]() + var fileErrorsWithIds = [(UUID?, TUSClientError)]() var fileErrors = [TUSClientError]() var progressPerId = [UUID: Int]() var totalProgressReceived = [Int]() @@ -49,6 +50,11 @@ final class TUSMockDelegate: TUSClientDelegate { fileErrorExpectation?.fulfill() } + func fileError(id: UUID?, error: TUSClientError, client: TUSClient) { + fileErrorsWithIds.append((id, error)) + fileError(error: error, client: client) + } + func uploadFailed(id: UUID, error: Error, context: [String : String]?, client: TUSClient) { failedUploads.append((id, error)) uploadFailedExpectation?.fulfill() @@ -142,4 +148,3 @@ final class MockURLProtocol: URLProtocol { // This is called if the request gets canceled or completed. } } - diff --git a/Tests/TUSKitTests/TUSClient/TUSClientInternalTests.swift b/Tests/TUSKitTests/TUSClient/TUSClientInternalTests.swift index 4c130690..73192c0b 100644 --- a/Tests/TUSKitTests/TUSClient/TUSClientInternalTests.swift +++ b/Tests/TUSKitTests/TUSClient/TUSClientInternalTests.swift @@ -89,4 +89,38 @@ final class TUSClientInternalTests: XCTestCase { contents = try FileManager.default.contentsOfDirectory(at: fullStoragePath, includingPropertiesForKeys: nil) XCTAssertFalse(contents.isEmpty, "The client is expected to NOT remove finished uploads on startup") } + + func testFileErrorIncludesIdWhenStoringMetadataFails() throws { + let failingId = UUID() + let missingFilePath = fullStoragePath.appendingPathComponent(failingId.uuidString) + try? FileManager.default.removeItem(at: missingFilePath) + + let metaData = UploadMetadata(id: failingId, + filePath: missingFilePath, + uploadURL: URL(string: "https://tus.example.com/files")!, + size: 10, + customHeaders: [:], + mimeType: nil) + let creationTask = try CreationTask(metaData: metaData, + api: TUSAPI(session: URLSession(configuration: .ephemeral)), + files: files) + + let scheduler = Scheduler() + let expectation = expectation(description: "delegate receives file error with id") + tusDelegate.fileErrorExpectation = expectation + + client.onError(error: TUSClientError.couldNotStoreFileMetadata(underlyingError: FilesError.relatedFileNotFound), + task: creationTask, + scheduler: scheduler) + + waitForExpectations(timeout: 1) + + XCTAssertEqual(tusDelegate.fileErrorsWithIds.count, 1) + XCTAssertEqual(tusDelegate.fileErrorsWithIds.first?.0, failingId) + if case .couldNotStoreFileMetadata = tusDelegate.fileErrorsWithIds.first?.1 { + // Expected error + } else { + XCTFail("Expected a couldNotStoreFileMetadata error") + } + } } From 86512587ae2695c16c4bbadc9086ddaa225cb80d Mon Sep 17 00:00:00 2001 From: Donny Wals Date: Mon, 24 Nov 2025 12:32:39 +0100 Subject: [PATCH 2/5] Treat cancellation more like pause, similar to cancelling all. --- Sources/TUSKit/TUSClient.swift | 21 +++++++++++++++++++ .../TUSClient/TUSClientInternalTests.swift | 17 +++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/Sources/TUSKit/TUSClient.swift b/Sources/TUSKit/TUSClient.swift index d3c956b0..109fc796 100644 --- a/Sources/TUSKit/TUSClient.swift +++ b/Sources/TUSKit/TUSClient.swift @@ -733,6 +733,23 @@ extension TUSClient: SchedulerDelegate { return nil } } + + func isCancellation(_ error: Error) -> Bool { + if let tusError = error as? TUSClientError, case .taskCancelled = tusError { + return true + } + + if let apiError = error as? TUSAPIError, case let .underlyingError(underlying) = apiError { + return isCancellation(underlying) + } + + if let urlError = error as? URLError, urlError.code == .cancelled { + return true + } + + let nsError = error as NSError + return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled + } var shouldReturnEarly = false queue.sync { @@ -747,6 +764,10 @@ extension TUSClient: SchedulerDelegate { return } + if isCancellation(error) { + return + } + metaData.errorCount += 1 do { try files.encodeAndStore(metaData: metaData) diff --git a/Tests/TUSKitTests/TUSClient/TUSClientInternalTests.swift b/Tests/TUSKitTests/TUSClient/TUSClientInternalTests.swift index 73192c0b..ffb5df44 100644 --- a/Tests/TUSKitTests/TUSClient/TUSClientInternalTests.swift +++ b/Tests/TUSKitTests/TUSClient/TUSClientInternalTests.swift @@ -123,4 +123,21 @@ final class TUSClientInternalTests: XCTestCase { XCTFail("Expected a couldNotStoreFileMetadata error") } } + + func testCancellationDoesNotIncrementErrorCountOrRetry() throws { + let metaData = try storeFiles() + let creationTask = try CreationTask(metaData: metaData, + api: TUSAPI(session: URLSession(configuration: .ephemeral)), + files: files) + let scheduler = Scheduler() + + XCTAssertEqual(metaData.errorCount, 0) + + client.onError(error: TUSClientError.taskCancelled, task: creationTask, scheduler: scheduler) + + scheduler.queue.sync { } + + XCTAssertEqual(metaData.errorCount, 0) + XCTAssertTrue(scheduler.allTasks.isEmpty) + } } From ee5b1f6abec97114f0e9475ad2ac8f9490a6f1c2 Mon Sep 17 00:00:00 2001 From: Donny Wals Date: Mon, 24 Nov 2025 12:42:19 +0100 Subject: [PATCH 3/5] Added convenience helper to cancel and clear individual upload --- Sources/TUSKit/TUSClient.swift | 12 ++++++++++ .../TUSClient/TUSClientInternalTests.swift | 23 +++++++++++++++---- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/Sources/TUSKit/TUSClient.swift b/Sources/TUSKit/TUSClient.swift index 109fc796..ae15a533 100644 --- a/Sources/TUSKit/TUSClient.swift +++ b/Sources/TUSKit/TUSClient.swift @@ -239,6 +239,18 @@ public final class TUSClient { scheduler.cancelTask(by: id) } + /// Cancel an upload and remove its cached data so it will not be retried on the next `start()`. + /// - Parameter id: The id of the upload to cancel and delete. + /// - Returns: `true` if a cached upload was found and deleted, `false` otherwise. + /// - Throws: File-related errors if the cache exists but cannot be removed. + @discardableResult + public func cancelAndDelete(id: UUID) throws -> Bool { + scheduler.cancelTask(by: id) + // Wait for the cancel request to be processed before deleting cached data. + scheduler.queue.sync { } + return try removeCacheFor(id: id) + } + /// This will cancel all running uploads and clear the local cache. /// Expect errors passed to the delegate for canceled tasks. /// - Warning: This method is destructive and will remove any stored cache. diff --git a/Tests/TUSKitTests/TUSClient/TUSClientInternalTests.swift b/Tests/TUSKitTests/TUSClient/TUSClientInternalTests.swift index ffb5df44..92330f48 100644 --- a/Tests/TUSKitTests/TUSClient/TUSClientInternalTests.swift +++ b/Tests/TUSKitTests/TUSClient/TUSClientInternalTests.swift @@ -22,11 +22,9 @@ final class TUSClientInternalTests: XCTestCase { do { relativeStoragePath = URL(string: "TUSTEST")! - - let docDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - fullStoragePath = docDir.appendingPathComponent(relativeStoragePath.absoluteString) - files = try Files(storageDirectory: fullStoragePath) - clearDirectory(dir: fullStoragePath) + files = try Files(storageDirectory: relativeStoragePath) + fullStoragePath = files.storageDirectory + clearDirectory(dir: files.storageDirectory) data = Data("abcdef".utf8) @@ -124,6 +122,21 @@ final class TUSClientInternalTests: XCTestCase { } } + func testCancelAndDeleteRemovesCacheAndPreventsResume() throws { + let clientFiles = try Files(storageDirectory: relativeStoragePath) + let id = UUID() + let path = try clientFiles.store(data: data, id: id) + let metaData = UploadMetadata(id: id, filePath: path, uploadURL: URL(string: "io.tus")!, size: data.count, customHeaders: [:], mimeType: nil) + try clientFiles.encodeAndStore(metaData: metaData) + + let deleted = try client.cancelAndDelete(id: id) + XCTAssertTrue(deleted) + XCTAssertNil(try clientFiles.findMetadata(id: id)) + + let resumed = client.start() + XCTAssertTrue(resumed.isEmpty) + } + func testCancellationDoesNotIncrementErrorCountOrRetry() throws { let metaData = try storeFiles() let creationTask = try CreationTask(metaData: metaData, From 20ff172d9caed0d57d1016fb5029936ba406d053 Mon Sep 17 00:00:00 2001 From: Donny Wals Date: Fri, 5 Dec 2025 13:38:48 +0100 Subject: [PATCH 4/5] Some test fixes --- Tests/TUSKitTests/TUSClient/TUSClientInternalTests.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Tests/TUSKitTests/TUSClient/TUSClientInternalTests.swift b/Tests/TUSKitTests/TUSClient/TUSClientInternalTests.swift index 92330f48..7cf0a0da 100644 --- a/Tests/TUSKitTests/TUSClient/TUSClientInternalTests.swift +++ b/Tests/TUSKitTests/TUSClient/TUSClientInternalTests.swift @@ -101,7 +101,8 @@ final class TUSClientInternalTests: XCTestCase { mimeType: nil) let creationTask = try CreationTask(metaData: metaData, api: TUSAPI(session: URLSession(configuration: .ephemeral)), - files: files) + files: files, + headerGenerator: HeaderGenerator(handler: nil)) let scheduler = Scheduler() let expectation = expectation(description: "delegate receives file error with id") @@ -141,7 +142,8 @@ final class TUSClientInternalTests: XCTestCase { let metaData = try storeFiles() let creationTask = try CreationTask(metaData: metaData, api: TUSAPI(session: URLSession(configuration: .ephemeral)), - files: files) + files: files, + headerGenerator: HeaderGenerator(handler: nil)) let scheduler = Scheduler() XCTAssertEqual(metaData.errorCount, 0) From 27a302b2a6fe2b86e4f98d7240ec40b4fdbdac27 Mon Sep 17 00:00:00 2001 From: Donny Wals Date: Fri, 5 Dec 2025 13:43:31 +0100 Subject: [PATCH 5/5] Some cleanup for logs --- Sources/TUSKit/Tasks/UploadDataTask.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/TUSKit/Tasks/UploadDataTask.swift b/Sources/TUSKit/Tasks/UploadDataTask.swift index b4a55b82..cbee0543 100644 --- a/Sources/TUSKit/Tasks/UploadDataTask.swift +++ b/Sources/TUSKit/Tasks/UploadDataTask.swift @@ -207,7 +207,6 @@ final class UploadDataTask: NSObject, IdentifiableTask { guard offset < range.endIndex else { return nil } let data = fileHandle.readData(ofLength: chunkSize) - print("read data of size \(data.count) at offset \(offset)") offset += chunkSize return data } catch {