fix(tvos): answer the Atmos probe start call exactly once

Hold the pending result in a one-shot latch fired by every terminal path,
including cancellation and deallocation. Stopping a probe mid-download
released the only strong reference and left the method call unanswered.
This commit is contained in:
edde746
2026-07-27 17:44:52 +02:00
parent ad3af474e9
commit 7e00de3ae9
3 changed files with 104 additions and 7 deletions
+39 -7
View File
@@ -642,6 +642,9 @@ final class AsbarProbe: NSObject {
private var download: URLSessionDownloadTask?
private var statusObserved = false
private var cancelled = false
// Guarded by `lock`. Consumed exactly once by the start path that reaches a
// verdict, by `cancel()`, or by `deinit`, whichever happens first.
private var startCompletion: ((String?) -> Void)?
// Guarded by `lock`; read from the method-channel thread.
private var enqueuedSamples = 0
@@ -663,11 +666,19 @@ final class AsbarProbe: NSObject {
/// `completion` reports only whether the run could be started.
func start(completion: @escaping (String?) -> Void) {
lock.lock()
startCompletion = completion
let aborted = cancelled
lock.unlock()
if aborted {
finishStart("cancelled")
return
}
if source.isFileURL {
queue.async { [weak self] in
guard let self else { return }
let error = self.beginReading(from: self.source)
DispatchQueue.main.async { completion(error) }
self.finishStart(self.beginReading(from: self.source))
}
return
}
@@ -675,11 +686,11 @@ final class AsbarProbe: NSObject {
// AVAssetReader needs a seekable local asset; stage the source first.
setPhase("downloading")
let task = URLSession.shared.downloadTask(with: source) { [weak self] url, _, error in
guard let self else { return }
guard let self else { return } // covered by `deinit`
guard let url else {
let message = error?.localizedDescription ?? "download failed"
self.fail(message)
DispatchQueue.main.async { completion(message) }
self.finishStart(message)
return
}
// The temporary file is removed as soon as this handler returns.
@@ -691,7 +702,7 @@ final class AsbarProbe: NSObject {
} catch {
let message = "failed to stage asset: \(error.localizedDescription)"
self.fail(message)
DispatchQueue.main.async { completion(message) }
self.finishStart(message)
return
}
self.lock.lock()
@@ -700,11 +711,11 @@ final class AsbarProbe: NSObject {
self.lock.unlock()
if aborted {
try? FileManager.default.removeItem(at: staged)
self.finishStart("cancelled") // no-op if `cancel()` already fired
return
}
self.queue.async {
let failureMessage = self.beginReading(from: staged)
DispatchQueue.main.async { completion(failureMessage) }
self.finishStart(self.beginReading(from: staged))
}
}
lock.lock()
@@ -722,6 +733,7 @@ final class AsbarProbe: NSObject {
temporaryFile = nil
lock.unlock()
task?.cancel()
finishStart("cancelled")
queue.sync {
if statusObserved, let renderer {
@@ -745,6 +757,13 @@ final class AsbarProbe: NSObject {
setPhase("cancelled")
}
deinit {
// `stopPlayback()` drops the only strong reference (`asbar = nil`), so the
// probe can die with a download still in flight; the handler's
// `guard let self` would then swallow the verdict for good.
finishStart("cancelled")
}
/// Runs on `queue`. Returns a message on failure.
private func beginReading(from url: URL) -> String? {
lock.lock()
@@ -1050,6 +1069,19 @@ final class AsbarProbe: NSObject {
// MARK: - State
/// Delivers the start verdict at most once, always on the main queue.
/// Never call while holding `lock` (NSLock is not recursive).
private func finishStart(_ message: String?) {
lock.lock()
let completion = startCompletion
startCompletion = nil
lock.unlock()
guard let completion else { return }
// Captures only `completion` and `message` never `self`, so this is safe
// to call from `deinit`.
DispatchQueue.main.async { completion(message) }
}
private func setPhase(_ value: String) {
lock.lock()
phase = value