Skip to content

refactor(state): extract observable stores for better SwiftUI performance#6

Closed
tanRdev wants to merge 4 commits intomasterfrom
eng/performance-improvements
Closed

refactor(state): extract observable stores for better SwiftUI performance#6
tanRdev wants to merge 4 commits intomasterfrom
eng/performance-improvements

Conversation

@tanRdev
Copy link
Copy Markdown
Owner

@tanRdev tanRdev commented Mar 7, 2026

Summary

Refactored the monolithic ProcessMonitor into three specialized @Observable stores to reduce unnecessary SwiftUI view updates and improve filtering performance.

Architecture Changes

Before: Single ProcessMonitor class managing all state, causing every view to re-render on any state change.

After: Composed architecture with granular observation:

  • FilterState - Centralized filter/search management
  • ProcessStore - Process data with pre-computed lookups
  • SystemStatsStore - System metrics with history

Performance Improvements

Operation Before After
Port lookups O(n×m) array scan O(1) via portProcessMap
Filter recalculation Every keystroke Debounced 300ms
Filter counts Computed each render Pre-computed on update

New Features

  • Search Debouncing: 300ms delay prevents excessive filter operations during typing
  • History Sparklines: CPU/memory metrics display 12-point visual history
  • MiniSparkline Component: Reusable SwiftUI view for metric cards

Files Changed

New Store Classes:

  • Services/FilterState.swift - Filter/search state with debouncing
  • Services/ProcessStore.swift - Process list, port mapping, filter counts
  • Services/SystemStatsStore.swift - System metrics with history arrays

New Test Files:

  • LucidTests/FilterStateTests.swift
  • LucidTests/ProcessStoreTests.swift
  • LucidTests/SystemStatsStoreTests.swift
  • LucidTests/PerformanceTests.swift

Modified Views:

  • ProcessMonitor.swift - Composes three stores, proxy properties for backward compatibility
  • LucidApp.swift - Injects all stores into SwiftUI environment
  • ContentView.swift - Uses FilterState for filtering
  • HeaderBar.swift - SearchField bound to FilterState.searchText
  • SidebarView.swift - Uses ProcessStore for filter counts, port lookups
  • MetricsRowView.swift - Uses SystemStatsStore for metrics
  • MetricCardView.swift - Added MiniSparkline component

Thread Safety

All stores marked @MainActor with proper weak self captures to prevent retain cycles. History arrays capped at 12 entries to prevent unbounded growth.

…ance

Split monolithic ProcessMonitor into three specialized @observable stores
to reduce unnecessary view updates and improve filtering performance:

- FilterState: centralized filter/search management with 300ms debounce
- ProcessStore: process data with pre-computed portProcessMap for O(1) lookups
- SystemStatsStore: system metrics with history for sparkline visualizations

Key improvements:
- Port lookups: O(1) via portProcessMap instead of O(n*m) array scans
- Search debouncing: prevents excessive filter operations during typing
- Granular observation: views only re-render when their specific store changes
- History sparklines: CPU/memory metrics now show 12-point history
- Pre-computed filter counts: no recalculation on every view update

All stores marked @mainactor for thread safety. Includes comprehensive unit
tests for each store plus performance/concurrency tests.
@tanRdev tanRdev force-pushed the eng/performance-improvements branch from 588aaf3 to fb584de Compare March 7, 2026 05:53
@tanRdev
Copy link
Copy Markdown
Owner Author

tanRdev commented Mar 7, 2026

@greptile review

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Mar 7, 2026

Greptile Summary

This PR decomposes the monolithic ProcessMonitor into three focused @Observable @MainActor stores (FilterState, ProcessStore, SystemStatsStore), which is a sound architectural improvement. By splitting observation boundaries, SwiftUI views now re-render only when the specific data they consume changes — for example, DetailView no longer re-renders on every keystroke because it reads debouncedSearchText rather than searchText. The O(1) portProcessMap lookup and pre-computed filterCounts are genuine wins. The MiniSparkline component and 12-point history arrays are clean additions.

Key issues found:

  • Double-sort on every refresh (ProcessMonitor.swift:186): refresh() calls .sorted() on newProcesses before handing off to updateProcesses, which sorts the same array again internally — wasting O(n log n) on every 2-second poll cycle, directly contradicting the PR's performance goals.
  • Debounce cancellation anti-pattern (FilterState.swift:80): try? await Task.sleep(for:) silently swallows the CancellationError, then manually checks Task.isCancelled. The idiomatic approach is try await Task.sleep(for:) so cancellation propagates naturally without the redundant guard.
  • Dead cleanup code in removeProcess (ProcessStore.swift:61–72): Manual removal of entries from processByPid, portProcessMap, and activePorts is immediately overwritten by the updateProcesses(processes) call on line 75 that rebuilds all three from scratch.
  • Misleading thread-safety test (PerformanceTests.swift:73): testConcurrentAccessToProcessStore runs all tasks on the main actor serially (because ProcessStore is @MainActor), so it does not actually exercise concurrent access despite its intent and comment.

Confidence Score: 4/5

  • Safe to merge after addressing the double-sort regression and the debounce cancellation pattern.
  • The architecture is well-designed and the SwiftUI observation model is used correctly. The @MainActor isolation is consistent across all stores, preventing data races. The only true performance regression (double-sort) is easy to fix with a one-line change, and the debounce anti-pattern is a style concern that happens to be functionally correct. The redundant removeProcess cleanup and misleading test comment are minor. No data loss or crash-level bugs were found.
  • Lucid/Lucid/Services/ProcessMonitor.swift (double-sort regression) and Lucid/Lucid/Services/FilterState.swift (debounce cancellation pattern).

Important Files Changed

Filename Overview
Lucid/Lucid/Services/FilterState.swift New observable store for filter/search state with 300ms debouncing. Core logic is correct, but the debounce task uses try? to swallow cancellation errors then checks Task.isCancelled manually — an anti-pattern that should use try await and natural propagation instead.
Lucid/Lucid/Services/ProcessStore.swift New observable store for process data with O(1) port lookups via portProcessMap. The removeProcess method performs a full manual cleanup of processByPid, portProcessMap, and activePorts, then immediately overwrites all of it by calling updateProcesses, making the manual cleanup dead code.
Lucid/Lucid/Services/SystemStatsStore.swift New observable store for system metrics with 12-point history arrays for sparklines. History trimming, average/peak calculations, and formatted output are all correct. History cap uses removeFirst which is O(n) but bounded to a fixed small window — acceptable here.
Lucid/Lucid/Services/ProcessMonitor.swift Refactored to compose three sub-stores. Contains a double-sort bug: refresh() sorts newProcesses before passing to processStore.updateProcesses(), which sorts the same array again internally — wasting O(n log n) on every 2-second poll cycle in a PR explicitly targeting performance.
Lucid/Lucid/ContentView.swift Migrated to consume ProcessStore and FilterState from the environment. filteredProcesses correctly accesses only debouncedSearchText/sortOrder/selectedFilter, so typing (changing searchText) no longer causes this view to re-render — the core performance goal.
Lucid/LucidTests/PerformanceTests.swift Performance and debounce tests are well-structured, but testConcurrentAccessToProcessStore is misleading — since ProcessStore is @MainActor, all tasks are serialized and the test doesn't actually exercise true concurrent access.

Sequence Diagram

sequenceDiagram
    participant Timer as DispatchSourceTimer
    participant PM as ProcessMonitor
    participant PS as ProcessStore
    participant SS as SystemStatsStore
    participant FS as FilterState
    participant DV as DetailView
    participant HB as HeaderBar

    Timer->>PM: fire (every 2s)
    PM->>PM: refresh() — collect PIDs, CPU, ports
    PM->>PS: updateProcesses(finalProcesses)
    PS->>PS: sort + build portProcessMap + filterCounts
    PM->>SS: updateStats(SystemStats)
    SS->>SS: append to cpuHistory / memoryHistory (cap 12)

    Note over HB: User types in SearchField
    HB->>FS: searchText = "chro..."
    FS->>FS: cancel previous debounceTask
    FS->>FS: new Task.sleep(300ms)
    Note over DV: searchText change → no re-render (not observed)

    FS->>FS: debouncedSearchText = "chro..."
    DV->>FS: filter(processStore.processes)
    FS-->>DV: filtered + sorted [LucidProcess]
Loading

Last reviewed commit: fb584de

}
}

let finalProcesses = newProcesses.sorted()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Double-sort on every refresh cycle

newProcesses.sorted() is called here to produce finalProcesses, but processStore.updateProcesses(finalProcesses) sorts the already-sorted array again on its first line (let sorted = newProcesses.sorted()). Since this PR is specifically targeting performance improvements, this unnecessary O(n log n) sort on every 2-second refresh cycle is a direct regression against stated goals.

Either remove the sort here and let updateProcesses own all sorting:

Suggested change
let finalProcesses = newProcesses.sorted()
let finalProcesses = newProcesses

Or remove the sort inside updateProcesses and trust that callers pre-sort. The current code does both.

Comment thread Lucid/Lucid/Services/FilterState.swift Outdated
Comment on lines +80 to +84
try? await Task.sleep(for: self.debounceInterval)

guard !Task.isCancelled else { return }

self.debouncedSearchText = self.searchText
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Anti-pattern: try? swallows cancellation, then manually checks Task.isCancelled

Using try? await Task.sleep(for:) discards the CancellationError that Task.sleep throws when cancelled. Execution then falls through to the guard !Task.isCancelled check, which happens to work — but only because Task.isCancelled is still true after try? consumed the error. This is fragile: the intent of the cancellation (stop the task) is expressed twice, redundantly.

The idiomatic Swift structured concurrency approach is to let the cancellation propagate naturally by NOT silencing the error:

Suggested change
try? await Task.sleep(for: self.debounceInterval)
guard !Task.isCancelled else { return }
self.debouncedSearchText = self.searchText
try await Task.sleep(for: self.debounceInterval)
self.debouncedSearchText = self.searchText

With try await (no try?), a cancelled task exits the Task body automatically when sleep throws — no explicit guard !Task.isCancelled needed.

Comment thread Lucid/Lucid/Services/ProcessStore.swift Outdated
Comment on lines +61 to +75
func removeProcess(_ process: LucidProcess) {
processes.removeAll { $0.pid == process.pid }
processByPid.removeValue(forKey: process.pid)

// Update port map
for port in process.ports {
portProcessMap[port]?.removeAll { $0 == process.pid }
if portProcessMap[port]?.isEmpty == true {
portProcessMap.removeValue(forKey: port)
activePorts.removeAll { $0 == port }
}
}

// Recalculate counts
updateProcesses(processes)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant manual cleanup before full rebuild

Lines 62–72 manually remove the process from processByPid, clean the portProcessMap entries, and update activePorts. However, line 75 then calls updateProcesses(processes), which rebuilds all three of these data structures from scratch — making every line of manual cleanup dead code that has zero net effect.

This creates a readability hazard: future readers may assume the intermediate state matters (e.g. for an observer or binding), when it does not. Consider simplifying to just the two lines that actually have lasting effect:

func removeProcess(_ process: LucidProcess) {
    processes.removeAll { $0.pid == process.pid }
    updateProcesses(processes)
}

Comment thread Lucid/LucidTests/PerformanceTests.swift Outdated
Comment on lines +73 to +98
func testConcurrentAccessToProcessStore() async {
let processes = generateProcesses(count: 100)
processStore.updateProcesses(processes)

await withTaskGroup(of: Void.self) { group in
// Concurrent reads
for _ in 0..<100 {
group.addTask {
_ = self.processStore.processes
_ = self.processStore.activePorts
_ = self.processStore.filterCounts
}
}

// Concurrent writes
for i in 0..<10 {
group.addTask {
let newProcesses = self.generateProcesses(count: 100 + i)
self.processStore.updateProcesses(newProcesses)
}
}
}

// If we get here without crash, thread safety is working
XCTAssertTrue(true)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thread safety test is trivially serialized — it doesn't test concurrency

ProcessStore is @MainActor, which means every group.addTask { ... } in this test is scheduled on the main actor's serial executor. The "concurrent reads" and "concurrent writes" are therefore never actually concurrent — they are interleaved in an unspecified order, but never simultaneous.

The comment // If we get here without crash, thread safety is working is misleading: this test passes purely because the main actor serializes all access, not because ProcessStore has any concurrency-safe internals. A true data race (e.g. a non-@MainActor caller accessing processes directly) would not be caught by this test. Consider either removing the misleading comment or replacing it with a note explaining that thread safety is guaranteed by @MainActor isolation, not by this test.

tanRdev added 3 commits March 7, 2026 04:12
- Remove redundant .sorted() call in ProcessMonitor.refresh() - updateProcesses()
  already sorts internally, saving O(n log n) per poll cycle

- Fix debounce cancellation anti-pattern in FilterState - use try await with
  proper CancellationError handling instead of manual isCancelled check

- Remove dead cleanup code in ProcessStore.removeProcess() - manual updates
  were immediately overwritten by updateProcesses() call

- Rename misleading test and add clarifying comment - tests @mainactor
  isolation safety, not true concurrency
- Make ProcessMonitor.init() nonisolated to allow initialization
  from non-MainActor contexts (SwiftUI @State property wrappers)
- Add @mainactor to killButton(for:) and killDialogMessage(for:)
  in SidebarView to properly access MainActor-isolated stores
@tanRdev tanRdev closed this Mar 7, 2026
@tanRdev tanRdev deleted the eng/performance-improvements branch March 7, 2026 09:26
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