Skip to content

Fix libdispatch over-resume crash in DispatchTimerSource.deinit - #138

Open
romansorochak wants to merge 2 commits into
dimitris-c:mainfrom
StarGoApps:fix/dispatch-timer-source-over-resume
Open

Fix libdispatch over-resume crash in DispatchTimerSource.deinit#138
romansorochak wants to merge 2 commits into
dimitris-c:mainfrom
StarGoApps:fix/dispatch-timer-source-over-resume

Conversation

@romansorochak

Copy link
Copy Markdown

Problem

DispatchTimerSource.deinit resumes the timer unconditionally:

deinit {
    timer.setEventHandler(handler: nil)
    timer.cancel()
    // balance called of cancel/resume to avoid crashes
    timer.resume()
}

That resume is necessary when the source is suspended — libdispatch traps when a
suspended source is released. But it is an over-resume when the source is still
activated, and libdispatch traps on that just the same.

The activated case is reached on a normal path in this library. Retrier schedules a
one-shot timer and calls timeoutTimer.activate() in internalRetry(); the timer stays
in .activated until someone calls cancel(). Whenever a failing stream is torn down
with a retry pending, the Retrier — and with it the DispatchTimerSource — is
deallocated while the source is still running, and the process aborts.

Crash signature:

Thread 0 Crashed:
0  libdispatch.dylib  _dispatch_source_dispose
...
   xctest at DispatchTimerSource.deinit
libdispatch: BUG IN CLIENT OF LIBDISPATCH (Abort Cause 105553162484480)

For context on how often this path is hit: in a radio app built on AudioStreaming this
was 62% of all iOS crash events, affecting 1.3% of weekly active users — dead and
flaky stream URLs put Retrier to work constantly, and every teardown with a pending
retry was a coin flip.

Fix

Make the balancing resume conditional on the source actually being suspended, and take a
lock around the check-and-change in activate() / suspend() so the suspend count cannot
be torn when those are called from different queues. state becomes a lock-protected
read-only property; isRunning is unchanged.

Tests

Two regression tests in DispatchTimerSourceTests:

  • test_DispatchTimerSource_Can_Be_Deallocated_While_Activated — deallocates an activated
    source. Reaching the end of the test is the assertion: against the current main this
    test does not fail, it takes the whole test runner down at DispatchTimerSource.deinit.
  • test_DispatchTimerSource_Repeated_Activate_And_Suspend_Stay_Balanced — repeated
    activate() / suspend() calls move the suspend count exactly once each, including the
    no-op paths.

Notes

The change is confined to DispatchTimerSource; no public API changes. We have been
running this patch in production on a pinned fork revision, and would rather have it
upstream than carry the fork.

romansorochak and others added 2 commits August 4, 2026 17:57
deinit resumed the timer unconditionally to balance a suspended source. When
the source is still activated — a pending retry whose owner is torn down —
that resume is an over-resume and libdispatch aborts the process.

Make the balancing resume conditional on the source actually being suspended,
and guard the state check and change with a lock so activate()/suspend() from
different queues cannot tear the suspend count.

Adds two regression tests; without the fix the deallocation test crashes the
test runner at DispatchTimerSource.deinit.
The fix introduced an NSLock, but `Core/Helpers/Lock.swift` already provides
`UnfairLock`, which is what the rest of the library uses to guard shared state
(NetworkingClient, AudioPlayerContext, AudioRendererContext, FrameFilterProcessor,
Atomic). Switch to it and express the critical sections with `withLock` instead of
manual lock/unlock pairs.

No behaviour change: the state check and change still happen together under the
lock, and deinit still resumes only a suspended source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
KevinTCoughlin added a commit to cascadiacollections/shoutkit that referenced this pull request Sep 7, 2026
handleMediaServicesReset() replaced `player` with a fresh AudioPlayer and let
the old one go. AudioStreaming 1.4.4's `AudioPlayer.deinit` closes only
`audioPlayingEntry`, never `audioReadingEntry`, so an abandoned reading entry
takes its RemoteAudioSource — and that source's pending retry timer — with it.
`DispatchTimerSource.deinit` resumes the timer unconditionally, which for a
timer still activated by a pending retry is an over-resume that aborts the
process inside libdispatch. A reset landing while a stream is mid-retry (a dead
station, a timeout) crashes on the way out.

`stop()` alone doesn't close it: AudioStreaming tears the reading entry down on
its own source queue, capturing the player weakly, so reassigning `player` on
the next line leaves that block with nil. Ask for the teardown, then hold the
discarded player alive for a second while it runs.

Exposure here is one path — the engine is a singleton and every ordinary path
(station switch, stop, replay) already goes through play/stop, both of which
cancel the retrier. Closing it is free, and the failure it produces is a hard
crash rather than a degraded stream.

Upstream fix: dimitris-c/AudioStreaming#138. Once a
release carrying it is pinned, this reduces back to a bare reassignment.

Signed-off-by: Kevin T. Coughlin <706967+KevinTCoughlin@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant