Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions WordPress/Classes/Utility/Media/MediaExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,13 +123,49 @@ extension MediaExporter {
}

func export() async throws -> MediaExport {
try await withUnsafeThrowingContinuation { continuation in
export(onCompletion: { continuation.resume(returning: $0) },
onError: { continuation.resume(throwing: $0) })
try await withCheckedThrowingContinuation { continuation in
let once = ResumeOnce(continuation)
export(onCompletion: { once.resume(returning: $0) },
onError: { once.resume(throwing: $0) })
}
}
}

/// A thread-safe wrapper that guarantees a `CheckedContinuation` is resumed at
/// most once, degrading any subsequent resume to a no-op.
///
/// The callback-based media export APIs can, on a cancellation race, deliver a
/// second terminal callback (e.g. a stray `.cancelled` after a success). Bridging
/// them to `async` with a raw continuation would make that second resume undefined
/// behavior — a hard crash. Routing every resume through this type keeps the
/// bridge safe regardless of how many times the callback fires.
final class ResumeOnce<T> {
private let lock = NSLock()
private var continuation: CheckedContinuation<T, Error>?

init(_ continuation: CheckedContinuation<T, Error>) {
self.continuation = continuation
}

func resume(returning value: T) {
take()?.resume(returning: value)
}

func resume(throwing error: Error) {
take()?.resume(throwing: error)
}

/// Atomically hands out the continuation exactly once, niling it so any
/// later call returns `nil`.
private func take() -> CheckedContinuation<T, Error>? {
lock.lock()
defer { lock.unlock() }
let continuation = self.continuation
self.continuation = nil
return continuation
}
}

/// Protocol of general options available for an export, typically corresponding to a user setting.
///
protocol MediaExportingOptions {
Expand Down
14 changes: 8 additions & 6 deletions WordPress/Classes/Utility/Media/MediaThumbnailExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -267,11 +267,12 @@ extension MediaThumbnailExporter {
func exportThumbnail(forFileURL fileURL: URL) async throws -> (ThumbnailIdentifier, MediaExport) {
let token = MediaExportCancelationToken()
return try await withTaskCancellationHandler {
try await withUnsafeThrowingContinuation { continuation in
try await withCheckedThrowingContinuation { continuation in
let once = ResumeOnce(continuation)
token.progress = exportThumbnail(forFile: fileURL, onCompletion: {
continuation.resume(returning: ($0, $1))
once.resume(returning: ($0, $1))
}, onError: {
continuation.resume(throwing: $0)
once.resume(throwing: $0)
})
}
} onCancel: {
Expand All @@ -282,11 +283,12 @@ extension MediaThumbnailExporter {
func exportThumbnail(forVideoURL url: URL) async throws -> (ThumbnailIdentifier, MediaExport) {
let token = MediaExportCancelationToken()
return try await withTaskCancellationHandler {
try await withUnsafeThrowingContinuation { continuation in
try await withCheckedThrowingContinuation { continuation in
let once = ResumeOnce(continuation)
token.progress = exportThumbnail(forVideoURL: url, onCompletion: {
continuation.resume(returning: ($0, $1))
once.resume(returning: ($0, $1))
}, onError: {
continuation.resume(throwing: $0)
once.resume(throwing: $0)
})
}
} onCancel: {
Expand Down
16 changes: 12 additions & 4 deletions WordPress/Classes/Utility/Media/MediaVideoExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -221,10 +221,18 @@ class MediaVideoExporter: MediaExporter {
exporter.options = imageOptions
}
exporter.mediaDirectoryType = self.mediaDirectoryType
let imageProgress = exporter.export(
onCompletion: onCompletion,
onError: onError)
progress.addChild(imageProgress, withPendingUnitCount: MediaExportProgressUnits.halfDone)
// The image export is synchronous and resumes the awaiting task via
// `onCompletion`/`onError`, so finish all `progress` bookkeeping *before*
// those callbacks fire. Mutating the `progress` object graph afterwards
// would race the resumed task, which can cancel `progress` from another
// thread (see the cancellation handler above).
exporter.export(onCompletion: { export in
progress.completedUnitCount = MediaExportProgressUnits.done
onCompletion(export)
}, onError: { error in
progress.completedUnitCount = MediaExportProgressUnits.done
onError(error)
})
})
return progress
}
Expand Down