Skip to content

Make AnalyticsCoordinator and AnalyticsSourceProvider MainActor-isolated - #4956

Draft
kean wants to merge 1 commit into
trunkfrom
kean/analytics-coordinator-main-actor
Draft

Make AnalyticsCoordinator and AnalyticsSourceProvider MainActor-isolated#4956
kean wants to merge 1 commit into
trunkfrom
kean/analytics-coordinator-main-actor

Conversation

@kean

@kean kean commented Aug 14, 2026

Copy link
Copy Markdown
Contributor
📘 Part of: #

Marks AnalyticsSourceProvider and AnalyticsCoordinator (and therefore AnalyticsEpisodeHelper / AnalyticsPlaybackHelper) as @MainActor.

track() used to check Thread.isMainThread and re-dispatch itself with DispatchQueue.main.async. That check is gone — the isolation now guarantees it. It also fixes a real bug: only track() hopped, so the surrounding currentSource bookkeeping ran inline on the caller's thread, and currentAnalyticsSource would reach SceneHelper.rootViewController() and UIApplication.shared.applicationState off the main thread.

⚠️ The Task { @MainActor in … } hops are temporary

Draft because the hops added at non-isolated call sites are scaffolding, not the destination. They exist only so this can land ahead of the callers, and they should disappear one by one as PlaybackManager, EpisodeManager, SiriShortcutsManager, DownloadManager and friends become main-actor isolated themselves — continuing the incremental top-down adoption from #4953. Once a caller is isolated, its hop collapses into a plain synchronous call, which is what most of the analytics call sites in this diff already look like.

Reviewing the hops as permanent API would be reading them wrong; the thing to check here is the isolation of the two analytics types and that no call site changed meaning.

All 110 call sites were checked. The ones already on the main actor (view controllers, MultiSelectHelper, PlaybackActionHelper, PlaybackManager.playBookmark, the tvOS SwiftUI views) call through synchronously with no hop at all. The genuinely non-isolated ones got an explicit Task { @MainActor in … }: PlaybackManager, EpisodeManager, SiriShortcutsManager, ServerSyncManager, DownloadManager, GoogleCastManager, WatchManager, UserEpisodeManager, and four DispatchQueue.global() blocks in PodcastViewController. Ordering within each call chain is preserved because the hops are enqueued in source order — which matters for the set-currentSource-then-track and ignoreNextSeek patterns.

Other notes:

  • On tvOS, SearchAnalytics.episodeTapped, AppCoordinator.remotePlayPauseToggle, PlayerStatusObserver.updatePlayState and the SearchableViewModel.playEpisode requirement were annotated @MainActor instead of hopping — their callers are all main-actor already.
  • OnboardingFlow's AnalyticsSourceProvider conformance moved to an extension so the struct itself doesn't inherit @MainActor (it's reached from the non-isolated IAPHelper).
  • The pure HLS property builders on AnalyticsPlaybackHelper are nonisolated — they only read a feature flag, the episode and PlaybackManager.shared, so non-isolated callers and the tests don't need a hop.
  • AnalyticsEpisodeHelper's ThreadSafeDictionary is now a plain dictionary, and its two notification observer bodies were extracted into named methods.
  • PlaybackManager.playbackDidFail saves/overrides/restores currentSource around pause(userInitiated: false). I kept it faithful rather than move pause() into the hop, since cleanupCurrentPlayer below depends on the pause having run. Worth flagging that this block is inert today: it was written in 5064b71 when the call was pause(), and userInitiated: false now suppresses the very event it was meant to attribute. Happy to remove it in a follow-up.

To test

  1. Build and run — no functional change is expected anywhere.
  2. Start playing an episode, then pause, skip back and skip forward from the full player. Confirm playback_play / playback_pause / playback_skip_back / playback_skip_forward are logged with source: player.
  3. Do the same from the mini player, lock screen and CarPlay, and confirm the source matches the surface you used rather than falling back to unknown.
  4. Scrub the player and confirm a playback_seek fires, and that skipping back/forward does not additionally fire one.
  5. Download an episode from the podcast screen, then let it finish. Confirm episode_download_queued and episode_download_finished both report source: podcast_screen.
  6. Multi-select several episodes in a filter and archive/star/mark as played. Confirm the bulk events report source: filters.
  7. On the podcast screen use Download All / Archive All / Unarchive All (these run on a background queue) and confirm the bulk events still report source: podcast_screen.
  8. Ask Siri to pause/resume playback and confirm the events report source: siri.
  9. Play/pause from the Apple Watch and confirm source: watch.
  10. tvOS: play an episode from search and confirm search_result_tapped plus playback_play with source: search.

Checklist

  • I have considered if this change warrants user-facing release notes and have added them to CHANGELOG.md if necessary.
  • I have considered adding unit tests for my changes.
  • I have updated (or requested that someone edit) the Event Horizon schema to reflect any new or changed analytics.

Removes the Thread.isMainThread check from track() and hops non-isolated
call sites onto the main actor instead.
@kean
kean requested a review from a team as a code owner August 14, 2026 17:28
@kean kean added this to the 8.19 milestone Aug 14, 2026
@kean kean added refactoring Refactoring related issues Analytics Analytics related issues Concurrency labels Aug 14, 2026
@claude

claude Bot commented Aug 14, 2026

Copy link
Copy Markdown

Claude finished @kean's task in 7m 14s —— View job


Review

  • Read the diff against origin/trunk
  • Verified the call-site hops preserve set-currentSource-then-track ordering
  • Checked the nonisolated boundaries, the ThreadSafeDictionary removal and the OnboardingFlow extension move
  • Posted inline comments

I couldn't build or run tests here (no Xcode toolchain on this runner), so everything below is from reading the code.

Overall this is a well-executed migration and the bug it fixes is real: currentAnalyticsSource reaching SceneHelper.rootViewController() and UIApplication.shared.applicationState off the main thread was a genuine hazard, and the Thread.isMainThread re-dispatch made it worse by hopping only track() while leaving the bookkeeping inline. The AnalyticsCoordinator diff is a clear net simplification, and the choices to make the HLS property builders nonisolated (justified — PlaybackManager.swift:1443 and :2648 are genuinely non-isolated callers) and to move OnboardingFlow's conformance into an extension so the struct doesn't inherit @MainActor are both right.

The thing I'd push back on is the ordering argument in the description.

The FIFO assumption deserves a caveat

Ordering within each call chain is preserved because the hops are enqueued in source order

This holds in practice for every pair in this diff, but it isn't a language guarantee: Task {} inherits priority from the enclosing context, and the main actor drains its queue in priority order, so two hops created at different priorities can invert. Every paired hop here originates from the same context, so priorities match — but the invariant is invisible at the call sites and one refactor away from breaking silently.

This matters more than it normally would because currentAnalyticsSource is consume-once — it nils currentSource on read — so a reordering doesn't just mislabel one event, it also leaks the source into whichever event reads next.

The places that bundle the source assignment and the triggering call into a single hop (PodcastViewController, WatchManager's SkipBackRequest) are immune by construction. Where that's feasible I'd prefer it over relying on enqueue order; where it isn't (the commandCenter.addTarget handlers have to return a status synchronously), a short comment at one representative site noting the dependency would help the next reader.

Related but separate: PlaybackManager is non-isolated, so its internal analytics calls now hop even when the caller is already on the main actor. That splits pairs like EpisodeManager.analyticsHelper.currentSource = analyticsSource (inline, EpisodeDetailViewController+Actions.swift:216 and NowPlayingPlayerItemViewController+Shelf.swift:334) from the eventual track(), which previously ran inline and atomically. AnalyticsPlaybackHelper and AnalyticsEpisodeHelper are separate instances with separate currentSource, which bounds the blast radius a lot — I traced both of those sites and couldn't find an interleaving AnalyticsEpisodeHelper event, so I don't think there's a live bug. Worth keeping in mind as the migration continues, though: each newly-isolated caller shrinks these windows, so they'll close on their own.

Inline comments

# File Severity Issue
1 PlaybackManager.swift:1373 Correctness The playbackDidFail save/override/restore isn't semantics-preserving — await previousSource.value suspends T2 and the restore can stamp a stale source over one set in between. Recommend deleting the block, as you suggest in the description.
2 ServerSyncManager.swift:179 Correctness downloaded(episodeUUID:) also does the episodeDownloadQueue.insert that gates episode_download_finished; that insert now lands after addToQueue starts the download.
3 DownloadManager.swift:37 Correctness Deferring setup() delays observer registration past startAllQueued() at launch.
4 WatchManager.swift:146 Consistency PlayPauseRequest/SkipForwardRequest leave the PlaybackManager call outside the hop while SkipBackRequest includes it.
5 PlaybackManager.swift:2763 Nit let mergedProperties = baseProperties is only there for the capture.
6 AnalyticsPlaybackHelperTests.swift:230 Nit Redundant @MainActor on the mock subclass.

Things I checked that look correct

  • ThreadSafeDictionary → plain dictionary. All four accessors (cacheDownloadSource, consumeDownloadSource, clearDownloadSource) live in a private extension on the now-isolated class, and nothing reaches episodeDownloadSources from outside. Safe, and one fewer lock.
  • MainActor.assumeIsolated in the notification observers. queue: .main means the block runs on the main thread whether Foundation executes it inline (posted from main) or via OperationQueue.main.addOperation, so the precondition holds. Consistent with the existing usages in TranscriptViewController and EpisodeCell.
  • testCurrentSourceIsRemovedAfterEventIsTriggered. Dropping eventually is right — it uses the real shared helper, so play() → track() → currentAnalyticsSource now nils synchronously. Strictly more deterministic than before, and it was the last eventually in the analytics tests.
  • Removing private let analyticsPlaybackHelper from PlaybackManager. Necessary, since a non-isolated stored property can't initialize from a main-actor static.
  • EpisodeManager.analyticsHelper var@MainActor let. Nothing assigns it (the only analyticsHelper = in the tree is SearchResultsModel's unrelated property), so no injection point is lost.
  • tvOS annotations. SearchAnalytics.episodeTapped, AppCoordinator.remotePlayPauseToggle, PlayerStatusObserver.updatePlayState and the SearchableViewModel.playEpisode requirement all have main-actor-only callers, so annotating beats hopping.

One note for later, not this PR

Several of these hops now send non-Sendable payloads across isolation — BaseEpisode (EpisodeManager), [Episode] (PodcastViewController), [String: Any] (DownloadManager+Logging, trackChapterEvent), and PlaybackProtocol? (failedPlayer in playbackDidFail). Fine under SWIFT_VERSION = 5.0 with minimal checking, but they'll all need attention when strict concurrency goes on. The failedPlayer one is the most interesting, since it hands a live player object to the main actor to be read by playerString(player:).
• branch kean/analytics-coordinator-main-actor

@kean
kean marked this pull request as draft August 14, 2026 17:29
Comment on lines +1373 to +1381
let previousSource = Task { @MainActor () -> AnalyticsSource? in
let previousSource = AnalyticsPlaybackHelper.shared.currentSource
AnalyticsPlaybackHelper.shared.currentSource = .playbackFailed
return previousSource
}
pause(userInitiated: false)
AnalyticsPlaybackHelper.shared.currentSource = previousSource
Task { @MainActor in
AnalyticsPlaybackHelper.shared.currentSource = await previousSource.value
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This save/override/restore is the one place where the split into two hops isn't semantics-preserving, and I'd argue it's now worse than inert.

Before, all three writes happened synchronously on the caller's thread, so the net effect on currentSource was exactly zero — a true no-op. Now:

  1. T1 saves previousSource and sets .playbackFailed.
  2. T2 is enqueued behind it, and await previousSource.value suspends T2 even though T1 has already finished — so the main actor is released and any job queued behind T2 runs before the restore lands.
  3. Whatever ran in between may have legitimately set currentSource (e.g. the user taps play in the player: the VC sets .player inline, play() enqueues its own hop). T2 then resumes and stamps the stale pre-failure source over it.

Because currentAnalyticsSource is consume-once, that stale value is then attributed to the next event that reads it instead of falling through to topAnalyticsSourceProvider().

Given pause(userInitiated: false) already suppresses the only event this was meant to attribute (as you note in the description), I'd just delete the block in this PR rather than carry the Task-value dance forward:

Suggested change
let previousSource = Task { @MainActor () -> AnalyticsSource? in
let previousSource = AnalyticsPlaybackHelper.shared.currentSource
AnalyticsPlaybackHelper.shared.currentSource = .playbackFailed
return previousSource
}
pause(userInitiated: false)
AnalyticsPlaybackHelper.shared.currentSource = previousSource
Task { @MainActor in
AnalyticsPlaybackHelper.shared.currentSource = await previousSource.value
}
pause(userInitiated: false)

If you'd rather keep it faithful for now, collapsing it into a single hop makes the no-op explicit (and self-evidently deletable):

Task { @MainActor in
    let previousSource = AnalyticsPlaybackHelper.shared.currentSource
    AnalyticsPlaybackHelper.shared.currentSource = .playbackFailed
    AnalyticsPlaybackHelper.shared.currentSource = previousSource
}
pause(userInitiated: false)

Comment on lines 37 to +39
let manager = DownloadManager(dataManager: DataManager.sharedManager)
AnalyticsEpisodeHelper.shared.setup()
Task { @MainActor in
AnalyticsEpisodeHelper.shared.setup()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

setup() is an empty method whose only job is to force AnalyticsEpisodeHelper.shared to initialize, and that initialization is what registers the episodeDownloaded / userEpisodeUploadStatusChanged observers.

Deferring it into a Task means observer registration now happens on a later main-actor turn than DownloadManager.shared initialization. AppDelegate.swift:110 touches DownloadManager.shared and immediately calls startAllQueued(), so there's now a window at launch where downloads are running but nothing is listening — any download that completes in that window silently loses its episode_download_finished event (and leaves a stale entry in episodeDownloadSources).

Narrow in practice, but easy to avoid: now that the helper is main-actor isolated, this side effect reads better as an explicit call from the main-actor launch path rather than a hop hidden in a lazy static.

Suggested change
let manager = DownloadManager(dataManager: DataManager.sharedManager)
AnalyticsEpisodeHelper.shared.setup()
Task { @MainActor in
AnalyticsEpisodeHelper.shared.setup()
return manager
}()

…and call AnalyticsEpisodeHelper.shared.setup() from AppDelegate alongside the other launch-time setup.

Comment on lines +179 to 182
Task { @MainActor in
AnalyticsEpisodeHelper.shared.downloaded(episodeUUID: uuid)
}
DownloadManager.shared.addToQueue(episodeUuid: uuid)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

downloaded(episodeUUID:) isn't only a track() — it also does the bookkeeping the completion observer depends on:

func downloaded(episodeUUID: String) {
    let source = cacheDownloadSource(for: episodeUUID)
    episodeDownloadQueue.insert(episodeUUID)   // <-- gate for episode_download_finished
    ...
}

Previously that insert was guaranteed to happen before addToQueue. Now addToQueue runs first and the insert lands on a later main-actor turn, so a download that completes very fast (already-present file, warm cache) can post episodeDownloaded before the UUID is in episodeDownloadQueueepisodeDownloadedNotificationReceived bails on the contains guard and episode_download_finished is dropped.

Moving addToQueue inside the hop restores the ordering, and has the bonus of getting the DownloadManager call onto a consistent actor:

Suggested change
Task { @MainActor in
AnalyticsEpisodeHelper.shared.downloaded(episodeUUID: uuid)
}
DownloadManager.shared.addToQueue(episodeUuid: uuid)
for uuid in uuids {
Task { @MainActor in
AnalyticsEpisodeHelper.shared.downloaded(episodeUUID: uuid)
DownloadManager.shared.addToQueue(episodeUuid: uuid)
}
}

If you'd rather not change when the download actually starts, a single hop wrapping the whole loop for the analytics side would work too.

Comment on lines +146 to 158
Task { @MainActor in
AnalyticsPlaybackHelper.shared.currentSource = .watch
}
if PlaybackManager.shared.isPlaying {
PlaybackManager.shared.pause()
} else {
PlaybackManager.shared.play()
}
} else if WatchConstants.Messages.SkipBackRequest.type == messageType {
AnalyticsPlaybackHelper.shared.currentSource = .watch
DispatchQueue.main.async {
Task { @MainActor in
AnalyticsPlaybackHelper.shared.currentSource = .watch
PlaybackManager.shared.skipBack()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit / consistency: SkipBackRequest below puts the source assignment and the PlaybackManager call in the same hop (good — that's the pattern PodcastViewController uses too), but PlayPauseRequest here and SkipForwardRequest leave the PlaybackManager call outside it. All three would be more obviously correct with the same shape, and it would also move PlaybackManager.shared.isPlaying off the WatchConnectivity queue:

Suggested change
Task { @MainActor in
AnalyticsPlaybackHelper.shared.currentSource = .watch
}
if PlaybackManager.shared.isPlaying {
PlaybackManager.shared.pause()
} else {
PlaybackManager.shared.play()
}
} else if WatchConstants.Messages.SkipBackRequest.type == messageType {
AnalyticsPlaybackHelper.shared.currentSource = .watch
DispatchQueue.main.async {
Task { @MainActor in
AnalyticsPlaybackHelper.shared.currentSource = .watch
PlaybackManager.shared.skipBack()
}
} else if WatchConstants.Messages.PlayPauseRequest.type == messageType {
Task { @MainActor in
AnalyticsPlaybackHelper.shared.currentSource = .watch
if PlaybackManager.shared.isPlaying {
PlaybackManager.shared.pause()
} else {
PlaybackManager.shared.play()
}
}
} else if WatchConstants.Messages.SkipBackRequest.type == messageType {
Task { @MainActor in
AnalyticsPlaybackHelper.shared.currentSource = .watch
PlaybackManager.shared.skipBack()
}

(Only worth doing if you're comfortable with play/pause now starting a turn later — the old code was synchronous here, unlike skip-back which already hopped.)

Comment on lines 2763 to +2769
if let extraProperties = properties {
baseProperties = baseProperties.merging(extraProperties, uniquingKeysWith: { current, _ in return current})
}
analyticsPlaybackHelper.track(event, properties: baseProperties)
let mergedProperties = baseProperties
Task { @MainActor in
AnalyticsPlaybackHelper.shared.track(event, properties: mergedProperties)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

let mergedProperties = baseProperties only exists to get an immutable binding for the closure capture. Making baseProperties a let in the first place is a bit tidier:

Suggested change
if let extraProperties = properties {
baseProperties = baseProperties.merging(extraProperties, uniquingKeysWith: { current, _ in return current})
}
analyticsPlaybackHelper.track(event, properties: baseProperties)
let mergedProperties = baseProperties
Task { @MainActor in
AnalyticsPlaybackHelper.shared.track(event, properties: mergedProperties)
}
func trackChapterEvent(_ event: AnalyticsEvent, properties: [String: Any]? = nil) {
let baseProperties = chapterManager.chaptersAnalyticsProperties
.merging(properties ?? [:], uniquingKeysWith: { current, _ in current })
Task { @MainActor in
AnalyticsPlaybackHelper.shared.track(event, properties: baseProperties)
}
}


// MARK: - AnalyticsPlaybackHelper Mock

@MainActor

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor: AnalyticsPlaybackHelper already inherits @MainActor from AnalyticsCoordinator, so the annotation on the subclass is redundant. (The one on the XCTestCase above is necessary, of course.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Analytics Analytics related issues Concurrency refactoring Refactoring related issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant