Merge pull request #344 from ebrindley/fix/double-resume-checked-continuation

fix: prevent double resume of checked continuations causing EXC_BREAKPOINT crashes
This commit is contained in:
Prakash Joshi Pax 2025-10-31 11:26:12 +05:45 committed by GitHub
commit d73f5bb926
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -2,6 +2,7 @@ import Foundation
import os import os
import Zip import Zip
import SwiftUI import SwiftUI
import Atomics
struct WhisperModel: Identifiable { struct WhisperModel: Identifiable {
@ -34,13 +35,17 @@ struct WhisperModel: Identifiable {
private class TaskDelegate: NSObject, URLSessionTaskDelegate { private class TaskDelegate: NSObject, URLSessionTaskDelegate {
private let continuation: CheckedContinuation<Void, Never> private let continuation: CheckedContinuation<Void, Never>
private let finished = ManagedAtomic(false)
init(_ continuation: CheckedContinuation<Void, Never>) { init(_ continuation: CheckedContinuation<Void, Never>) {
self.continuation = continuation self.continuation = continuation
} }
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
continuation.resume() // Ensure continuation is resumed only once, even if called multiple times
if finished.exchange(true, ordering: .acquiring) == false {
continuation.resume()
}
} }
} }
@ -100,16 +105,25 @@ extension WhisperState {
let destinationURL = modelsDirectory.appendingPathComponent(UUID().uuidString) let destinationURL = modelsDirectory.appendingPathComponent(UUID().uuidString)
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Data, Error>) in return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Data, Error>) in
// Guard to prevent double resume
let finished = ManagedAtomic(false)
func finishOnce(_ result: Result<Data, Error>) {
if finished.exchange(true, ordering: .acquiring) == false {
continuation.resume(with: result)
}
}
let task = URLSession.shared.downloadTask(with: url) { tempURL, response, error in let task = URLSession.shared.downloadTask(with: url) { tempURL, response, error in
if let error = error { if let error = error {
continuation.resume(throwing: error) finishOnce(.failure(error))
return return
} }
guard let httpResponse = response as? HTTPURLResponse, guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode), (200...299).contains(httpResponse.statusCode),
let tempURL = tempURL else { let tempURL = tempURL else {
continuation.resume(throwing: URLError(.badServerResponse)) finishOnce(.failure(URLError(.badServerResponse)))
return return
} }
@ -119,12 +133,12 @@ extension WhisperState {
// Read the file in chunks to avoid memory pressure // Read the file in chunks to avoid memory pressure
let data = try Data(contentsOf: destinationURL, options: .mappedIfSafe) let data = try Data(contentsOf: destinationURL, options: .mappedIfSafe)
continuation.resume(returning: data) finishOnce(.success(data))
// Clean up the temporary file // Clean up the temporary file
try? FileManager.default.removeItem(at: destinationURL) try? FileManager.default.removeItem(at: destinationURL)
} catch { } catch {
continuation.resume(throwing: error) finishOnce(.failure(error))
} }
} }
@ -151,6 +165,10 @@ extension WhisperState {
Task { Task {
await withTaskCancellationHandler { await withTaskCancellationHandler {
observation.invalidate() observation.invalidate()
// Also ensure continuation is resumed with cancellation if task is cancelled
if finished.exchange(true, ordering: .acquiring) == false {
continuation.resume(throwing: CancellationError())
}
} operation: { } operation: {
await withCheckedContinuation { (_: CheckedContinuation<Void, Never>) in } await withCheckedContinuation { (_: CheckedContinuation<Void, Never>) in }
} }
@ -211,13 +229,21 @@ extension WhisperState {
} }
private func unzipCoreMLFile(_ zipPath: URL, to destination: URL) async throws { private func unzipCoreMLFile(_ zipPath: URL, to destination: URL) async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in let finished = ManagedAtomic(false)
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
func finishOnce(_ result: Result<Void, Error>) {
if finished.exchange(true, ordering: .acquiring) == false {
continuation.resume(with: result)
}
}
do { do {
try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
try Zip.unzipFile(zipPath, destination: destination, overwrite: true, password: nil) try Zip.unzipFile(zipPath, destination: destination, overwrite: true, password: nil)
continuation.resume() finishOnce(.success(()))
} catch { } catch {
continuation.resume(throwing: error) finishOnce(.failure(error))
} }
} }
} }