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
commit7f505267849172b6a946e79c471da3fbbdb9c4ab authoriko <mail@iko.soy> dateSun 21 Jun 2026, 00:04 (3 months ago) parent5247b42
7 files changed+47−25
ParseableViewer/App/AppState.swift+30−9 ParseableViewer/Models/LogStream.swift+8−1 ParseableViewer/Models/ParseableFilter.swift+1−1 ParseableViewer/ViewModels/QueryViewModel.swift+3−1 ParseableViewer/Views/ConnectionSheet.swift+4−4 ParseableViewer/Views/LogTableView.swift+1−1 ParseableViewer/Views/StreamDetailView.swift−8
ParseableViewer/App/AppState.swift+30−9
@@ -96,16 +96,18 @@ final class AppState {
9696 // MARK: - Connection Management
9797
9898 @MainActor
99− func connect(to connection: ServerConnection) async {
100− guard !isConnecting else { return }
99+ @discardableResult
100+ func connect(to connection: ServerConnection) async -> Bool {
101+ guard !isConnecting else { return false }
101102
102103 if !isNetworkAvailable {
103104 self.errorMessage = "No internet connection. Check your network and try again."
104105 self.showError = true
105− return
106+ return false
106107 }
107108
108109 isConnecting = true
110+ defer { isConnecting = false }
109111 errorMessage = nil
110112
111113 // When switching to a different server, clear stream-specific state
@@ -126,6 +128,11 @@ final class AppState {
126128 // Test connection — throws on non-200 or network failure
127129 try await newClient.checkHealth()
128130
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+
129136 self.client = newClient
130137 self.activeConnection = connection
131138 self.isConnected = true
@@ -137,15 +144,30 @@ final class AppState {
137144 async let streamList = newClient.listStreams()
138145 async let filterList = newClient.listFilters()
139146
140− self.serverAbout = try? await about
141− self.filters = (try? await filterList) ?? []
147+ let aboutResult = try? await about
148+ let filterResult = (try? await filterList) ?? []
149+ let streamResult: Result<[LogStream], Error>
142150 do {
143− self.streams = try await streamList
144− self.streamLoadError = nil
151+ streamResult = .success(try await streamList)
145152 } 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):
146167 self.streams = []
147168 self.streamLoadError = ParseableError.userFriendlyMessage(for: error)
148169 }
170+ return true
149171 } catch {
150172 // If the task was cancelled (e.g. user clicked Disconnect during
151173 // auto-reconnect), skip the error alert — disconnect() already
@@ -164,9 +186,8 @@ final class AppState {
164186 self.serverAbout = nil
165187 self.filters = []
166188 }
189+ return false
167190 }
168−
169− isConnecting = false
170191 }
171192
172193 @MainActor
ParseableViewer/Models/LogStream.swift+8−1
@@ -10,7 +10,14 @@ struct LogStream: Identifiable, Codable, Hashable, Sendable, Comparable {
1010 }
1111
1212 static func < (lhs: LogStream, rhs: LogStream) -> Bool {
13− lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
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 `==`.
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+ }
1421 }
1522
1623 init(from decoder: Decoder) throws {
ParseableViewer/Models/ParseableFilter.swift+1−1
@@ -19,7 +19,7 @@ struct FilterQuery: Codable, Hashable, Sendable {
1919
2020 init(from decoder: Decoder) throws {
2121 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"
2323 filterQuery = try? container.decode(String.self, forKey: .filterQuery)
2424 filterBuilder = try? container.decode(JSONValue.self, forKey: .filterBuilder)
2525 }
ParseableViewer/ViewModels/QueryViewModel.swift+3−1
@@ -460,7 +460,9 @@ final class QueryViewModel {
460460 var empty = Set(columns)
461461 for record in records {
462462 if empty.isEmpty { break }
463− for column in empty {
463+ // Iterate a snapshot: mutating `empty` while iterating it directly
464+ // is undefined behavior and can skip elements or trap.
465+ for column in Array(empty) {
464466 if let value = record[column], value != .null, value != .string("") {
465467 empty.remove(column)
466468 }
ParseableViewer/Views/ConnectionSheet.swift+4−4
@@ -181,14 +181,14 @@ struct ConnectionSheet: View {
181181 testResult = nil
182182
183183 Task {
184− await appState.connect(to: conn)
184+ let success = await appState.connect(to: conn)
185185 await MainActor.run {
186186 isSaving = false
187− if appState.errorMessage != nil {
188− testResult = .failure(appState.errorMessage ?? "Connection failed")
189− } else {
187+ if success {
190188 password = ""
191189 dismiss()
190+ } else {
191+ testResult = .failure(appState.errorMessage ?? "Connection failed")
192192 }
193193 }
194194 }
ParseableViewer/Views/LogTableView.swift+1−1
@@ -23,7 +23,7 @@ func idealColumnWidth(for column: String, records: [LogRecord]) -> CGFloat {
2323 // Sample evenly across the whole result set, not just the first N rows —
2424 // otherwise a wide value that sorts beyond row N is never measured and the
2525 // 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))
2727 for i in stride(from: 0, to: records.count, by: step).prefix(sampleCount) {
2828 let text = records[i][column]?.displayString ?? ""
2929 widest = max(widest, measureTextWidth(text, font: cellMeasureFont))
ParseableViewer/Views/StreamDetailView.swift−8
@@ -20,14 +20,6 @@ struct StreamDetailView: View {
2020 Text(stream)
2121 .font(.title2)
2222 .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− }
3123 }
3224
3325 Spacer()