git.iko.soy/parseable-macos
on master help sign in
Fix 4 bugs: timer deinit thread safety, ISO8601 formatter concurrency, QueryViewModel cache race, SQL identifier ASCII alignment
commit9678dcf0beabd39b7da5163d14095fbdf651b085 authoriko <mail@iko.soy> dateMon 22 Jun 2026, 07:13 (3 months ago) parent58be682
4 files changed+41−7
ParseableViewer/Models/SQLTokenizer.swift+12−2 ParseableViewer/Services/ParseableClient.swift+9−4 ParseableViewer/ViewModels/LiveTailViewModel.swift+9−1 ParseableViewer/ViewModels/QueryViewModel.swift+11
ParseableViewer/Models/SQLTokenizer.swift+12−2
@@ -131,8 +131,8 @@ struct SQLTokenizer: Sendable {
131131 tokens.append(Token(kind: .number(String(sql[start..<i])), range: start..<i))
132132
133133 // Identifier or keyword
134− case _ where c.isLetter || c == "_":
135− advance(&i, in: sql, while: { $0.isLetter || $0.isNumber || $0 == "_" })
134+ case _ where isASCIILetter(c) || c == "_":
135+ advance(&i, in: sql, while: { isASCIILetter($0) || isASCIIDigit($0) || $0 == "_" })
136136 let word = String(sql[start..<i])
137137 if keywords.contains(word.uppercased()) {
138138 tokens.append(Token(kind: .keyword(word.uppercased()), range: start..<i))
@@ -264,6 +264,16 @@ struct SQLTokenizer: Sendable {
264264 c.isASCII && c.isNumber
265265 }
266266
267+ /// Bare (unquoted) SQL identifiers use only ASCII letters. `Character.isLetter`
268+ /// matches the full Unicode letter range (`café`, `naïve`, etc.), which
269+ /// DataFusion's lexer would require to be double-quoted — accepting them bare
270+ /// here would tokenize them as identifiers and diverge token offsets from the
271+ /// server, throwing off `errorHighlightRange` mapping (same rationale as
272+ /// `isASCIIDigit`).
273+ private static func isASCIILetter(_ c: Character) -> Bool {
274+ c.isASCII && c.isLetter
275+ }
276+
267277 private static func consumeNumber(_ i: inout String.Index, in sql: String) {
268278 // Digits with at most one decimal point, so malformed input like
269279 // `1.2.3` doesn't get swallowed into a single number token.
ParseableViewer/Services/ParseableClient.swift+9−4
@@ -101,11 +101,15 @@ final class ParseableClient: Sendable {
101101 // so a single shared instance would be a data race. Constructing one is cheap.
102102 private static var jsonDecoder: JSONDecoder { JSONDecoder() }
103103
104− private static nonisolated(unsafe) let isoFormatter: ISO8601DateFormatter = {
104+ // A fresh formatter per access, for the same reason as `jsonDecoder` above:
105+ // `query` can run concurrently and `ISO8601DateFormatter.string(from:)` isn't
106+ // documented as thread-safe, so a single shared instance would contradict the
107+ // file's per-call concurrency discipline. Constructing one is cheap.
108+ private static var isoFormatter: ISO8601DateFormatter {
105109 let f = ISO8601DateFormatter()
106110 f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
107111 return f
108− }()
112+ }
109113
110114 init(url: URL, username: String, password: String) {
111115 self.baseURL = url
@@ -288,10 +292,11 @@ final class ParseableClient: Sendable {
288292 // MARK: - Query
289293
290294 func query(sql: String, startTime: Date, endTime: Date) async throws -> [LogRecord] {
295+ let formatter = Self.isoFormatter
291296 let body: [String: Any] = [
292297 "query": sql,
293− "startTime": Self.isoFormatter.string(from: startTime),
294− "endTime": Self.isoFormatter.string(from: endTime)
298+ "startTime": formatter.string(from: startTime),
299+ "endTime": formatter.string(from: endTime)
295300 ]
296301
297302 let bodyData = try JSONSerialization.data(withJSONObject: body)
ParseableViewer/ViewModels/LiveTailViewModel.swift+9−1
@@ -57,7 +57,15 @@ final class LiveTailViewModel {
5757 private var pollGeneration = 0
5858
5959 deinit {
60− timer?.invalidate()
60+ // Timer.invalidate() must run on the run loop the timer was scheduled on
61+ // (RunLoop.main, see start()). deinit is nonisolated and can run off the
62+ // main thread, so hop to the main queue rather than invalidating inline.
63+ // The scheduled timer only weakly references self, so deferring teardown
64+ // past deallocation is safe — at worst it fires once more as a no-op
65+ // before being invalidated. stop() remains the normal teardown path.
66+ if let timer {
67+ DispatchQueue.main.async { timer.invalidate() }
68+ }
6169 }
6270
6371 // Cached formatters — nonisolated(unsafe) so static methods can use them from Task.detached
ParseableViewer/ViewModels/QueryViewModel.swift+11
@@ -17,6 +17,8 @@ final class QueryViewModel {
1717 // Build search texts asynchronously
1818 searchTextTask?.cancel()
1919 cachedSearchTexts = []
20+ searchTextGeneration &+= 1
21+ let generation = searchTextGeneration
2022 // Apply the current filter immediately using the inline fallback path
2123 // (the cache was just invalidated). Calling updateFilteredResults()
2224 // rather than `filteredResults = results` avoids briefly showing
@@ -36,6 +38,12 @@ final class QueryViewModel {
3638 }
3739 }.value
3840 guard let self, !Task.isCancelled else { return }
41+ // Reject a stale write: if `results` was reassigned while this task
42+ // was awaiting, a newer didSet bumped the generation. Task.isCancelled
43+ // alone isn't sufficient because two re-queries producing result sets
44+ // of identical count would otherwise let this stale, same-length
45+ // cachedSearchTexts slip past updateFilteredResults's count guard.
46+ guard generation == self.searchTextGeneration else { return }
3947 self.cachedSearchTexts = texts
4048 if !self.filterText.isEmpty {
4149 self.updateFilteredResults()
@@ -45,6 +53,9 @@ final class QueryViewModel {
4553 }
4654 private var cachedSearchTexts: [String] = []
4755 private var searchTextTask: Task<Void, Never>?
56+ /// Bumped on every `results` reassignment so the async search-text builder can
57+ /// detect it was superseded and drop a stale write (see results.didSet).
58+ private var searchTextGeneration = 0
4859 var columns: [String] = []
4960 var columnOrder: [String] = []
5061 var hiddenColumns: Set<String> = []