Skip to content

Commit 5d6740f

Browse files
authored
fix: address critical concurrency bugs and architectural anti-patterns (#77)
1 parent f82facc commit 5d6740f

16 files changed

+380
-61
lines changed

AGENTS.md

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,177 @@ swiftlint --strict && swiftformat .
189189
- Mark `@Observable` classes with `@MainActor`
190190
- Never use `DispatchQueue` — use `async`/`await`, `MainActor`
191191

192+
### Common Bug Patterns to Avoid
193+
194+
These patterns have caused bugs in this codebase. **Always check for these during code review.**
195+
196+
#### ❌ Fire-and-Forget Tasks
197+
198+
```swift
199+
// ❌ BAD: Task not tracked, errors lost, can't cancel
200+
func likeTrack() {
201+
Task { await api.like(trackId) }
202+
}
203+
204+
// ✅ GOOD: Track task, handle errors, support cancellation
205+
private var likeTask: Task<Void, Error>?
206+
207+
func likeTrack() async throws {
208+
likeTask?.cancel()
209+
likeTask = Task {
210+
try await api.like(trackId)
211+
}
212+
try await likeTask?.value
213+
}
214+
```
215+
216+
#### ❌ Optimistic Updates Without Proper Rollback
217+
218+
```swift
219+
// ❌ BAD: CancellationError not handled, cache permanently wrong
220+
func rate(_ song: Song, status: LikeStatus) async {
221+
let previous = cache[song.id]
222+
cache[song.id] = status // Optimistic update
223+
do {
224+
try await api.rate(song.id, status)
225+
} catch {
226+
cache[song.id] = previous // Doesn't run on cancellation!
227+
}
228+
}
229+
230+
// ✅ GOOD: Handle ALL errors including cancellation
231+
func rate(_ song: Song, status: LikeStatus) async {
232+
let previous = cache[song.id]
233+
cache[song.id] = status
234+
do {
235+
try await api.rate(song.id, status)
236+
} catch let error as CancellationError {
237+
cache[song.id] = previous // Rollback on cancel
238+
throw error // Propagate original cancellation
239+
} catch {
240+
cache[song.id] = previous // Rollback on error
241+
throw error
242+
}
243+
}
244+
```
245+
246+
#### ❌ Static Shared Singletons with Mutable Assignment
247+
248+
```swift
249+
// ❌ BAD: Race condition if multiple instances created
250+
class LibraryViewModel {
251+
static var shared: LibraryViewModel?
252+
init() { Self.shared = self } // Overwrites previous!
253+
}
254+
255+
// ✅ GOOD: Use SwiftUI Environment for dependency injection
256+
@Observable @MainActor
257+
class LibraryViewModel { /* ... */ }
258+
259+
// In parent view:
260+
.environment(libraryViewModel)
261+
262+
// In child view:
263+
@Environment(LibraryViewModel.self) var viewModel
264+
```
265+
266+
#### `.onAppear` Instead of `.task` for Async Work
267+
268+
```swift
269+
// ❌ BAD: Task not cancelled on disappear, can update stale view
270+
.onAppear {
271+
Task { await viewModel.load() }
272+
}
273+
274+
// ✅ GOOD: Lifecycle-managed, auto-cancelled on disappear
275+
.task {
276+
await viewModel.load()
277+
}
278+
279+
// ✅ GOOD: With ID for re-execution on change
280+
.task(id: playlistId) {
281+
await viewModel.load(playlistId)
282+
}
283+
```
284+
285+
#### ❌ ForEach with Unstable Identity
286+
287+
```swift
288+
// ❌ BAD: Index-based identity causes wrong views during mutations
289+
ForEach(tracks.indices, id: \.self) { index in
290+
TrackRow(track: tracks[index])
291+
}
292+
293+
// ❌ BAD: Array enumeration recreates identity on every change
294+
ForEach(Array(tracks.enumerated()), id: \.offset) { index, track in
295+
TrackRow(track: track, rank: index + 1)
296+
}
297+
298+
// ✅ GOOD: Use stable model identity
299+
ForEach(tracks) { track in
300+
TrackRow(track: track)
301+
}
302+
303+
// ✅ GOOD: If you need index for display (charts), use element ID
304+
ForEach(Array(tracks.enumerated()), id: \.element.id) { index, track in
305+
TrackRow(track: track, rank: index + 1)
306+
}
307+
```
308+
309+
#### ❌ Background Tasks Not Cancelled on Deinit
310+
311+
```swift
312+
// ❌ BAD: Task continues after ViewModel is deallocated
313+
@Observable @MainActor
314+
class HomeViewModel {
315+
private var backgroundTask: Task<Void, Never>?
316+
317+
func startLoading() {
318+
backgroundTask = Task { /* ... */ }
319+
}
320+
// Missing deinit cleanup!
321+
}
322+
323+
// ✅ GOOD: Cancel tasks in deinit
324+
@Observable @MainActor
325+
class HomeViewModel {
326+
private var backgroundTask: Task<Void, Never>?
327+
328+
func startLoading() {
329+
backgroundTask?.cancel()
330+
backgroundTask = Task { [weak self] in
331+
guard !Task.isCancelled else { return }
332+
// ...
333+
}
334+
}
335+
336+
deinit {
337+
backgroundTask?.cancel()
338+
}
339+
}
340+
```
341+
342+
#### ❌ Shared Continuation Tokens Across Different Requests
343+
344+
```swift
345+
// ❌ BAD: Single token for all search types causes conflicts
346+
class YTMusicClient {
347+
private var searchContinuationToken: String? // Shared!
348+
349+
func searchSongs() { /* sets token */ }
350+
func searchAlbums() { /* overwrites token! */ }
351+
}
352+
353+
// ✅ GOOD: Scope tokens by request type or return in response
354+
class YTMusicClient {
355+
private var continuationTokens: [String: String] = [:]
356+
357+
func searchSongs() -> (songs: [Song], continuation: String?) {
358+
// Return token with response, let caller manage
359+
}
360+
}
361+
```
362+
192363
### WebKit Patterns
193364

194365
- Use `WebKitManager`'s shared `WKWebsiteDataStore` for cookie persistence
@@ -264,6 +435,19 @@ Before completing non-trivial features, verify:
264435
- [ ] ForEach uses stable identity (avoid `Array(enumerated())` unless rank is needed)
265436
- [ ] Frequently updating UI (e.g., progress) caches formatted strings
266437

438+
## Concurrency Safety Checklist
439+
440+
Before completing features with async code, verify:
441+
442+
- [ ] No fire-and-forget `Task { }` without error handling
443+
- [ ] Optimistic updates handle `CancellationError` explicitly
444+
- [ ] Background tasks cancelled in `deinit`
445+
- [ ] Using `.task` instead of `.onAppear { Task { } }`
446+
- [ ] Continuation tokens scoped per-request (not shared across types)
447+
- [ ] No `static var shared` pattern with mutable assignment in `init`
448+
- [ ] WebView message handlers removed in `dismantleNSView`
449+
- [ ] `WKNavigationDelegate` implements `webViewWebContentProcessDidTerminate`
450+
267451
> 📚 See [docs/architecture.md#performance-guidelines](docs/architecture.md#performance-guidelines) for detailed patterns.
268452
269453
## Task Planning: Phases with Exit Criteria

App/AppDelegate.swift

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,47 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
2929
try? await Task.sleep(for: .milliseconds(500))
3030
self.setupWindowDelegate()
3131
}
32+
33+
// Register for system sleep/wake notifications
34+
self.registerForSleepWakeNotifications()
35+
}
36+
37+
/// Registers for system sleep and wake notifications to handle playback appropriately.
38+
private func registerForSleepWakeNotifications() {
39+
let notificationCenter = NSWorkspace.shared.notificationCenter
40+
41+
notificationCenter.addObserver(
42+
self,
43+
selector: #selector(self.systemWillSleep),
44+
name: NSWorkspace.willSleepNotification,
45+
object: nil
46+
)
47+
48+
notificationCenter.addObserver(
49+
self,
50+
selector: #selector(self.systemDidWake),
51+
name: NSWorkspace.didWakeNotification,
52+
object: nil
53+
)
54+
}
55+
56+
/// Tracks whether audio was playing before system sleep (for resume on wake).
57+
private var wasPlayingBeforeSleep: Bool = false
58+
59+
@objc private func systemWillSleep(_: Notification) {
60+
// Remember playback state and pause before sleep
61+
self.wasPlayingBeforeSleep = self.playerService?.isPlaying ?? false
62+
if self.wasPlayingBeforeSleep {
63+
DiagnosticsLogger.player.info("System going to sleep, pausing playback")
64+
SingletonPlayerWebView.shared.pause()
65+
}
66+
}
67+
68+
@objc private func systemDidWake(_: Notification) {
69+
// Optionally resume playback after wake if it was playing before sleep
70+
// Note: We don't auto-resume by default as it could be surprising
71+
// Just log the wake event for now
72+
DiagnosticsLogger.player.info("System woke from sleep, wasPlayingBeforeSleep: \(self.wasPlayingBeforeSleep)")
3273
}
3374

