chore: added logic for canceling request in favor of a new one
baef6f55
2 file(s) · +87 −41
| 37 | 37 | enum TitanError: LocalizedError { |
|
| 38 | 38 | case invalidResponse |
|
| 39 | 39 | case invalidURL |
|
| 40 | + | case cancelled |
|
| 40 | 41 | ||
| 41 | 42 | var errorDescription: String? { |
|
| 42 | 43 | switch self { |
|
| 43 | 44 | case .invalidResponse: return "Invalid response from server" |
|
| 44 | 45 | case .invalidURL: return "Invalid URL" |
|
| 46 | + | case .cancelled: return "Request cancelled" |
|
| 45 | 47 | } |
|
| 46 | 48 | } |
|
| 47 | 49 | } |
|
| 62 | 64 | ) async throws -> TitanResponse { |
|
| 63 | 65 | let host = NWEndpoint.Host(hostname) |
|
| 64 | 66 | let port = NWEndpoint.Port(integerLiteral: UInt16(port)) |
|
| 65 | - | ||
| 67 | + | ||
| 66 | 68 | let tlsOptions = NWProtocolTLS.Options() |
|
| 67 | 69 | let rejectUnauthorized = self.rejectUnauthorized // Capture the value, not self |
|
| 68 | 70 | ||
| 83 | 85 | let parameters = NWParameters(tls: tlsOptions) |
|
| 84 | 86 | let connection = NWConnection(host: host, port: port, using: parameters) |
|
| 85 | 87 | let state = ConnectionState() |
|
| 86 | - | ||
| 87 | - | return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<TitanResponse, Error>) in |
|
| 88 | - | connection.stateUpdateHandler = { connectionState in |
|
| 89 | - | switch connectionState { |
|
| 90 | - | case .ready: |
|
| 91 | - | print("✓ TLS connection established\n") |
|
| 92 | - | let request = urlString + "\r\n" |
|
| 93 | - | print("📤 Request: \(request.trimmingCharacters(in: .whitespacesAndNewlines))") |
|
| 94 | - | ||
| 95 | - | if let requestData = request.data(using: .utf8) { |
|
| 96 | - | connection.send(content: requestData, completion: .idempotent) |
|
| 97 | - | } |
|
| 98 | - | ||
| 99 | - | self.receiveData(connection: connection, state: state) |
|
| 100 | - | ||
| 101 | - | case .failed(let error): |
|
| 102 | - | print("❌ Error: \(error)") |
|
| 103 | - | connection.cancel() |
|
| 104 | - | if !state.continuationResumed { |
|
| 105 | - | state.continuationResumed = true |
|
| 106 | - | continuation.resume(throwing: error) |
|
| 107 | - | } |
|
| 108 | - | ||
| 109 | - | case .cancelled: |
|
| 110 | - | print("✓ Connection closed by server\n") |
|
| 111 | - | if !state.continuationResumed { |
|
| 112 | - | state.continuationResumed = true |
|
| 113 | - | do { |
|
| 114 | - | let response = try self.parseResponse(state.responseData) |
|
| 115 | - | continuation.resume(returning: response) |
|
| 116 | - | } catch { |
|
| 88 | + | ||
| 89 | + | return try await withTaskCancellationHandler { |
|
| 90 | + | try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<TitanResponse, Error>) in |
|
| 91 | + | connection.stateUpdateHandler = { connectionState in |
|
| 92 | + | switch connectionState { |
|
| 93 | + | case .ready: |
|
| 94 | + | // Check for cancellation before sending request |
|
| 95 | + | if Task.isCancelled { |
|
| 96 | + | connection.cancel() |
|
| 97 | + | if !state.continuationResumed { |
|
| 98 | + | state.continuationResumed = true |
|
| 99 | + | continuation.resume(throwing: TitanError.cancelled) |
|
| 100 | + | } |
|
| 101 | + | return |
|
| 102 | + | } |
|
| 103 | + | ||
| 104 | + | print("✓ TLS connection established\n") |
|
| 105 | + | let request = urlString + "\r\n" |
|
| 106 | + | print("📤 Request: \(request.trimmingCharacters(in: .whitespacesAndNewlines))") |
|
| 107 | + | ||
| 108 | + | if let requestData = request.data(using: .utf8) { |
|
| 109 | + | connection.send(content: requestData, completion: .idempotent) |
|
| 110 | + | } |
|
| 111 | + | ||
| 112 | + | self.receiveData(connection: connection, state: state) |
|
| 113 | + | ||
| 114 | + | case .failed(let error): |
|
| 115 | + | print("❌ Error: \(error)") |
|
| 116 | + | connection.cancel() |
|
| 117 | + | if !state.continuationResumed { |
|
| 118 | + | state.continuationResumed = true |
|
| 117 | 119 | continuation.resume(throwing: error) |
|
| 118 | 120 | } |
|
| 121 | + | ||
| 122 | + | case .cancelled: |
|
| 123 | + | if !state.continuationResumed { |
|
| 124 | + | state.continuationResumed = true |
|
| 125 | + | // Check if this was a user-initiated cancellation |
|
| 126 | + | if state.wasCancelled { |
|
| 127 | + | print("✓ Request cancelled\n") |
|
| 128 | + | continuation.resume(throwing: TitanError.cancelled) |
|
| 129 | + | } else { |
|
| 130 | + | print("✓ Connection closed by server\n") |
|
| 131 | + | do { |
|
| 132 | + | let response = try self.parseResponse(state.responseData) |
|
| 133 | + | continuation.resume(returning: response) |
|
| 134 | + | } catch { |
|
| 135 | + | continuation.resume(throwing: error) |
|
| 136 | + | } |
|
| 137 | + | } |
|
| 138 | + | } |
|
| 139 | + | ||
| 140 | + | default: |
|
| 141 | + | break |
|
| 119 | 142 | } |
|
| 120 | - | ||
| 121 | - | default: |
|
| 122 | - | break |
|
| 123 | 143 | } |
|
| 144 | + | ||
| 145 | + | connection.start(queue: .global()) |
|
| 124 | 146 | } |
|
| 125 | - | ||
| 126 | - | connection.start(queue: .global()) |
|
| 147 | + | } onCancel: { |
|
| 148 | + | state.wasCancelled = true |
|
| 149 | + | connection.cancel() |
|
| 127 | 150 | } |
|
| 128 | 151 | } |
|
| 129 | 152 | ||
| 180 | 203 | } |
|
| 181 | 204 | ||
| 182 | 205 | // Helper class to manage connection state |
|
| 183 | - | private class ConnectionState { |
|
| 206 | + | private class ConnectionState: @unchecked Sendable { |
|
| 184 | 207 | var chunks: [Data] = [] |
|
| 185 | 208 | var continuationResumed = false |
|
| 186 | - | ||
| 209 | + | var wasCancelled = false |
|
| 210 | + | ||
| 187 | 211 | var responseData: Data { |
|
| 188 | 212 | chunks.reduce(Data(), +) |
|
| 189 | 213 | } |
|
| 45 | 45 | @State private var showMediaPreview = false |
|
| 46 | 46 | @State private var mediaContent: MediaContent? |
|
| 47 | 47 | ||
| 48 | + | // Current fetch task (for cancellation) |
|
| 49 | + | @State private var currentFetchTask: Task<Void, Never>? |
|
| 50 | + | ||
| 48 | 51 | private let maxRedirects = 5 |
|
| 49 | 52 | ||
| 50 | 53 | var body: some View { |
|
| 186 | 189 | } |
|
| 187 | 190 | ||
| 188 | 191 | private func fetchContent(addToHistory: Bool = true) { |
|
| 192 | + | // Cancel any pending request before starting a new one |
|
| 193 | + | currentFetchTask?.cancel() |
|
| 194 | + | ||
| 189 | 195 | isLoading = true |
|
| 190 | - | Task { |
|
| 196 | + | currentFetchTask = Task { |
|
| 191 | 197 | do { |
|
| 192 | 198 | let (response, finalURL) = try await fetchWithRedirects(urlString: urlText, redirectCount: 0) |
|
| 199 | + | ||
| 200 | + | // Check if task was cancelled during fetch |
|
| 201 | + | if Task.isCancelled { return } |
|
| 193 | 202 | ||
| 194 | 203 | if finalURL != urlText { |
|
| 195 | 204 | urlText = finalURL |
|
| 239 | 248 | case .clientCertificate: |
|
| 240 | 249 | responseText = "Client certificate required: \(response.meta)" |
|
| 241 | 250 | } |
|
| 251 | + | isLoading = false |
|
| 252 | + | } catch is CancellationError { |
|
| 253 | + | // Task was cancelled, don't update UI |
|
| 254 | + | return |
|
| 255 | + | } catch let error as TitanError where error == .cancelled { |
|
| 256 | + | // Request was cancelled, don't update UI |
|
| 257 | + | return |
|
| 242 | 258 | } catch { |
|
| 243 | 259 | responseText = "Error: \(error.localizedDescription)" |
|
| 260 | + | isLoading = false |
|
| 244 | 261 | } |
|
| 245 | - | isLoading = false |
|
| 246 | 262 | } |
|
| 247 | 263 | } |
|
| 248 | 264 | ||
| 249 | 265 | private func fetchWithRedirects(urlString: String, redirectCount: Int) async throws -> (TitanResponse, String) { |
|
| 266 | + | // Check for cancellation before starting |
|
| 267 | + | try Task.checkCancellation() |
|
| 268 | + | ||
| 250 | 269 | guard let url = URL(string: urlString), |
|
| 251 | 270 | let host = url.host else { |
|
| 252 | 271 | throw TitanError.invalidURL |
|
| 259 | 278 | port: port, |
|
| 260 | 279 | urlString: urlString |
|
| 261 | 280 | ) |
|
| 281 | + | ||
| 282 | + | // Check for cancellation after fetch |
|
| 283 | + | try Task.checkCancellation() |
|
| 262 | 284 | ||
| 263 | 285 | if response.statusCategory == .redirect { |
|
| 264 | 286 | guard redirectCount < maxRedirects else { |
|