git.iko.soy/parseable-macos
on master help sign in
Fix 7 bugs: Set mutation during iteration, connect cancellation race, stale filter decode, LogStream ordering, column sampling, and duplicate timestamp
7f50526iko
ParseableViewer/App/AppState.swift+30−9
@@ -96,16 +96,18 @@ final class AppState {
96 // MARK: - Connection Management
96 // MARK: - Connection Management
97
97
98 @MainActor
98 @MainActor
99+ @discardableResult
99− func connect(to connection: ServerConnection) async {
100+ func connect(to connection: ServerConnection) async -> Bool {
100− guard !isConnecting else { return }
101+ guard !isConnecting else { return false }
101
102
102 if !isNetworkAvailable {
103 if !isNetworkAvailable {
103 self.errorMessage = "No internet connection. Check your network and try again."
104 self.errorMessage = "No internet connection. Check your network and try again."
104 self.showError = true
105 self.showError = true
105− return
106+ return false
106 }
107 }
107
108
108 isConnecting = true
109 isConnecting = true
110+ defer { isConnecting = false }
109 errorMessage = nil
111 errorMessage = nil
110
112
111 // When switching to a different server, clear stream-specific state
113 // When switching to a different server, clear stream-specific state
@@ -126,6 +128,11 @@ final class AppState {
126 // Test connection — throws on non-200 or network failure
128 // Test connection — throws on non-200 or network failure
127 try await newClient.checkHealth()
129 try await newClient.checkHealth()
128
130
131+ // If the task was cancelled while awaiting (e.g. user clicked
132+ // Disconnect during auto-reconnect), bail before installing the
133+ // client so we don't resurrect a connection disconnect() just cleared.
134+ if Task.isCancelled { return false }
135+
129 self.client = newClient
136 self.client = newClient
130 self.activeConnection = connection
137 self.activeConnection = connection
131 self.isConnected = true
138 self.isConnected = true
@@ -137,15 +144,30 @@ final class AppState {
137 async let streamList = newClient.listStreams()
144 async let streamList = newClient.listStreams()
138 async let filterList = newClient.listFilters()
145 async let filterList = newClient.listFilters()
139
146
140− self.serverAbout = try? await about
147+ let aboutResult = try? await about
141− self.filters = (try? await filterList) ?? []
148+ let filterResult = (try? await filterList) ?? []
149+ let streamResult: Result<[LogStream], Error>
142 do {
150 do {
143− self.streams = try await streamList
151+ streamResult = .success(try await streamList)
144− self.streamLoadError = nil
145 } catch {
152 } catch {
153+ streamResult = .failure(error)
154+ }
155+
156+ // Re-check after the loads above: a disconnect could have landed
157+ // while awaiting, in which case committing these would show stale data.
158+ if Task.isCancelled { return false }
159+
160+ self.serverAbout = aboutResult
161+ self.filters = filterResult
162+ switch streamResult {
163+ case .success(let list):
164+ self.streams = list
165+ self.streamLoadError = nil
166+ case .failure(let error):
146 self.streams = []
167 self.streams = []
147 self.streamLoadError = ParseableError.userFriendlyMessage(for: error)
168 self.streamLoadError = ParseableError.userFriendlyMessage(for: error)
148 }
169 }
170+ return true
149 } catch {
171 } catch {
150 // If the task was cancelled (e.g. user clicked Disconnect during
172 // If the task was cancelled (e.g. user clicked Disconnect during
151 // auto-reconnect), skip the error alert — disconnect() already
173 // auto-reconnect), skip the error alert — disconnect() already
@@ -164,9 +186,8 @@ final class AppState {
164 self.serverAbout = nil
186 self.serverAbout = nil
165 self.filters = []
187 self.filters = []
166 }
188 }
189+ return false
167 }
190 }
168−
169− isConnecting = false
170 }
191 }
171
192
172 @MainActor
193 @MainActor
ParseableViewer/Models/LogStream.swift+8−1
@@ -10,7 +10,14 @@ struct LogStream: Identifiable, Codable, Hashable, Sendable, Comparable {
10 }
10 }
11
11
12 static func < (lhs: LogStream, rhs: LogStream) -> Bool {
12 static func < (lhs: LogStream, rhs: LogStream) -> Bool {
13+ // Fall back to a raw comparison when the localized comparison reports
14+ // `.orderedSame` for two distinct names (e.g. Unicode-equivalent forms),
15+ // so `<` stays a strict total order consistent with synthesized `==`.
13− lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
16+ switch lhs.name.localizedStandardCompare(rhs.name) {
17+ case .orderedAscending: return true
18+ case .orderedDescending: return false
19+ case .orderedSame: return lhs.name < rhs.name
20+ }
14 }
21 }
15
22
16 init(from decoder: Decoder) throws {
23 init(from decoder: Decoder) throws {
ParseableViewer/Models/ParseableFilter.swift+1−1
@@ -19,7 +19,7 @@ struct FilterQuery: Codable, Hashable, Sendable {
19
19
20 init(from decoder: Decoder) throws {
20 init(from decoder: Decoder) throws {
21 let container = try decoder.container(keyedBy: CodingKeys.self)
21 let container = try decoder.container(keyedBy: CodingKeys.self)
22− filterType = try container.decode(String.self, forKey: .filterType)
22+ filterType = (try? container.decode(String.self, forKey: .filterType)) ?? "sql"
23 filterQuery = try? container.decode(String.self, forKey: .filterQuery)
23 filterQuery = try? container.decode(String.self, forKey: .filterQuery)
24 filterBuilder = try? container.decode(JSONValue.self, forKey: .filterBuilder)
24 filterBuilder = try? container.decode(JSONValue.self, forKey: .filterBuilder)
25 }
25 }
ParseableViewer/ViewModels/QueryViewModel.swift+3−1
@@ -460,7 +460,9 @@ final class QueryViewModel {
460 var empty = Set(columns)
460 var empty = Set(columns)
461 for record in records {
461 for record in records {
462 if empty.isEmpty { break }
462 if empty.isEmpty { break }
463+ // Iterate a snapshot: mutating `empty` while iterating it directly
464+ // is undefined behavior and can skip elements or trap.
463− for column in empty {
465+ for column in Array(empty) {
464 if let value = record[column], value != .null, value != .string("") {
466 if let value = record[column], value != .null, value != .string("") {
465 empty.remove(column)
467 empty.remove(column)
466 }
468 }
ParseableViewer/Views/ConnectionSheet.swift+4−4
@@ -181,14 +181,14 @@ struct ConnectionSheet: View {
181 testResult = nil
181 testResult = nil
182
182
183 Task {
183 Task {
184− await appState.connect(to: conn)
184+ let success = await appState.connect(to: conn)
185 await MainActor.run {
185 await MainActor.run {
186 isSaving = false
186 isSaving = false
187− if appState.errorMessage != nil {
187+ if success {
188− testResult = .failure(appState.errorMessage ?? "Connection failed")
189− } else {
190 password = ""
188 password = ""
191 dismiss()
189 dismiss()
190+ } else {
191+ testResult = .failure(appState.errorMessage ?? "Connection failed")
192 }
192 }
193 }
193 }
194 }
194 }
ParseableViewer/Views/LogTableView.swift+1−1
@@ -23,7 +23,7 @@ func idealColumnWidth(for column: String, records: [LogRecord]) -> CGFloat {
23 // Sample evenly across the whole result set, not just the first N rows —
23 // Sample evenly across the whole result set, not just the first N rows —
24 // otherwise a wide value that sorts beyond row N is never measured and the
24 // otherwise a wide value that sorts beyond row N is never measured and the
25 // column auto-fits too narrow after sorting.
25 // column auto-fits too narrow after sorting.
26− let step = max(1, records.count / max(sampleCount, 1))
26+ let step = max(1, (records.count + sampleCount - 1) / max(sampleCount, 1))
27 for i in stride(from: 0, to: records.count, by: step).prefix(sampleCount) {
27 for i in stride(from: 0, to: records.count, by: step).prefix(sampleCount) {
28 let text = records[i][column]?.displayString ?? ""
28 let text = records[i][column]?.displayString ?? ""
29 widest = max(widest, measureTextWidth(text, font: cellMeasureFont))
29 widest = max(widest, measureTextWidth(text, font: cellMeasureFont))
ParseableViewer/Views/StreamDetailView.swift−8
@@ -20,14 +20,6 @@ struct StreamDetailView: View {
20 Text(stream)
20 Text(stream)
21 .font(.title2)
21 .font(.title2)
22 .fontWeight(.semibold)
22 .fontWeight(.semibold)
23−
24− if let info {
25− if let created = info.createdAt {
26− Text("Created: \(created)")
27− .font(.caption)
28− .foregroundStyle(.secondary)
29− }
30− }
31 }
23 }
32
24
33 Spacer()
25 Spacer()