Skip to content

Fix recursive lock crash in NWPathMonitor AsyncStream#1135

Open
timbms wants to merge 5 commits intodevelopfrom
fix/nwpathmonitor-recursive-lock-crash
Open

Fix recursive lock crash in NWPathMonitor AsyncStream#1135
timbms wants to merge 5 commits intodevelopfrom
fix/nwpathmonitor-recursive-lock-crash

Conversation

@timbms
Copy link
Copy Markdown
Contributor

@timbms timbms commented Apr 9, 2026

Summary

Defensive fix for a production crash (Firebase issue af3200f1b50a8163b09373952f7baa77) caused by a recursive os_unfair_lock abort in the Network framework's interaction with Swift Concurrency.

  • Defer NWPathMonitor.cancel() off the AsyncStream lock context — onTermination fires while AsyncStream._Storage's internal os_unfair_lock is held; calling cancel() synchronously risks re-entering the same lock during Network framework teardown
  • Replace the #available(iOS 17) branch that used for await path in monitor (which triggers the buggy makeAsyncStream()) with our own paths() wrapper on all versions, bypassing the Apple bug entirely
  • Also removes the @available(iOS, obsoleted: 17.0) guard on paths() and fixes a dispatch queue label typo (NSPathMonitorNWPathMonitor)

The root cause is an Apple bug where startLocked(lockedState:) holds an internal lock and then calls AsyncStream.Continuation.finish(), which tries to re-acquire the same lock.

Test plan

  • Build and run on iOS 16 simulator — verify NWPathMonitor path updates still work
  • Build and run on iOS 17+ simulator — verify NWPathMonitor path updates still work
  • Confirm no new crashes appear in Firebase for _os_unfair_lock_recursive_abort on com.apple.network.connections

timbms and others added 5 commits April 7, 2026 21:52
Guard against NaN propagation when min/max temperature values produce
a zero-span range. Extract math into testable ColorTemperatureRowMath
and replace stride-based gradient generation with an integer-step
approach that is safe when the span is zero.

Signed-off-by: Tim Mueller-Seydlitz <timbms@gmail.com>
onTermination fires while AsyncStream._Storage's internal os_unfair_lock
is held. Calling cancel() synchronously from that closure risks re-entering
the same lock if the Network framework's teardown path calls back into the
same AsyncStream storage, causing _os_unfair_lock_recursive_abort.

Deferring the cancel() to a global queue breaks any potential re-entrancy
chain without changing the functional behaviour.

Signed-off-by: Tim Mueller-Seydlitz <timbms@gmail.com>
…lock crash

On iOS 17+, NWPathMonitor.makeAsyncStream() has a bug where
startLocked(lockedState:) holds an internal os_unfair_lock and then calls
AsyncStream.Continuation.finish() from within that locked context. finish()
tries to re-acquire the same lock, causing _os_unfair_lock_recursive_abort
on com.apple.network.connections (confirmed in production crash report
issue 1775adbe288bdbc47fda646aea5d92f2).

Replace the #available(iOS 17) branch that used `for await path in monitor`
(which triggers makeAsyncStream()) with our own paths() wrapper on all
versions. The wrapper uses pathUpdateHandler + AsyncStream directly,
bypassing the buggy Apple implementation entirely.

Also removes the @available(iOS, obsoleted: 17.0) guard that was preventing
paths() from being called on iOS 17+, and fixes the dispatch queue label
typo (NSPathMonitor → NWPathMonitor).

Signed-off-by: Tim Mueller-Seydlitz <timbms@gmail.com>
Signed-off-by: Tim Mueller-Seydlitz <timbms@gmail.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