Skip to content

Fix double unlock of EffectsPlayer's playerLock on play() failure#4641

Merged
kean merged 4 commits into
trunkfrom
fix/effectsplayer-double-unlock
Jul 2, 2026
Merged

Fix double unlock of EffectsPlayer's playerLock on play() failure#4641
kean merged 4 commits into
trunkfrom
fix/effectsplayer-double-unlock

Conversation

@kean

@kean kean commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Fixes a double unlock of EffectsPlayer.playerLock. When player.play() threw, the lock was released in the catch and then again by play() — and unlocking an NSLock you don't hold is undefined behavior. The helper is now inlined so the failure path unlocks exactly once and returns, matching the other failure paths.

Only triggers when AVAudioPlayerNode.play() throws (rare, e.g. corrupt audio). When it did, the second unlock could release the lock while another thread held it (the same lock guards endPlayback teardown) → intermittent crash or half-torn-down engine. Happy-path playback is unaffected.

To test

  1. Play episodes normally — playback, scrubbing, and effects work as before.
  2. If possible, play a corrupt/truncated file and confirm it pauses cleanly without crashing.

Inline playAndCatchExceptionIfNeeded so the catch path releases the
lock once and returns, matching the other failure paths in play().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kean kean requested a review from a team as a code owner June 29, 2026 21:21
@kean kean added this to the 8.16 milestone Jun 29, 2026
@kean kean added the [Type] Bug Used for issues where something is not functioning as intended. label Jun 29, 2026
Copilot AI review requested due to automatic review settings June 29, 2026 21:21
@kean kean added the crash Crash related issues label Jun 29, 2026
@kean kean removed the request for review from a team June 29, 2026 21:21
@kean kean added the [Type] Bug Used for issues where something is not functioning as intended. label Jun 29, 2026
@kean kean requested a review from SergioEstevao June 29, 2026 21:21
@kean kean added the crash Crash related issues label Jun 29, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@SergioEstevao SergioEstevao requested a review from Copilot July 1, 2026 15:53
@SergioEstevao

Copy link
Copy Markdown
Contributor

@copilot please review this


strongSelf.playerLock.unlock()

completion?()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should completion be called inside the catch now?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or alternative and looking at other early returns above when exceptions happens call:

PlaybackManager.shared.playbackDidFail(error: .fileCorrupted(logMessage: "AVAudioEngine reported not running"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, good catch! The original code inadvertently called completion on the failure path because the catch block had no return, so execution fell through to the completion?() call below. The fix added return but missed preserving that behavior — completion?() is now called in the catch block before returning.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is called for backward-compatibility. Here's a quick repot on how it's used:

PlaybackProtocol.play(completion:) — What the completion is for and how it's used

Summary

func play(completion: (() -> Void)?) (podcasts/PlaybackProtocol.swift:9) takes an optional
"playback has started" callback. Starting playback isn't always synchronous — depending on which
concrete player backs the PlaybackProtocol, actually getting audio flowing can involve async work
(activating the audio session, spinning up an AVAudioEngine on a background queue). The completion
lets the caller run code after the play command has been carried through, rather than assuming it
finished the moment play() returns.

How it flows

PlaybackManager.play(...) (podcasts/PlaybackManager.swift:225) is the public entry point.
It doesn't fire the completion itself — it activates the audio session and then forwards it down to
the concrete player (PlaybackProtocol):

self.player?.play {
    completion?()
}

The three concrete players each honor it differently:

  • DefaultPlayer (podcasts/DefaultPlayer.swift:140, AVPlayer-based) — starts playback
    synchronously and calls completion?() at the very end (line 150).
  • GoogleCastPlayer (podcasts/Playback/Players/Google Cast/GoogleCastPlayer.swift:41) — tells
    the cast manager to play, then calls completion?() synchronously (line 44).
  • EffectsPlayer (podcasts/EffectsPlayer.swift:77, the custom AVAudioEngine player) — this
    is the one where the async nature matters. All the engine setup runs on
    DispatchQueue.global().async, and completion?() fires only once the engine is built and started
    (line 182). Here the completion is genuinely deferred off the calling thread.

How callers use it

Most internal callers pass nil — they only care about starting playback, not being notified:

  • DefaultPlayer re-calls self.play(completion: nil) after a seek finishes
    (podcasts/DefaultPlayer.swift:183, :796, :870).
  • PlaybackManager.playPause()play() uses the default nil.

The one meaningful non-nil use is tvOS Pocket Casts TV App/UI/Player/NowPlayingTab.swift:23: on
appear, if nothing is playing, it calls play and in the completion immediately pauses again
(userInitiated: false). That's a deliberate trick — play-then-pause forces the player to load and
prepare the current episode so the Now Playing UI is populated, without actually leaving audio
running.

One caveat worth knowing

The completion is not guaranteed to fire on failure paths. In EffectsPlayer, the early-return
error branches (playbackDidFail, e.g. lines 132–133, 167–168, 174–175) return without calling
completion?(). Likewise PlaybackManager.play returns early without invoking it if the audio
session fails to activate (:245-247). So it's best treated as a "started successfully" signal, not
a "play attempt finished" signal — don't rely on it always running.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not yet sure if we should keep or remove it. It should probably, to the very least, be throwable.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

@CLAassistant

CLAassistant commented Jul 1, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@SergioEstevao SergioEstevao left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's working correctly but left two comments for your consideration.

@kean kean force-pushed the fix/effectsplayer-double-unlock branch 2 times, most recently from 63e2a7b to c2084d0 Compare July 2, 2026 18:12
@kean kean enabled auto-merge July 2, 2026 18:12
@kean kean merged commit f001a7d into trunk Jul 2, 2026
4 checks passed
@kean kean deleted the fix/effectsplayer-double-unlock branch July 2, 2026 18:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

crash Crash related issues [Type] Bug Used for issues where something is not functioning as intended.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants