From 5a0cda662cabe2c562ce60643bff65842beaaf66 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:04:22 -0400 Subject: [PATCH] feat(audio): let the host veto a scheduled audio-session release A final stop() with deactivatesAudioSessionOnStop schedules setActive(false) off the main actor, and that call alone takes about half a second on an Atmos passthrough route. Only a load() on the same engine cancels it. A host that tears one player down and opens another engine straight away could therefore have the old engine's late release deactivate the session the new player has just taken. This was reported but never reproduced, so the change is a defensive guard. Add audioSessionReleaseGate, an optional @Sendable () -> Bool the engine reads when stop() schedules the release and calls right before setActive(false), after any renderer activation queued ahead of it. When it returns false the release is skipped and logged. With no gate set the release runs as before. Document it in docs/api.md and the changelog, and cover nil, false and true in the teardown policy tests. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 2 ++ Sources/AetherEngine/AetherEngine.swift | 25 +++++++++++++++++++ .../AudioSessionTeardownPolicyTests.swift | 20 +++++++++++++++ docs/api.md | 1 + 4 files changed, 48 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f4ff0ad2..3ae70f8ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ the public-API contract. - `LoadOptions.httpRequestAuthorization` now covers direct play. The byte-range reader asks the resolver for the source URL before every range, reconnect, probe and seek, so a rotated access token reaches the next request instead of the session sending the headers it opened with until the host reloads the player. A 401 retries once at the same byte offset when the resolver returns a changed `Authorization`; unchanged credentials, a second 401, or a resolver that throws or exceeds its 10 s bound fail the read without running the reconnect ladder. Resolved credentials follow the static-header redirect policy, so they never reach a cross-origin redirect target. Live ingest, remote disc images and audio-only sources that AVPlayer decodes natively keep static headers. +- `audioSessionReleaseGate` lets a host veto the scheduled `deactivatesAudioSessionOnStop` release just before `setActive(false)` runs. The release runs after `stop()` returns and `setActive(false)` alone takes about half a second on an Atmos passthrough route, but only a `load()` on the same engine cancelled it, so a host that opened a new player in that window could have the new player's session released. Returning `false` skips the release and logs it; with no gate set the release runs as before. + - `LoadOptions.httpRequestAuthorization` accepts an async `HTTPRequestAuthorization` resolver for native HLS. The engine resolves headers before requests and redirects, and retries a rejected request once when the bearer changes, preserving the active player item across token rotation. ### Fixed diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index 9c6950fcd..d8e074431 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -497,6 +497,16 @@ public final class AetherEngine: ObservableObject { /// software or audio-only load is still activating releases the session after that activation (AE#538). public var deactivatesAudioSessionOnStop: Bool = false + /// Last word on a scheduled `deactivatesAudioSessionOnStop` release. Default `nil`, which releases as before. + /// + /// The release runs off the main actor after `stop()` returns, and `setActive(false)` alone takes about + /// half a second on an Atmos passthrough route. The engine's own guard only covers a `load()` on the same + /// engine, so a host that runs more than one engine (a new player opened while the old one is still + /// releasing) answers here whether this engine still owns the session. Returning `false` skips `setActive(false)` and logs the skip. Read when `stop()` + /// schedules the release and called off the main actor right before it, after any renderer activation + /// queued ahead of it, so keep it cheap and thread-safe. + public var audioSessionReleaseGate: (@Sendable () -> Bool)? + @Published public internal(set) var duration: Double = 0 /// Forwarder; see `clock.progress`. @@ -6570,6 +6580,12 @@ public final class AetherEngine: ObservableObject { finalTeardown && !keepNativeHost && hostOptedIn } + /// Whether a scheduled release may still run once its turn comes: no `audioSessionReleaseGate`, or one + /// that answers `true`. + nonisolated static func audioSessionReleaseGateAllows(_ gate: (@Sendable () -> Bool)?) -> Bool { + gate?() ?? true + } + #if os(iOS) || os(tvOS) /// Counterpart to the activations the engine takes part in (#215). The native path never activates the /// session itself (AVKit does it per playback, #24); the software and audio renderer paths do, in @@ -6624,11 +6640,20 @@ public final class AetherEngine: ObservableObject { /// the meantime bumps it again and the pending deactivation drops rather than releasing the session /// out from under the new item. `stopInternal` also cancels a pending task before scheduling a new one. /// The release queues behind a renderer activation still in flight (AE#538), see `enqueueAudioSessionTransition`. + /// Another engine's `load()` is out of reach of that guard, so the host's `audioSessionReleaseGate` gets the + /// last word. private func scheduleAudioSessionDeactivation() { let generation = loadGeneration + let gate = audioSessionReleaseGate audioSessionDeactivationTask = enqueueAudioSessionTransition { [weak self] in guard let self else { return } guard !Task.isCancelled, await self.loadGeneration == generation else { return } + guard AetherEngine.audioSessionReleaseGateAllows(gate) else { + EngineLog.emit( + "[AetherEngine] AVAudioSession release skipped: audioSessionReleaseGate returned false", + category: .engine) + return + } AetherEngine.deactivateSharedAudioSession() } } diff --git a/Tests/AetherEngineTests/AudioSessionTeardownPolicyTests.swift b/Tests/AetherEngineTests/AudioSessionTeardownPolicyTests.swift index 71a90f777..0c1f9eb05 100644 --- a/Tests/AetherEngineTests/AudioSessionTeardownPolicyTests.swift +++ b/Tests/AetherEngineTests/AudioSessionTeardownPolicyTests.swift @@ -53,5 +53,25 @@ struct AudioSessionTeardownPolicyTests { func optInDefaultsOff() throws { let engine = try AetherEngine() #expect(engine.deactivatesAudioSessionOnStop == false) + #expect(engine.audioSessionReleaseGate == nil) + } + + /// The scheduled release runs after `stop()` returns (its `setActive(false)` alone takes ~0.5 s on an + /// Atmos passthrough route), and its own guard only sees this engine's loads. A host that opens a + /// second player in that window answers through `audioSessionReleaseGate`; without one the release + /// behaves exactly as before. + @Test("without a release gate a scheduled release still deactivates") + func noGateDeactivates() { + #expect(AetherEngine.audioSessionReleaseGateAllows(nil)) + } + + @Test("a release gate that answers false skips the deactivation") + func refusingGateSkips() { + #expect(!AetherEngine.audioSessionReleaseGateAllows { false }) + } + + @Test("a release gate that answers true deactivates") + func allowingGateDeactivates() { + #expect(AetherEngine.audioSessionReleaseGateAllows { true }) } } diff --git a/docs/api.md b/docs/api.md index 60f48c195..eac7389d5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -916,6 +916,7 @@ say so on the tracker rather than working around it. | `audioNowPlayingSession`, `setAudioNowPlayingInfo(_:)` | The same pair for the audio-only path, which owns its session unconditionally (there is no AVKit fork there). Pass an empty dictionary to clear. | | `setExternalMetadata(_:)` | AVKit's on-screen info pane on the video path. Safe before `load()`; replayed at host creation. | | `deactivatesAudioSessionOnStop` | Off by default. The engine declares the audio-session category at init and never activates it on the native path, because AVKit activates per playback and that is what lets tvOS negotiate the HDMI route (#24), so it never deactivates it either. Set true only when the app owns the session outright; the engine then releases it on a genuine final teardown, meaning `stop()` and never a reload, handoff or live retune. | +| `audioSessionReleaseGate` | Optional `@Sendable () -> Bool`, `nil` by default. The release above runs off the main actor after `stop()` returns, `setActive(false)` alone takes about half a second on an Atmos passthrough route, and the engine only cancels the release for a `load()` on the same engine. A host that runs several engines, or opens a new player while the old one is still releasing, sets this to answer whether this engine still owns the session; `false` skips `setActive(false)` and logs the skip. Read when `stop()` schedules the release and called off the main actor just before it, after any renderer activation queued ahead, so keep it cheap and thread-safe. | ## Stills and thumbnails