From fb584de9f28204c985f830ec901707280ae0d299 Mon Sep 17 00:00:00 2001 From: Tan Date: Sat, 7 Mar 2026 00:46:49 -0500 Subject: [PATCH 1/4] refactor(state): extract observable stores for better SwiftUI performance 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. --- Lucid/Lucid/ContentView.swift | 59 ++--- Lucid/Lucid/LucidApp.swift | 9 +- Lucid/Lucid/Services/FilterState.swift | 87 +++++++ Lucid/Lucid/Services/ProcessMonitor.swift | 128 ++++------ Lucid/Lucid/Services/ProcessStore.swift | 77 ++++++ Lucid/Lucid/Services/SystemStatsStore.swift | 71 ++++++ Lucid/Lucid/Views/Content/HeaderBar.swift | 62 +++-- .../Views/Dashboard/MetricCardView.swift | 42 ++++ .../Views/Dashboard/MetricsRowView.swift | 17 +- Lucid/Lucid/Views/Sidebar/SidebarView.swift | 221 ++++++++++-------- Lucid/LucidTests/FilterStateTests.swift | 122 ++++++++++ Lucid/LucidTests/PerformanceTests.swift | 152 ++++++++++++ Lucid/LucidTests/ProcessStoreTests.swift | 163 +++++++++++++ Lucid/LucidTests/SystemStatsStoreTests.swift | 149 ++++++++++++ 14 files changed, 1101 insertions(+), 258 deletions(-) create mode 100644 Lucid/Lucid/Services/FilterState.swift create mode 100644 Lucid/Lucid/Services/ProcessStore.swift create mode 100644 Lucid/Lucid/Services/SystemStatsStore.swift create mode 100644 Lucid/LucidTests/FilterStateTests.swift create mode 100644 Lucid/LucidTests/PerformanceTests.swift create mode 100644 Lucid/LucidTests/ProcessStoreTests.swift create mode 100644 Lucid/LucidTests/SystemStatsStoreTests.swift diff --git a/Lucid/Lucid/ContentView.swift b/Lucid/Lucid/ContentView.swift index 30913f6..d01d62b 100644 --- a/Lucid/Lucid/ContentView.swift +++ b/Lucid/Lucid/ContentView.swift @@ -24,19 +24,23 @@ struct ContentView: View { struct DetailView: View { @Environment(ProcessMonitor.self) var monitor - @State private var searchText = "" - @State private var sortOrder: [KeyPathComparator] = [ - .init(\.cpuUsage, order: .reverse) - ] + @Environment(ProcessStore.self) var processStore + @Environment(FilterState.self) var filterState + @State private var killTarget: LucidProcess? @State private var multiKillTargets: [LucidProcess] = [] @State private var selection = Set() @State private var killError: String? - private var filterBinding: Binding { + // Computed filtered processes - now delegated to FilterState + var filteredProcesses: [LucidProcess] { + filterState.filter(processStore.processes) + } + + private var sortOrderBinding: Binding<[KeyPathComparator]> { Binding( - get: { monitor.selectedFilter }, - set: { monitor.selectedFilter = $0 } + get: { filterState.sortOrder }, + set: { filterState.applySortOrder($0) } ) } @@ -57,46 +61,11 @@ struct DetailView: View { ) } - var filteredProcesses: [LucidProcess] { - var result = monitor.processes - - // Apply filter - switch monitor.selectedFilter { - case .all: - break - case .system: - result = result.filter { $0.safety == .system } - case .user: - result = result.filter { $0.safety == .user } - case .unknown: - result = result.filter { $0.safety == .unknown } - case .port(let port): - result = result.filter { $0.ports.contains(port) } - } - - // Apply search - if !searchText.isEmpty { - result = result.filter { process in - process.name.localizedCaseInsensitiveContains(searchText) || - process.description.localizedCaseInsensitiveContains(searchText) - } - } - - // Apply sort - result.sort(using: sortOrder) - - return result - } - var body: some View { VStack(spacing: 0) { - HeaderBar( - processCount: filteredProcesses.count, - searchText: $searchText, - selectedFilter: filterBinding - ) + HeaderBar(processCount: filteredProcesses.count) - Table(filteredProcesses, selection: $selection, sortOrder: $sortOrder) { + Table(filteredProcesses, selection: $selection, sortOrder: sortOrderBinding) { TableColumn("Name", value: \.name) { process in Text(process.name) .font(.system(.body, design: .monospaced)) @@ -251,4 +220,4 @@ struct DetailView: View { Text("Are you sure you want to kill \(multiKillTargets.count) processes?") } } -} +} \ No newline at end of file diff --git a/Lucid/Lucid/LucidApp.swift b/Lucid/Lucid/LucidApp.swift index 25a408c..2c096b8 100644 --- a/Lucid/Lucid/LucidApp.swift +++ b/Lucid/Lucid/LucidApp.swift @@ -9,6 +9,9 @@ struct LucidApp: App { WindowGroup { ContentView() .environment(monitor) + .environment(monitor.processStore) + .environment(monitor.statsStore) + .environment(monitor.filterState) .frame(minWidth: 1100, minHeight: 750) .onAppear { monitor.start() @@ -25,14 +28,14 @@ struct LucidApp: App { forName: NSApplication.didResignActiveNotification, object: nil, queue: .main ) { _ in - monitor.stop() + Task { @MainActor in monitor.stop() } }, NotificationCenter.default.addObserver( forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main ) { _ in - monitor.start() + Task { @MainActor in monitor.start() } } ] } -} +} \ No newline at end of file diff --git a/Lucid/Lucid/Services/FilterState.swift b/Lucid/Lucid/Services/FilterState.swift new file mode 100644 index 0000000..90aab95 --- /dev/null +++ b/Lucid/Lucid/Services/FilterState.swift @@ -0,0 +1,87 @@ +import SwiftUI +import Observation + +@Observable +@MainActor +final class FilterState { + // MARK: - State + var selectedFilter: FilterCategory = .all + var searchText: String = "" { + didSet { + handleSearchTextChange() + } + } + var debouncedSearchText: String = "" + var sortOrder: [KeyPathComparator] = [ + .init(\.cpuUsage, order: .reverse) + ] + + // MARK: - Private State + private var debounceTask: Task? + private let debounceInterval: Duration = .milliseconds(300) + + // MARK: - Public Methods + + func applyFilter(_ filter: FilterCategory) { + selectedFilter = filter + } + + func clearSearch() { + searchText = "" + debouncedSearchText = "" + debounceTask?.cancel() + } + + func applySortOrder(_ order: [KeyPathComparator]) { + sortOrder = order + } + + // MARK: - Filtering + + func filter(_ processes: [LucidProcess]) -> [LucidProcess] { + var result = processes + + // Apply category filter + switch selectedFilter { + case .all: + break + case .system: + result = result.filter { $0.safety == .system } + case .user: + result = result.filter { $0.safety == .user } + case .unknown: + result = result.filter { $0.safety == .unknown } + case .port(let port): + result = result.filter { $0.ports.contains(port) } + } + + // Apply search filter (using debounced value) + if !debouncedSearchText.isEmpty { + result = result.filter { process in + process.name.localizedCaseInsensitiveContains(debouncedSearchText) || + process.description.localizedCaseInsensitiveContains(debouncedSearchText) + } + } + + // Apply sort + result.sort(using: sortOrder) + + return result + } + + // MARK: - Private Methods + + private func handleSearchTextChange() { + debounceTask?.cancel() + + debounceTask = Task { [weak self] in + guard let self else { return } + + try? await Task.sleep(for: self.debounceInterval) + + guard !Task.isCancelled else { return } + + self.debouncedSearchText = self.searchText + } + } +} \ No newline at end of file diff --git a/Lucid/Lucid/Services/ProcessMonitor.swift b/Lucid/Lucid/Services/ProcessMonitor.swift index 797e50f..936a96e 100644 --- a/Lucid/Lucid/Services/ProcessMonitor.swift +++ b/Lucid/Lucid/Services/ProcessMonitor.swift @@ -3,39 +3,34 @@ import AppKit import os @Observable +@MainActor final class ProcessMonitor { - // MARK: - Observable State - var processes: [LucidProcess] = [] - var stats: SystemStats = SystemStats( - cpuUsage: 0, - memoryUsage: 0, - memoryBytes: 0, - totalMemoryBytes: 0, - timestamp: Date() - ) + // MARK: - Composed Stores (New Architecture) + let processStore = ProcessStore() + let statsStore = SystemStatsStore() + let filterState = FilterState() + + // MARK: - Proxy Properties (for backward compatibility during migration) + var processes: [LucidProcess] { processStore.processes } + var stats: SystemStats { statsStore.stats } + var selectedFilter: FilterCategory { + get { filterState.selectedFilter } + set { filterState.applyFilter(newValue) } + } + var filterCounts: ProcessStore.FilterCounts { processStore.filterCounts } + var activePorts: [UInt16] { processStore.activePorts } + + // MARK: - Observable State (remaining in monitor) var isRunning = false var lastError: String? - var selectedFilter: FilterCategory = .all - var filterCounts = FilterCounts() - var activePorts: [UInt16] = [] - - struct FilterCounts { - var total: Int = 0 - var system: Int = 0 - var user: Int = 0 - var unknown: Int = 0 - } // MARK: - Private State private var timer: DispatchSourceTimer? private var previousCPUTimes: [pid_t: UInt64] = [:] - private var previousCPUHistory: [Double] = [] - private var previousMemoryHistory: [Double] = [] private let pollInterval: TimeInterval = 2.0 private let logger = Logger(subsystem: "com.tan.lucid", category: "ProcessMonitor") private let timerQueue = DispatchQueue(label: "com.tan.lucid.timer", qos: .userInitiated) - // Thread-safe refresh guard private let refreshLock = NSLock() private var _isRefreshing = false private var isRefreshing: Bool { @@ -43,10 +38,7 @@ final class ProcessMonitor { set { refreshLock.withLock { _isRefreshing = newValue } } } - // LLM service for process identification private let llmService = LLMService() - - // Cached NSWorkspace app names (refreshed every other cycle) private var appNameCache: [pid_t: String] = [:] private var shouldRefreshAppNames = true @@ -64,7 +56,9 @@ final class ProcessMonitor { let newTimer = DispatchSource.makeTimerSource(queue: timerQueue) newTimer.schedule(deadline: .now() + pollInterval, repeating: pollInterval) newTimer.setEventHandler { [weak self] in - self?.refresh() + Task { @MainActor [weak self] in + self?.refresh() + } } newTimer.resume() timer = newTimer @@ -76,22 +70,16 @@ final class ProcessMonitor { timer = nil } - deinit { - stop() - } - - // MARK: - Process Management + // MARK: - Refresh (Orchestrates Store Updates) func refresh() { Task { [weak self] in guard let self else { return } guard !self.isRefreshing else { return } self.isRefreshing = true - defer { - self.isRefreshing = false - } + defer { self.isRefreshing = false } - // Refresh NSWorkspace app names every other cycle to reduce MainActor blocking + // Refresh app name cache every other cycle self.shouldRefreshAppNames.toggle() if self.shouldRefreshAppNames || self.appNameCache.isEmpty { self.appNameCache = await MainActor.run { @@ -117,7 +105,7 @@ final class ProcessMonitor { let prevCPUTimes = self.previousCPUTimes let llm = self.llmService - // Batch PIDs into chunks to reduce task overhead (~8 tasks instead of ~400) + // Batch PIDs into chunks let chunkSize = 50 let pidChunks = stride(from: 0, to: pids.count, by: chunkSize).map { Array(pids[$0.. Result { - // Verify the PID still refers to the same process before killing guard let currentName = DarwinProcess.getProcessName(pid: process.pid), currentName == process.name else { return .failure(.failedToKill(pid: process.pid, description: "Process no longer exists or has changed")) @@ -241,6 +215,10 @@ final class ProcessMonitor { return errors.isEmpty ? .success(()) : .failure(KillErrors(errors: errors)) } + func processes(for port: UInt16) -> [pid_t] { + processStore.processes(for: port) + } + struct KillErrors: Error { let errors: [String] var localizedDescription: String { @@ -250,46 +228,22 @@ final class ProcessMonitor { // MARK: - Private Helpers - @MainActor private func updateSystemStats() { let totalMemory = ProcessInfo.processInfo.physicalMemory - let usedMemory = calculateUsedMemory() - let totalCPU = calculateAverageCPU() - + let usedMemory = processes.reduce(0) { $0 + $1.memoryBytes } + let totalCPU = processes.reduce(0.0) { $0 + $1.cpuUsage } + let coreCount = Double(ProcessInfo.processInfo.activeProcessorCount) + let averageCPU = min(totalCPU / coreCount, 100.0) let memoryPercent = (Double(usedMemory) / Double(totalMemory)) * 100 - var cpuHistory = previousCPUHistory - cpuHistory.append(totalCPU) - if cpuHistory.count > 12 { - cpuHistory.removeFirst() - } - previousCPUHistory = cpuHistory - - var memoryHistory = previousMemoryHistory - memoryHistory.append(memoryPercent) - if memoryHistory.count > 12 { - memoryHistory.removeFirst() - } - previousMemoryHistory = memoryHistory - - stats = SystemStats( - cpuUsage: totalCPU, + let stats = SystemStats( + cpuUsage: averageCPU, memoryUsage: memoryPercent, memoryBytes: usedMemory, totalMemoryBytes: totalMemory, timestamp: Date() ) - stats.cpuHistory = cpuHistory - stats.memoryHistory = memoryHistory - } - - private func calculateUsedMemory() -> UInt64 { - processes.reduce(0) { $0 + $1.memoryBytes } - } - private func calculateAverageCPU() -> Double { - let totalCPU = processes.reduce(0.0) { $0 + $1.cpuUsage } - let coreCount = Double(ProcessInfo.processInfo.activeProcessorCount) - return min(totalCPU / coreCount, 100.0) + statsStore.updateStats(stats) } -} +} \ No newline at end of file diff --git a/Lucid/Lucid/Services/ProcessStore.swift b/Lucid/Lucid/Services/ProcessStore.swift new file mode 100644 index 0000000..bd1fea1 --- /dev/null +++ b/Lucid/Lucid/Services/ProcessStore.swift @@ -0,0 +1,77 @@ +import Foundation +import Observation + +@Observable +@MainActor +final class ProcessStore { + // MARK: - Observable State + var processes: [LucidProcess] = [] + var activePorts: [UInt16] = [] + var filterCounts = FilterCounts() + + // MARK: - Derived State (Pre-computed for performance) + private(set) var portProcessMap: [UInt16: [pid_t]] = [:] + private var processByPid: [pid_t: LucidProcess] = [:] + + struct FilterCounts { + var total: Int = 0 + var system: Int = 0 + var user: Int = 0 + var unknown: Int = 0 + } + + // MARK: - Public Methods + + func updateProcesses(_ newProcesses: [LucidProcess]) { + let sorted = newProcesses.sorted() + self.processes = sorted + + // Build lookup dictionary + processByPid = Dictionary( + uniqueKeysWithValues: sorted.map { ($0.pid, $0) } + ) + + // Calculate filter counts + filterCounts = FilterCounts( + total: sorted.count, + system: sorted.filter { $0.safety == .system }.count, + user: sorted.filter { $0.safety == .user }.count, + unknown: sorted.filter { $0.safety == .unknown }.count + ) + + // Build port-to-process map for O(1) lookup + var portMap: [UInt16: [pid_t]] = [:] + for process in sorted { + for port in process.ports { + portMap[port, default: []].append(process.pid) + } + } + portProcessMap = portMap + activePorts = Array(portMap.keys).sorted() + } + + func process(for pid: pid_t) -> LucidProcess? { + processByPid[pid] + } + + func processes(for port: UInt16) -> [pid_t] { + portProcessMap[port] ?? [] + } + + 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) + } +} \ No newline at end of file diff --git a/Lucid/Lucid/Services/SystemStatsStore.swift b/Lucid/Lucid/Services/SystemStatsStore.swift new file mode 100644 index 0000000..e856288 --- /dev/null +++ b/Lucid/Lucid/Services/SystemStatsStore.swift @@ -0,0 +1,71 @@ +import Foundation +import Observation + +@Observable +@MainActor +final class SystemStatsStore { + // MARK: - Observable State + var stats: SystemStats = SystemStats( + cpuUsage: 0, + memoryUsage: 0, + memoryBytes: 0, + totalMemoryBytes: 0, + timestamp: Date() + ) + + private(set) var cpuHistory: [Double] = [] + private(set) var memoryHistory: [Double] = [] + private(set) var lastUpdate: Date = Date() + + // MARK: - Constants + private let maxHistoryEntries = 12 + + // MARK: - Public Methods + + func updateStats(_ newStats: SystemStats) { + stats = newStats + lastUpdate = Date() + + // Update histories + cpuHistory.append(newStats.cpuUsage) + memoryHistory.append(newStats.memoryUsage) + + // Trim to max entries + if cpuHistory.count > maxHistoryEntries { + cpuHistory.removeFirst(cpuHistory.count - maxHistoryEntries) + } + if memoryHistory.count > maxHistoryEntries { + memoryHistory.removeFirst(memoryHistory.count - maxHistoryEntries) + } + } + + // MARK: - Calculated Properties + + var averageCPU: Double { + guard !cpuHistory.isEmpty else { return 0 } + return cpuHistory.reduce(0, +) / Double(cpuHistory.count) + } + + var peakMemoryUsage: Double { + memoryHistory.max() ?? 0 + } + + var averageMemoryUsage: Double { + guard !memoryHistory.isEmpty else { return 0 } + return memoryHistory.reduce(0, +) / Double(memoryHistory.count) + } + + // MARK: - Formatted Values + + var formattedCPU: String { + String(format: "%.1f%%", stats.cpuUsage) + } + + var formattedMemory: String { + String(format: "%.1f%%", stats.memoryUsage) + } + + var formattedMemoryGB: String { + String(format: "%.1f/%.1f", stats.memoryMB / 1024, stats.totalMemoryGB) + } +} \ No newline at end of file diff --git a/Lucid/Lucid/Views/Content/HeaderBar.swift b/Lucid/Lucid/Views/Content/HeaderBar.swift index 054c85f..e571c43 100644 --- a/Lucid/Lucid/Views/Content/HeaderBar.swift +++ b/Lucid/Lucid/Views/Content/HeaderBar.swift @@ -1,11 +1,18 @@ import SwiftUI struct HeaderBar: View { - let processCount: Int - @Binding var searchText: String - @Binding var selectedFilter: FilterCategory + @Environment(FilterState.self) var filterState @State private var showSettings = false + let processCount: Int + + private var searchTextBinding: Binding { + Binding( + get: { filterState.searchText }, + set: { filterState.searchText = $0 } + ) + } + var body: some View { HStack(spacing: 16) { VStack(alignment: .leading, spacing: 4) { @@ -28,25 +35,7 @@ struct HeaderBar: View { .buttonStyle(.plain) .help("Settings") - HStack(spacing: 12) { - Image(systemName: "magnifyingglass") - .foregroundStyle(.secondary) - - TextField("Search processes...", text: $searchText) - .textFieldStyle(.plain) - - if !searchText.isEmpty { - Button(action: { searchText = "" }) { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - } - } - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(LucidTheme.backgroundTertiary) - .cornerRadius(8) - .frame(maxWidth: 250) + SearchField(text: searchTextBinding) } .padding(16) .background(LucidTheme.backgroundSecondary) @@ -56,3 +45,32 @@ struct HeaderBar: View { } } } + +// MARK: - Search Field Component + +struct SearchField: View { + @Binding var text: String + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + + TextField("Search processes...", text: $text) + .textFieldStyle(.plain) + + if !text.isEmpty { + Button(action: { text = "" }) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(LucidTheme.backgroundTertiary) + .cornerRadius(8) + .frame(maxWidth: 250) + } +} \ No newline at end of file diff --git a/Lucid/Lucid/Views/Dashboard/MetricCardView.swift b/Lucid/Lucid/Views/Dashboard/MetricCardView.swift index 1f64455..ef51bb0 100644 --- a/Lucid/Lucid/Views/Dashboard/MetricCardView.swift +++ b/Lucid/Lucid/Views/Dashboard/MetricCardView.swift @@ -30,6 +30,13 @@ struct MetricCardView: View { .lineLimit(1) .fixedSize(horizontal: true, vertical: true) } + + // Mini sparkline when history available + if !history.isEmpty { + MiniSparkline(data: history, color: color) + .frame(height: 12) + .padding(.top, 2) + } } .padding(8) .background(LucidTheme.backgroundTertiary) @@ -37,3 +44,38 @@ struct MetricCardView: View { .help("\(label): \(value)") } } + +// MARK: - Mini Sparkline + +struct MiniSparkline: View { + let data: [Double] + let color: Color + + var body: some View { + GeometryReader { geometry in + let width = geometry.size.width + let height = geometry.size.height + let maxValue = data.max() ?? 1 + let minValue = data.min() ?? 0 + let range = maxValue - minValue + + Path { path in + guard data.count > 1 else { return } + + let stepX = width / CGFloat(data.count - 1) + + for (index, value) in data.enumerated() { + let x = CGFloat(index) * stepX + let y = range > 0 ? height - ((CGFloat(value - minValue) / CGFloat(range)) * height) : height / 2 + + if index == 0 { + path.move(to: CGPoint(x: x, y: y)) + } else { + path.addLine(to: CGPoint(x: x, y: y)) + } + } + } + .stroke(color.opacity(0.6), lineWidth: 1.5) + } + } +} \ No newline at end of file diff --git a/Lucid/Lucid/Views/Dashboard/MetricsRowView.swift b/Lucid/Lucid/Views/Dashboard/MetricsRowView.swift index 3b9bc4d..adabaf8 100644 --- a/Lucid/Lucid/Views/Dashboard/MetricsRowView.swift +++ b/Lucid/Lucid/Views/Dashboard/MetricsRowView.swift @@ -1,7 +1,8 @@ import SwiftUI struct MetricsRowView: View { - @Environment(ProcessMonitor.self) var monitor + @Environment(ProcessStore.self) var processStore + @Environment(SystemStatsStore.self) var statsStore private let columns = [ GridItem(.flexible(), spacing: 8), @@ -12,23 +13,23 @@ struct MetricsRowView: View { LazyVGrid(columns: columns, spacing: 8) { MetricCardView( label: "CPU", - value: String(format: "%.1f%%", monitor.stats.cpuUsage), + value: statsStore.formattedCPU, icon: "cpu", color: LucidTheme.metricCPU, - history: [] + history: statsStore.cpuHistory ) MetricCardView( label: "Memory", - value: String(format: "%.1f%%", monitor.stats.memoryUsage), + value: statsStore.formattedMemory, icon: "memorychip", color: LucidTheme.metricMemory, - history: [] + history: statsStore.memoryHistory ) MetricCardView( label: "Processes", - value: "\(monitor.filterCounts.total)", + value: "\(processStore.filterCounts.total)", icon: "square.grid.2x2", color: LucidTheme.metricProcesses, history: [] @@ -36,7 +37,7 @@ struct MetricsRowView: View { MetricCardView( label: "Memory GB", - value: String(format: "%.1f/%.1f", monitor.stats.memoryMB / 1024, monitor.stats.totalMemoryGB), + value: statsStore.formattedMemoryGB, icon: "internaldrive", color: LucidTheme.metricDisk, history: [] @@ -44,4 +45,4 @@ struct MetricsRowView: View { } .padding(.horizontal) } -} +} \ No newline at end of file diff --git a/Lucid/Lucid/Views/Sidebar/SidebarView.swift b/Lucid/Lucid/Views/Sidebar/SidebarView.swift index 34d85d1..83631ab 100644 --- a/Lucid/Lucid/Views/Sidebar/SidebarView.swift +++ b/Lucid/Lucid/Views/Sidebar/SidebarView.swift @@ -2,6 +2,9 @@ import SwiftUI struct SidebarView: View { @Environment(ProcessMonitor.self) var monitor + @Environment(ProcessStore.self) var processStore + @Environment(FilterState.self) var filterState + @State private var portToKill: UInt16? @State private var killError: String? @@ -21,108 +24,24 @@ struct SidebarView: View { var body: some View { VStack(spacing: 16) { - // Metrics Row + // Metrics Row - now with history MetricsRowView() Divider() // Filters Section - VStack(alignment: .leading, spacing: 12) { - Text("Filters") - .font(.headline) - .padding(.horizontal) - - VStack(spacing: 8) { - FilterButton( - label: "All Processes", - icon: "square.grid.2x2", - count: monitor.filterCounts.total, - isActive: monitor.selectedFilter == .all, - action: { monitor.selectedFilter = .all } - ) - - FilterButton( - label: "System", - icon: "gearshape.fill", - count: monitor.filterCounts.system, - isActive: monitor.selectedFilter == .system, - action: { monitor.selectedFilter = .system } - ) - - FilterButton( - label: "User", - icon: "person.fill", - count: monitor.filterCounts.user, - isActive: monitor.selectedFilter == .user, - action: { monitor.selectedFilter = .user } - ) - - FilterButton( - label: "Unknown", - icon: "questionmark.circle.fill", - count: monitor.filterCounts.unknown, - isActive: monitor.selectedFilter == .unknown, - action: { monitor.selectedFilter = .unknown } - ) - } - .padding(.horizontal) - } + FiltersSection() - // Active Ports Section - if !monitor.activePorts.isEmpty { + // Active Ports Section - now uses pre-computed portProcessMap + if !processStore.activePorts.isEmpty { Divider() - - VStack(alignment: .leading, spacing: 8) { - Text("Active Ports") - .font(.headline) - .padding(.horizontal) - - ScrollView { - VStack(spacing: 4) { - ForEach(monitor.activePorts, id: \.self) { port in - PortFilterRow( - port: port, - processCount: monitor.processes.filter { $0.ports.contains(port) }.count, - isActive: monitor.selectedFilter == .port(port), - onSelect: { monitor.selectedFilter = .port(port) }, - onKill: { portToKill = port } - ) - } - } - .padding(.horizontal) - } - .frame(maxHeight: 200) - } + ActivePortsSection(portToKill: $portToKill) } Spacer() // Footer - VStack(alignment: .leading, spacing: 8) { - Divider() - HStack(spacing: 8) { - if monitor.isRunning { - PulsingStatusDot() - } else { - Circle() - .fill(Color.gray) - .frame(width: 8, height: 8) - } - VStack(alignment: .leading, spacing: 2) { - Text(monitor.isRunning ? "Monitoring" : "Idle") - .font(.caption) - .fontWeight(.semibold) - .foregroundStyle(monitor.isRunning ? Color.green : .secondary) - Text(systemInfoString) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(2) - } - Spacer() - } - .padding(.horizontal) - .padding(.bottom, 8) - } + SidebarFooter() } .padding(.vertical, 16) .background(LucidTheme.backgroundSecondary) @@ -144,7 +63,7 @@ struct SidebarView: View { private func killButton(for port: UInt16) -> some View { Button("Kill All Processes on Port \(port)", role: .destructive) { - let processesToKill = monitor.processes.filter { $0.ports.contains(port) } + let processesToKill = processStore.processes.filter { $0.ports.contains(port) } if case .failure(let error) = monitor.killProcesses(processesToKill) { killError = error.localizedDescription } else { @@ -157,9 +76,125 @@ struct SidebarView: View { } private func killDialogMessage(for port: UInt16) -> some View { - let processCount = monitor.processes.filter { $0.ports.contains(port) }.count + let processCount = processStore.processes(for: port).count return Text("Are you sure you want to kill all \(processCount) process(es) using port \(port)?") } +} + +// MARK: - Filters Section + +struct FiltersSection: View { + @Environment(ProcessStore.self) var processStore + @Environment(FilterState.self) var filterState + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Filters") + .font(.headline) + .padding(.horizontal) + + VStack(spacing: 8) { + FilterButton( + label: "All Processes", + icon: "square.grid.2x2", + count: processStore.filterCounts.total, + isActive: filterState.selectedFilter == .all, + action: { filterState.applyFilter(.all) } + ) + + FilterButton( + label: "System", + icon: "gearshape.fill", + count: processStore.filterCounts.system, + isActive: filterState.selectedFilter == .system, + action: { filterState.applyFilter(.system) } + ) + + FilterButton( + label: "User", + icon: "person.fill", + count: processStore.filterCounts.user, + isActive: filterState.selectedFilter == .user, + action: { filterState.applyFilter(.user) } + ) + + FilterButton( + label: "Unknown", + icon: "questionmark.circle.fill", + count: processStore.filterCounts.unknown, + isActive: filterState.selectedFilter == .unknown, + action: { filterState.applyFilter(.unknown) } + ) + } + .padding(.horizontal) + } + } +} + +// MARK: - Active Ports Section + +struct ActivePortsSection: View { + @Environment(ProcessStore.self) var processStore + @Environment(FilterState.self) var filterState + @Binding var portToKill: UInt16? + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Active Ports") + .font(.headline) + .padding(.horizontal) + + ScrollView { + VStack(spacing: 4) { + ForEach(processStore.activePorts, id: \.self) { port in + PortFilterRow( + port: port, + processCount: processStore.processes(for: port).count, + isActive: filterState.selectedFilter == .port(port), + onSelect: { filterState.applyFilter(.port(port)) }, + onKill: { portToKill = port } + ) + } + } + .padding(.horizontal) + } + .frame(maxHeight: 200) + } + } +} + +// MARK: - Sidebar Footer + +struct SidebarFooter: View { + @Environment(ProcessMonitor.self) var monitor + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Divider() + HStack(spacing: 8) { + if monitor.isRunning { + PulsingStatusDot() + } else { + Circle() + .fill(Color.gray) + .frame(width: 8, height: 8) + } + VStack(alignment: .leading, spacing: 2) { + Text(monitor.isRunning ? "Monitoring" : "Idle") + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(monitor.isRunning ? Color.green : .secondary) + Text(systemInfoString) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(2) + } + Spacer() + } + .padding(.horizontal) + .padding(.bottom, 8) + } + } private var systemInfoString: String { let version = ProcessInfo.processInfo.operatingSystemVersion @@ -169,4 +204,4 @@ struct SidebarView: View { let ramGB = String(format: "%.0f", Double(totalRAM) / (1024 * 1024 * 1024)) return "\(osVersion) \u{2022} \(cpuCores) cores \u{2022} \(ramGB) GB RAM" } -} +} \ No newline at end of file diff --git a/Lucid/LucidTests/FilterStateTests.swift b/Lucid/LucidTests/FilterStateTests.swift new file mode 100644 index 0000000..169e8ec --- /dev/null +++ b/Lucid/LucidTests/FilterStateTests.swift @@ -0,0 +1,122 @@ +import XCTest +@testable import Lucid + +@MainActor +final class FilterStateTests: XCTestCase { + var filterState: FilterState! + + override func setUp() { + super.setUp() + filterState = FilterState() + } + + override func tearDown() { + filterState = nil + super.tearDown() + } + + // MARK: - Initial State Tests + + func testInitialSelectedFilterIsAll() { + XCTAssertEqual(filterState.selectedFilter, .all) + } + + func testInitialSearchTextIsEmpty() { + XCTAssertEqual(filterState.searchText, "") + } + + func testInitialDebouncedSearchTextIsEmpty() { + XCTAssertEqual(filterState.debouncedSearchText, "") + } + + // MARK: - Filter Application Tests + + func testApplyFilterChangesSelectedFilter() { + filterState.applyFilter(.system) + XCTAssertEqual(filterState.selectedFilter, .system) + } + + func testClearSearchResetsSearchText() { + filterState.searchText = "test" + filterState.clearSearch() + XCTAssertEqual(filterState.searchText, "") + } + + // MARK: - Process Filtering Tests + + func testFilterProcessesWithNoFiltersReturnsAll() { + let processes = sampleProcesses() + let filtered = filterState.filter(processes) + XCTAssertEqual(filtered.count, processes.count) + } + + func testFilterProcessesByCategory() { + let processes = sampleProcesses() + filterState.applyFilter(.system) + + let filtered = filterState.filter(processes) + + XCTAssertTrue(filtered.allSatisfy { $0.safety == .system }) + } + + func testFilterProcessesBySearchText() { + let processes = sampleProcesses() + filterState.searchText = "kernel" + filterState.debouncedSearchText = "kernel" // Simulate debounce complete + + let filtered = filterState.filter(processes) + + XCTAssertTrue(filtered.allSatisfy { + $0.name.localizedCaseInsensitiveContains("kernel") || + $0.description.localizedCaseInsensitiveContains("kernel") + }) + } + + func testFilterProcessesByCategoryAndSearch() { + let processes = sampleProcesses() + filterState.applyFilter(.user) + filterState.searchText = "chrome" + filterState.debouncedSearchText = "chrome" + + let filtered = filterState.filter(processes) + + XCTAssertTrue(filtered.allSatisfy { + $0.safety == .user && + ($0.name.localizedCaseInsensitiveContains("chrome") || + $0.description.localizedCaseInsensitiveContains("chrome")) + }) + } + + func testFilterProcessesByPort() { + let processes = sampleProcesses() + filterState.applyFilter(.port(8080)) + + let filtered = filterState.filter(processes) + + XCTAssertTrue(filtered.allSatisfy { $0.ports.contains(8080) }) + } + + // MARK: - Sorting Tests + + func testSortOrderDefault() { + XCTAssertEqual(filterState.sortOrder.count, 1) + XCTAssertEqual(filterState.sortOrder.first?.keyPath, \LucidProcess.cpuUsage) + } + + func testApplySortOrderUpdatesSortOrder() { + let newSort = [KeyPathComparator(\LucidProcess.name, order: .forward)] + filterState.applySortOrder(newSort) + + XCTAssertEqual(filterState.sortOrder.first?.keyPath, \LucidProcess.name) + } + + // MARK: - Helpers + + private func sampleProcesses() -> [LucidProcess] { + [ + LucidProcess(pid: 1, name: "kernel_task", description: "System kernel", cpuUsage: 10, memoryBytes: 100_000_000, safety: .system, exePath: "/kernel", ports: []), + LucidProcess(pid: 2, name: "Chrome", description: "Browser", cpuUsage: 20, memoryBytes: 200_000_000, safety: .user, exePath: "/Apps/Chrome", ports: [8080]), + LucidProcess(pid: 3, name: "UnknownApp", description: "Unknown", cpuUsage: 5, memoryBytes: 50_000_000, safety: .unknown, exePath: "/tmp/unknown", ports: [3000, 8080]) + ] + } +} \ No newline at end of file diff --git a/Lucid/LucidTests/PerformanceTests.swift b/Lucid/LucidTests/PerformanceTests.swift new file mode 100644 index 0000000..49155c7 --- /dev/null +++ b/Lucid/LucidTests/PerformanceTests.swift @@ -0,0 +1,152 @@ +import XCTest +@testable import Lucid + +@MainActor +final class PerformanceTests: XCTestCase { + var monitor: ProcessMonitor! + var filterState: FilterState! + var processStore: ProcessStore! + + override func setUp() { + super.setUp() + monitor = ProcessMonitor() + filterState = monitor.filterState + processStore = monitor.processStore + } + + override func tearDown() { + monitor.stop() + monitor = nil + super.tearDown() + } + + // MARK: - Filter Performance Tests + + func testFilterPerformanceWith400Processes() { + let processes = generateProcesses(count: 400) + processStore.updateProcesses(processes) + + measure { + // Run filtering 100 times to get average + for _ in 0..<100 { + _ = filterState.filter(processes) + } + } + } + + func testFilterWithSearchPerformance() { + let processes = generateProcesses(count: 400) + processStore.updateProcesses(processes) + filterState.searchText = "kernel" + filterState.debouncedSearchText = "kernel" + + measure { + for _ in 0..<100 { + _ = filterState.filter(processes) + } + } + } + + func testPortLookupPerformance() { + let processes = generateProcessesWithPorts(count: 400, portsPerProcess: 2) + processStore.updateProcesses(processes) + + measure { + for port in processStore.activePorts { + _ = processStore.processes(for: port) + } + } + } + + // MARK: - Memory Tests + + func testProcessStoreMemoryUsage() { + let processes = generateProcesses(count: 1000) + + measure(metrics: [XCTMemoryMetric()]) { + processStore.updateProcesses(processes) + } + } + + // MARK: - Thread Safety Tests + + 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) + } + + // MARK: - Debounce Tests + + func testSearchDebounceDoesNotTriggerExcessiveFilters() async throws { + let processes = generateProcesses(count: 100) + processStore.updateProcesses(processes) + + // Simulate rapid typing + for char in "searching" { + filterState.searchText += String(char) + _ = filterState.filter(processes) // Simulate view evaluation + try await Task.sleep(for: .milliseconds(50)) + } + + // Wait for debounce + try await Task.sleep(for: .milliseconds(400)) + + // Should have filtered with debounced value, not every keystroke + XCTAssertEqual(filterState.debouncedSearchText, "searching") + } + + // MARK: - Helpers + + private func generateProcesses(count: Int) -> [LucidProcess] { + (0.. [LucidProcess] { + (0.. [LucidProcess] { + [ + LucidProcess(pid: 1, name: "kernel_task", description: "System kernel", cpuUsage: 10, memoryBytes: 100_000_000, safety: .system, exePath: "/kernel", ports: []), + LucidProcess(pid: 2, name: "Chrome", description: "Browser", cpuUsage: 20, memoryBytes: 200_000_000, safety: .user, exePath: "/Apps/Chrome", ports: [8080]), + LucidProcess(pid: 3, name: "UnknownApp", description: "Unknown", cpuUsage: 5, memoryBytes: 50_000_000, safety: .unknown, exePath: "/tmp/unknown", ports: [3000]) + ] + } +} \ No newline at end of file diff --git a/Lucid/LucidTests/SystemStatsStoreTests.swift b/Lucid/LucidTests/SystemStatsStoreTests.swift new file mode 100644 index 0000000..a48636f --- /dev/null +++ b/Lucid/LucidTests/SystemStatsStoreTests.swift @@ -0,0 +1,149 @@ +import XCTest +@testable import Lucid + +@MainActor +final class SystemStatsStoreTests: XCTestCase { + var store: SystemStatsStore! + + override func setUp() { + super.setUp() + store = SystemStatsStore() + } + + override func tearDown() { + store = nil + super.tearDown() + } + + // MARK: - Initial State Tests + + func testInitialStatsAreZero() { + XCTAssertEqual(store.stats.cpuUsage, 0) + XCTAssertEqual(store.stats.memoryUsage, 0) + } + + func testInitialHistoryIsEmpty() { + XCTAssertTrue(store.cpuHistory.isEmpty) + XCTAssertTrue(store.memoryHistory.isEmpty) + } + + // MARK: - Update Stats Tests + + func testUpdateStatsUpdatesCurrentStats() { + let newStats = SystemStats( + cpuUsage: 25.5, + memoryUsage: 60.0, + memoryBytes: 8_000_000_000, + totalMemoryBytes: 16_000_000_000, + timestamp: Date() + ) + + store.updateStats(newStats) + + XCTAssertEqual(store.stats.cpuUsage, 25.5) + XCTAssertEqual(store.stats.memoryUsage, 60.0) + } + + func testUpdateStatsAddsToHistory() { + let stats1 = SystemStats(cpuUsage: 10, memoryUsage: 20, memoryBytes: 4_000_000_000, totalMemoryBytes: 16_000_000_000, timestamp: Date()) + let stats2 = SystemStats(cpuUsage: 20, memoryUsage: 30, memoryBytes: 5_000_000_000, totalMemoryBytes: 16_000_000_000, timestamp: Date()) + + store.updateStats(stats1) + store.updateStats(stats2) + + XCTAssertEqual(store.cpuHistory, [10, 20]) + XCTAssertEqual(store.memoryHistory, [20, 30]) + } + + func testHistoryLimitedToTwelveEntries() { + for i in 0..<15 { + let stats = SystemStats( + cpuUsage: Double(i), + memoryUsage: Double(i * 2), + memoryBytes: UInt64(i) * 1_000_000, + totalMemoryBytes: 16_000_000_000, + timestamp: Date() + ) + store.updateStats(stats) + } + + XCTAssertEqual(store.cpuHistory.count, 12) + XCTAssertEqual(store.memoryHistory.count, 12) + XCTAssertEqual(store.cpuHistory.first, 3) // First 3 evicted + XCTAssertEqual(store.cpuHistory.last, 14) + } + + func testUpdateStatsUpdatesTimestamp() { + let before = Date() + let newStats = SystemStats( + cpuUsage: 10, + memoryUsage: 20, + memoryBytes: 4_000_000_000, + totalMemoryBytes: 16_000_000_000, + timestamp: Date() + ) + store.updateStats(newStats) + let after = Date() + + XCTAssertGreaterThanOrEqual(store.lastUpdate, before) + XCTAssertLessThanOrEqual(store.lastUpdate, after) + } + + // MARK: - Calculated Stats Tests + + func testAverageCPUCalculatesCorrectly() { + let stats = [ + SystemStats(cpuUsage: 10, memoryUsage: 20, memoryBytes: 4_000_000_000, totalMemoryBytes: 16_000_000_000, timestamp: Date()), + SystemStats(cpuUsage: 20, memoryUsage: 30, memoryBytes: 5_000_000_000, totalMemoryBytes: 16_000_000_000, timestamp: Date()), + SystemStats(cpuUsage: 30, memoryUsage: 40, memoryBytes: 6_000_000_000, totalMemoryBytes: 16_000_000_000, timestamp: Date()) + ] + + for stat in stats { + store.updateStats(stat) + } + + XCTAssertEqual(store.averageCPU, 20.0, accuracy: 0.01) + } + + func testPeakMemoryCalculatesCorrectly() { + let stats = [ + SystemStats(cpuUsage: 10, memoryUsage: 20, memoryBytes: 4_000_000_000, totalMemoryBytes: 16_000_000_000, timestamp: Date()), + SystemStats(cpuUsage: 20, memoryUsage: 50, memoryBytes: 8_000_000_000, totalMemoryBytes: 16_000_000_000, timestamp: Date()), + SystemStats(cpuUsage: 30, memoryUsage: 30, memoryBytes: 5_000_000_000, totalMemoryBytes: 16_000_000_000, timestamp: Date()) + ] + + for stat in stats { + store.updateStats(stat) + } + + XCTAssertEqual(store.peakMemoryUsage, 50.0, accuracy: 0.01) + } + + // MARK: - Formatted Values Tests + + func testFormattedCPU() { + let stats = SystemStats( + cpuUsage: 25.5, + memoryUsage: 60.0, + memoryBytes: 8_000_000_000, + totalMemoryBytes: 16_000_000_000, + timestamp: Date() + ) + store.updateStats(stats) + + XCTAssertEqual(store.formattedCPU, "25.5%") + } + + func testFormattedMemory() { + let stats = SystemStats( + cpuUsage: 25.5, + memoryUsage: 60.0, + memoryBytes: 8_000_000_000, + totalMemoryBytes: 16_000_000_000, + timestamp: Date() + ) + store.updateStats(stats) + + XCTAssertEqual(store.formattedMemory, "60.0%") + } +} \ No newline at end of file From 6e983d476b7fff64cc6542b8f6043d79611d5fe4 Mon Sep 17 00:00:00 2001 From: Tan Date: Sat, 7 Mar 2026 04:12:33 -0500 Subject: [PATCH 2/4] fix: address Greptile code review feedback (#6) - 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 --- Lucid/Lucid/Services/FilterState.swift | 13 ++++++++----- Lucid/Lucid/Services/ProcessMonitor.swift | 3 +-- Lucid/Lucid/Services/ProcessStore.swift | 16 ++-------------- Lucid/LucidTests/PerformanceTests.swift | 7 +++++-- 4 files changed, 16 insertions(+), 23 deletions(-) diff --git a/Lucid/Lucid/Services/FilterState.swift b/Lucid/Lucid/Services/FilterState.swift index 90aab95..c1e35a2 100644 --- a/Lucid/Lucid/Services/FilterState.swift +++ b/Lucid/Lucid/Services/FilterState.swift @@ -77,11 +77,14 @@ final class FilterState { debounceTask = Task { [weak self] in guard let self else { return } - try? await Task.sleep(for: self.debounceInterval) - - guard !Task.isCancelled else { return } - - self.debouncedSearchText = self.searchText + do { + try await Task.sleep(for: self.debounceInterval) + self.debouncedSearchText = self.searchText + } catch is CancellationError { + // Task was cancelled, do nothing + } catch { + // Other errors shouldn't happen with Task.sleep, but handle just in case + } } } } \ No newline at end of file diff --git a/Lucid/Lucid/Services/ProcessMonitor.swift b/Lucid/Lucid/Services/ProcessMonitor.swift index 936a96e..15553fe 100644 --- a/Lucid/Lucid/Services/ProcessMonitor.swift +++ b/Lucid/Lucid/Services/ProcessMonitor.swift @@ -183,13 +183,12 @@ final class ProcessMonitor { } } - let finalProcesses = newProcesses.sorted() let finalCPUTimes = currentCPUTimes await MainActor.run { [weak self] in guard let self else { return } self.previousCPUTimes = finalCPUTimes - self.processStore.updateProcesses(finalProcesses) + self.processStore.updateProcesses(newProcesses) self.updateSystemStats() } } diff --git a/Lucid/Lucid/Services/ProcessStore.swift b/Lucid/Lucid/Services/ProcessStore.swift index bd1fea1..1609a73 100644 --- a/Lucid/Lucid/Services/ProcessStore.swift +++ b/Lucid/Lucid/Services/ProcessStore.swift @@ -59,19 +59,7 @@ final class ProcessStore { } 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) + // updateProcesses will rebuild all data structures from scratch + updateProcesses(processes.filter { $0.pid != process.pid }) } } \ No newline at end of file diff --git a/Lucid/LucidTests/PerformanceTests.swift b/Lucid/LucidTests/PerformanceTests.swift index 49155c7..de0d84d 100644 --- a/Lucid/LucidTests/PerformanceTests.swift +++ b/Lucid/LucidTests/PerformanceTests.swift @@ -68,9 +68,12 @@ final class PerformanceTests: XCTestCase { } } - // MARK: - Thread Safety Tests + // MARK: - Actor Isolation Tests - func testConcurrentAccessToProcessStore() async { + /// Verifies that multiple sequential accesses to @MainActor-isolated ProcessStore + /// complete without data races. Note: Since ProcessStore is @MainActor, tasks are + /// serialized on the main actor - this tests isolation safety, not true concurrency. + func testSequentialAccessToProcessStore() async { let processes = generateProcesses(count: 100) processStore.updateProcesses(processes) From fdadfc1f301a3cdf5ace20f45829f34bae5fe7e4 Mon Sep 17 00:00:00 2001 From: Tan Date: Sat, 7 Mar 2026 04:18:44 -0500 Subject: [PATCH 3/4] fix: resolve MainActor isolation compile errors - 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 --- Lucid/Lucid/Services/ProcessMonitor.swift | 2 +- Lucid/Lucid/Views/Sidebar/SidebarView.swift | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Lucid/Lucid/Services/ProcessMonitor.swift b/Lucid/Lucid/Services/ProcessMonitor.swift index 15553fe..0454eec 100644 --- a/Lucid/Lucid/Services/ProcessMonitor.swift +++ b/Lucid/Lucid/Services/ProcessMonitor.swift @@ -44,7 +44,7 @@ final class ProcessMonitor { // MARK: - Lifecycle - init() {} + nonisolated init() {} func start() { guard !isRunning else { return } diff --git a/Lucid/Lucid/Views/Sidebar/SidebarView.swift b/Lucid/Lucid/Views/Sidebar/SidebarView.swift index 83631ab..2a82959 100644 --- a/Lucid/Lucid/Views/Sidebar/SidebarView.swift +++ b/Lucid/Lucid/Views/Sidebar/SidebarView.swift @@ -61,6 +61,7 @@ struct SidebarView: View { } } + @MainActor private func killButton(for port: UInt16) -> some View { Button("Kill All Processes on Port \(port)", role: .destructive) { let processesToKill = processStore.processes.filter { $0.ports.contains(port) } @@ -75,6 +76,7 @@ struct SidebarView: View { } } + @MainActor private func killDialogMessage(for port: UInt16) -> some View { let processCount = processStore.processes(for: port).count return Text("Are you sure you want to kill all \(processCount) process(es) using port \(port)?") From e1efbd7654d46597127d356af22132be393c2a7a Mon Sep 17 00:00:00 2001 From: Tan Date: Sat, 7 Mar 2026 04:22:54 -0500 Subject: [PATCH 4/4] fix: add MainActor to ContentView computed properties and methods --- Lucid/Lucid/ContentView.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Lucid/Lucid/ContentView.swift b/Lucid/Lucid/ContentView.swift index d01d62b..506630b 100644 --- a/Lucid/Lucid/ContentView.swift +++ b/Lucid/Lucid/ContentView.swift @@ -33,10 +33,12 @@ struct DetailView: View { @State private var killError: String? // Computed filtered processes - now delegated to FilterState + @MainActor var filteredProcesses: [LucidProcess] { filterState.filter(processStore.processes) } + @MainActor private var sortOrderBinding: Binding<[KeyPathComparator]> { Binding( get: { filterState.sortOrder }, @@ -193,6 +195,7 @@ struct DetailView: View { } } + @MainActor private func performKill() { let processesToKill: [LucidProcess] if let single = killTarget {