From 7d1402d7cb48540f016eb9dc3b396678ddd7616d Mon Sep 17 00:00:00 2001 From: Philipp Kursawe Date: Fri, 17 Jul 2026 16:42:36 +0200 Subject: [PATCH 1/3] fix: don't hang the main thread on live streams during chapter inspection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The metadata completion read `pendingAsset.duration` without ever async-loading it. An unloaded AVAsset property getter falls back to a synchronous XPC fetch to mediaserverd — and for indefinite-duration (live) streams, iOS 26 answers that query only after an internal ~20s timeout. Since the completion runs on the main thread, every playback start of a SHOUTcast/Icecast stream froze the entire app for ~20s (queued touches, watchdog 0x8BADF00D kills when backgrounded). Load `duration` together with the other inspected keys, and skip chapter inspection entirely for indefinite assets — live streams have no chapters, and their metadata arrives via timed metadata anyway. Watchdog backtrace mid-hang (iPhone 13 Pro, iOS 26.5): mach_msg2_trap xpc_connection_send_message_with_reply_sync CoreMedia FigXPCConnectionSendSyncMessageCreatingReply MediaToolbox remoteXPCAsset_CopyPropertyAndBlockageWarning AVFCore -[AVAsset(AVAssetChapterInspection) _loadChapterInfo] AVFCore -[AVAsset(AVAssetChapterInspection) availableChapterLocales] --- .../SwiftAudioEx/AVPlayerWrapper/AVPlayerWrapper.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftAudioEx/AVPlayerWrapper/AVPlayerWrapper.swift b/Sources/SwiftAudioEx/AVPlayerWrapper/AVPlayerWrapper.swift index a7b168e8..a172bfa2 100755 --- a/Sources/SwiftAudioEx/AVPlayerWrapper/AVPlayerWrapper.swift +++ b/Sources/SwiftAudioEx/AVPlayerWrapper/AVPlayerWrapper.swift @@ -239,7 +239,7 @@ class AVPlayerWrapper: AVPlayerWrapperProtocol { state = .loading // Load metadata keys asynchronously and separate from playable, to allow that to execute as quickly as it can - let metdataKeys = ["commonMetadata", "availableChapterLocales", "availableMetadataFormats"] + let metdataKeys = ["commonMetadata", "duration", "availableChapterLocales", "availableMetadataFormats"] pendingAsset.loadValuesAsynchronously(forKeys: metdataKeys, completionHandler: { [weak self] in guard let self = self else { return } if (pendingAsset != self.asset) { return; } @@ -249,6 +249,14 @@ class AVPlayerWrapper: AVPlayerWrapperProtocol { self.delegate?.AVWrapper(didReceiveCommonMetadata: commonData) } + // Indefinite-duration assets (live streams) have no chapters. + // Reading `duration` below without it being async-loaded would + // fall back to a synchronous XPC fetch that iOS 26 answers only + // after ~20s for live streams — executed on the main thread it + // hangs the whole app (watchdog 0x8BADF00D). `duration` is part + // of `metdataKeys` now, so this check is loaded and cheap. + if pendingAsset.duration.isIndefinite { return } + if pendingAsset.availableChapterLocales.count > 0 { for locale in pendingAsset.availableChapterLocales { let chapters = pendingAsset.chapterMetadataGroups(withTitleLocale: locale, containingItemsWithCommonKeys: nil) From 89ff7d8290624caaa15f73414953dc85172936a7 Mon Sep 17 00:00:00 2001 From: Philipp Kursawe Date: Fri, 17 Jul 2026 17:29:03 +0200 Subject: [PATCH 2/3] fix: inspect chapters/metadata only after playback starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Making the reads async-loaded was not enough: AVAsset serializes its property loading per asset, so requesting the metadata keys before "playable" queued item creation — and audio start — behind the same ~20s live-stream probe. Move the inspection to the end of the playable completion: playback starts immediately, chapters/common metadata resolve whenever the daemon answers (instantly for files, late and irrelevant for live streams). --- .../AVPlayerWrapper/AVPlayerWrapper.swift | 68 ++++++++++--------- 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/Sources/SwiftAudioEx/AVPlayerWrapper/AVPlayerWrapper.swift b/Sources/SwiftAudioEx/AVPlayerWrapper/AVPlayerWrapper.swift index a172bfa2..4c9df8b7 100755 --- a/Sources/SwiftAudioEx/AVPlayerWrapper/AVPlayerWrapper.swift +++ b/Sources/SwiftAudioEx/AVPlayerWrapper/AVPlayerWrapper.swift @@ -238,39 +238,6 @@ class AVPlayerWrapper: AVPlayerWrapperProtocol { asset = pendingAsset state = .loading - // Load metadata keys asynchronously and separate from playable, to allow that to execute as quickly as it can - let metdataKeys = ["commonMetadata", "duration", "availableChapterLocales", "availableMetadataFormats"] - pendingAsset.loadValuesAsynchronously(forKeys: metdataKeys, completionHandler: { [weak self] in - guard let self = self else { return } - if (pendingAsset != self.asset) { return; } - - let commonData = pendingAsset.commonMetadata - if (!commonData.isEmpty) { - self.delegate?.AVWrapper(didReceiveCommonMetadata: commonData) - } - - // Indefinite-duration assets (live streams) have no chapters. - // Reading `duration` below without it being async-loaded would - // fall back to a synchronous XPC fetch that iOS 26 answers only - // after ~20s for live streams — executed on the main thread it - // hangs the whole app (watchdog 0x8BADF00D). `duration` is part - // of `metdataKeys` now, so this check is loaded and cheap. - if pendingAsset.duration.isIndefinite { return } - - if pendingAsset.availableChapterLocales.count > 0 { - for locale in pendingAsset.availableChapterLocales { - let chapters = pendingAsset.chapterMetadataGroups(withTitleLocale: locale, containingItemsWithCommonKeys: nil) - self.delegate?.AVWrapper(didReceiveChapterMetadata: chapters) - } - } else { - for format in pendingAsset.availableMetadataFormats { - let timeRange = CMTimeRange(start: CMTime(seconds: 0, preferredTimescale: 1000), end: pendingAsset.duration) - let group = AVTimedMetadataGroup(items: pendingAsset.metadata(forFormat: format), timeRange: timeRange) - self.delegate?.AVWrapper(didReceiveTimedMetadata: [group]) - } - } - }) - // Load playable portion of the track and commence when ready let playableKeys = ["playable"] pendingAsset.loadValuesAsynchronously(forKeys: playableKeys, completionHandler: { [weak self] in @@ -313,6 +280,41 @@ class AVPlayerWrapper: AVPlayerWrapperProtocol { self.timeToSeekToAfterLoading = nil self.seek(to: initialTime) } + + // Inspect chapters/metadata only after playback is under + // way. AVAsset serializes property loading per asset, and + // for live (indefinite) streams iOS 26 resolves duration/ + // chapter probes only after an internal ~20s timeout — + // requesting them before "playable" queues item creation + // (and audio start) behind that wait; reading them without + // loading blocks the calling thread in a sync XPC fetch. + let metdataKeys = ["commonMetadata", "duration", "availableChapterLocales", "availableMetadataFormats"] + pendingAsset.loadValuesAsynchronously(forKeys: metdataKeys, completionHandler: { [weak self] in + guard let self = self else { return } + if (pendingAsset != self.asset) { return; } + + let commonData = pendingAsset.commonMetadata + if (!commonData.isEmpty) { + self.delegate?.AVWrapper(didReceiveCommonMetadata: commonData) + } + + // Live streams have no chapters; their metadata arrives + // via timed metadata on the player item instead. + if pendingAsset.duration.isIndefinite { return } + + if pendingAsset.availableChapterLocales.count > 0 { + for locale in pendingAsset.availableChapterLocales { + let chapters = pendingAsset.chapterMetadataGroups(withTitleLocale: locale, containingItemsWithCommonKeys: nil) + self.delegate?.AVWrapper(didReceiveChapterMetadata: chapters) + } + } else { + for format in pendingAsset.availableMetadataFormats { + let timeRange = CMTimeRange(start: CMTime(seconds: 0, preferredTimescale: 1000), end: pendingAsset.duration) + let group = AVTimedMetadataGroup(items: pendingAsset.metadata(forFormat: format), timeRange: timeRange) + self.delegate?.AVWrapper(didReceiveTimedMetadata: [group]) + } + } + }) } }) } From 2b62b4e302e892e9eb9d8d2a0d3b546d89b67a85 Mon Sep 17 00:00:00 2001 From: Philipp Kursawe Date: Fri, 17 Jul 2026 17:34:42 +0200 Subject: [PATCH 3/3] fix: defer asset metadata inspection until the item reports a finite duration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requesting the metadata keys anywhere near playback start still stalls audio on live streams: AVAsset serializes property loading, so the ~20s live-stream probe queues either the playable key or the item's own preparation behind it. The player item's observed duration tells live from file without touching the asset — inspect chapters and common metadata only once a finite duration is reported, and never for indefinite (live) streams. --- .../AVPlayerWrapper/AVPlayerWrapper.swift | 75 ++++++++++--------- 1 file changed, 40 insertions(+), 35 deletions(-) diff --git a/Sources/SwiftAudioEx/AVPlayerWrapper/AVPlayerWrapper.swift b/Sources/SwiftAudioEx/AVPlayerWrapper/AVPlayerWrapper.swift index 4c9df8b7..d5c60ede 100755 --- a/Sources/SwiftAudioEx/AVPlayerWrapper/AVPlayerWrapper.swift +++ b/Sources/SwiftAudioEx/AVPlayerWrapper/AVPlayerWrapper.swift @@ -30,6 +30,7 @@ class AVPlayerWrapper: AVPlayerWrapperProtocol { private let playerItemObserver = AVPlayerItemObserver() fileprivate var timeToSeekToAfterLoading: TimeInterval? fileprivate var asset: AVAsset? = nil + fileprivate var didInspectAssetMetadata = false fileprivate var item: AVPlayerItem? = nil fileprivate var url: URL? = nil fileprivate var urlOptions: [String: Any]? = nil @@ -236,6 +237,7 @@ class AVPlayerWrapper: AVPlayerWrapperProtocol { if let url = url { let pendingAsset = AVURLAsset(url: url, options: urlOptions) asset = pendingAsset + didInspectAssetMetadata = false state = .loading // Load playable portion of the track and commence when ready @@ -280,41 +282,6 @@ class AVPlayerWrapper: AVPlayerWrapperProtocol { self.timeToSeekToAfterLoading = nil self.seek(to: initialTime) } - - // Inspect chapters/metadata only after playback is under - // way. AVAsset serializes property loading per asset, and - // for live (indefinite) streams iOS 26 resolves duration/ - // chapter probes only after an internal ~20s timeout — - // requesting them before "playable" queues item creation - // (and audio start) behind that wait; reading them without - // loading blocks the calling thread in a sync XPC fetch. - let metdataKeys = ["commonMetadata", "duration", "availableChapterLocales", "availableMetadataFormats"] - pendingAsset.loadValuesAsynchronously(forKeys: metdataKeys, completionHandler: { [weak self] in - guard let self = self else { return } - if (pendingAsset != self.asset) { return; } - - let commonData = pendingAsset.commonMetadata - if (!commonData.isEmpty) { - self.delegate?.AVWrapper(didReceiveCommonMetadata: commonData) - } - - // Live streams have no chapters; their metadata arrives - // via timed metadata on the player item instead. - if pendingAsset.duration.isIndefinite { return } - - if pendingAsset.availableChapterLocales.count > 0 { - for locale in pendingAsset.availableChapterLocales { - let chapters = pendingAsset.chapterMetadataGroups(withTitleLocale: locale, containingItemsWithCommonKeys: nil) - self.delegate?.AVWrapper(didReceiveChapterMetadata: chapters) - } - } else { - for format in pendingAsset.availableMetadataFormats { - let timeRange = CMTimeRange(start: CMTime(seconds: 0, preferredTimescale: 1000), end: pendingAsset.duration) - let group = AVTimedMetadataGroup(items: pendingAsset.metadata(forFormat: format), timeRange: timeRange) - self.delegate?.AVWrapper(didReceiveTimedMetadata: [group]) - } - } - }) } }) } @@ -510,6 +477,43 @@ extension AVPlayerWrapper: AVPlayerItemNotificationObserverDelegate { } +extension AVPlayerWrapper { + /// Chapter/common-metadata inspection, deferred until the playing item + /// reports its duration. Probing an in-use asset's duration/chapter + /// properties makes iOS 26 stall the asset's serialized inspector for + /// ~20s on live streams — which either blocks item preparation (delayed + /// audio start) or, done synchronously, hangs the calling thread. The + /// item's observed duration tells live from file without touching the + /// asset: only finite-duration (file) assets are inspected. + fileprivate func inspectAssetMetadataIfNeeded(itemDuration: Double) { + guard !didInspectAssetMetadata, let asset = asset else { return } + didInspectAssetMetadata = true + guard itemDuration.isFinite && itemDuration > 0 else { return } + let metadataKeys = ["commonMetadata", "availableChapterLocales", "availableMetadataFormats"] + asset.loadValuesAsynchronously(forKeys: metadataKeys, completionHandler: { [weak self] in + guard let self = self, asset == self.asset else { return } + + let commonData = asset.commonMetadata + if (!commonData.isEmpty) { + self.delegate?.AVWrapper(didReceiveCommonMetadata: commonData) + } + + if asset.availableChapterLocales.count > 0 { + for locale in asset.availableChapterLocales { + let chapters = asset.chapterMetadataGroups(withTitleLocale: locale, containingItemsWithCommonKeys: nil) + self.delegate?.AVWrapper(didReceiveChapterMetadata: chapters) + } + } else { + for format in asset.availableMetadataFormats { + let timeRange = CMTimeRange(start: CMTime(seconds: 0, preferredTimescale: 1000), end: CMTime(seconds: itemDuration, preferredTimescale: 1000)) + let group = AVTimedMetadataGroup(items: asset.metadata(forFormat: format), timeRange: timeRange) + self.delegate?.AVWrapper(didReceiveTimedMetadata: [group]) + } + } + }) + } +} + extension AVPlayerWrapper: AVPlayerItemObserverDelegate { // MARK: - AVPlayerItemObserverDelegate @@ -521,6 +525,7 @@ extension AVPlayerWrapper: AVPlayerItemObserverDelegate { func item(didUpdateDuration duration: Double) { delegate?.AVWrapper(didUpdateDuration: duration) + inspectAssetMetadataIfNeeded(itemDuration: duration) } func item(didReceiveTimedMetadata metadata: [AVTimedMetadataGroup]) {