3475
func applicationDidBecomeActive(_: Notification) {

Core/Services/API/YTMusicClient.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1090,6 +1090,7 @@ final class YTMusicClient: YTMusicClientProtocol {
10901090
"target": ["playlistId": playlistId],
10911091
]
10921092

1093+
self.logger.debug("Calling like/removelike with playlistId=\(playlistId)")
10931094
_ = try await self.request("like/removelike", body: body)
10941095
self.logger.info("Successfully unsubscribed from podcast \(showId)")
10951096

Core/Services/FavoritesManager.swift

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ final class FavoritesManager {
2929
return kasetDir.appendingPathComponent("favorites.json")
3030
}
3131

32+
/// Task for the current save operation - cancelled when new save is triggered.
33+
private var saveTask: Task<Void, Never>?
34+
3235
// MARK: - Initialization
3336

3437
private init() {
@@ -73,18 +76,28 @@ final class FavoritesManager {
7376

7477
/// Saves items to disk asynchronously on a background thread.
7578
/// Captures current state and writes without blocking the main thread.
79+
/// Cancels any pending save and debounces to coalesce rapid changes.
7680
/// Test instances (skipPersistence=true) never write to disk.
7781
private func save() {
7882
// Skip persistence for test instances to avoid overwriting user data
7983
guard !self.skipPersistence else { return }
8084

85+
// Cancel any pending save to avoid race conditions
86+
self.saveTask?.cancel()
87+
8188
// Capture current state for background write
8289
let itemsSnapshot = self.items
8390
let targetURL = self.fileURL
8491

85-
// Perform disk I/O off the main actor.
86-
// Fire-and-forget: failures are logged but not propagated.
87-
Task(priority: .utility) {
92+
// Perform disk I/O off the main actor with debounce.
93+
// Slight delay coalesces rapid successive saves.
94+
self.saveTask = Task(priority: .utility) {
95+
// Debounce: wait briefly to coalesce rapid changes
96+
try? await Task.sleep(for: .milliseconds(100))
97+
98+
// Check if cancelled (a newer save superseded this one)
99+
guard !Task.isCancelled else { return }
100+
88101
do {
89102
// Ensure directory exists
90103
let directory = targetURL.deletingLastPathComponent()

Core/Services/SongLikeStatusManager.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,14 @@ final class SongLikeStatusManager {
9999
do {
100100
try await client.rateSong(videoId: song.videoId, rating: status)
101101
DiagnosticsLogger.api.info("Rated song \(song.videoId) as \(status.rawValue)")
102+
} catch is CancellationError {
103+
// Task was cancelled - rollback optimistic update
104+
if let previous = previousStatus {
105+
self.statusCache[song.videoId] = previous
106+
} else {
107+
self.statusCache.removeValue(forKey: song.videoId)
108+
}
109+
DiagnosticsLogger.api.debug("Rating cancelled for song \(song.videoId), rolled back")
102110
} catch {
103111
// Revert on failure
104112
if let previous = previousStatus {

Core/ViewModels/LibraryViewModel.swift

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,6 @@ import os
66
@MainActor
77
@Observable
88
final class LibraryViewModel {
9-
/// Shared instance for checking library status from other views.
10-
static var shared: LibraryViewModel?
11-
129
/// Current loading state.
1310
private(set) var loadingState: LoadingState = .idle
1411

@@ -36,8 +33,6 @@ final class LibraryViewModel {
3633

3734
init(client: any YTMusicClientProtocol) {
3835
self.client = client
39-
// Set shared instance for global access
40-
LibraryViewModel.shared = self
4136
}
4237

4338
/// Checks if a playlist is in the user's library.

Tools/api-explorer.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,7 @@ let authRequiredEndpoints = Set([
327327
"FEmusic_library_albums",
328328
"FEmusic_library_artists",
329329
"FEmusic_library_songs",
330+
"FEmusic_library_non_music_audio_list",
330331
"FEmusic_recently_played",
331332
"FEmusic_offline",
332333
"FEmusic_library_privately_owned_landing",

Views/macOS/LibraryView.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ struct LibraryView: View {
2727
@State var viewModel: LibraryViewModel
2828
@Environment(PlayerService.self) private var playerService
2929
@Environment(FavoritesManager.self) private var favoritesManager
30+
@Environment(LibraryViewModel.self) private var libraryViewModelEnv: LibraryViewModel?
3031
@State private var networkMonitor = NetworkMonitor.shared
3132

3233
@State private var navigationPath = NavigationPath()
@@ -66,8 +67,9 @@ struct LibraryView: View {
6667
)
6768
)
6869
}
69-
.navigationDestination(for: PodcastShow.self) { show in
70+
.navigationDestination(for: PodcastShow.self) { [libraryViewModelEnv] show in
7071
PodcastShowView(show: show, client: self.viewModel.client)
72+
.environment(libraryViewModelEnv)
7173
}
7274
}
7375
.safeAreaInset(edge: .bottom, spacing: 0) {

Views/macOS/LikedMusicView.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,7 @@ struct LikedMusicView: View {
8989
if self.viewModel.songs.isEmpty {
9090
self.emptyStateView
9191
} else {
92-
ForEach(self.viewModel.songs.indices, id: \.self) { index in
93-
let song = self.viewModel.songs[index]
92+
ForEach(Array(self.viewModel.songs.enumerated()), id: \.element.id) { index, song in
9493
self.songRow(song, index: index)
9594
.onAppear {
9695
// Load more when reaching the last few items

0 commit comments

Comments
 (0)