chore: added logic for canceling request in favor of a new one baef6f55
Steve · 2025-12-23 21:22 2 file(s) · +87 −41
Titan/Services/TitanClient.swift +63 −39
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
        }
Titan/Views/ContentView.swift +24 −2
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 {