@@ -48,6 +48,10 @@ final class LiveTailViewModel {
48 /// (which would let the two interleave and write `lastTimestamp` /
48 /// (which would let the two interleave and write `lastTimestamp` /
49 /// `consecutiveErrors` out of order).
49 /// `consecutiveErrors` out of order).
50 private var isPolling = false
50 private var isPolling = false
51+ /// Bumped by start()/stop()/clear() so an in-flight poll whose network
52+ /// round-trip outlives one of those transitions can detect it was superseded
53+ /// and drop its results instead of repopulating a torn-down/cleared tail.
54+ private var pollGeneration = 0
53 timer?.invalidate()
57 timer?.invalidate()
@@ -56,6 +60,9 @@ final class LiveTailViewModel {
56 // Cached formatters — nonisolated(unsafe) so static methods can use them from Task.detached
60 // Cached formatters — nonisolated(unsafe) so static methods can use them from Task.detached
57 nonisolated(unsafe) private static let displayFormatter: DateFormatter = {
61 nonisolated(unsafe) private static let displayFormatter: DateFormatter = {
58 let f = DateFormatter()
62 let f = DateFormatter()
63+ // Fixed-format formatters must use en_US_POSIX so a non-Gregorian or
64+ // non-Latin-digit system locale doesn't render wrong numerals/era.
65+ f.locale = Locale(identifier: "en_US_POSIX")
59 f.dateFormat = "HH:mm:ss.SSS"
66 f.dateFormat = "HH:mm:ss.SSS"
@@ -168,6 +175,7 @@ final class LiveTailViewModel {
168 let storedMax = UserDefaults.standard.integer(forKey: "liveTailMaxEntries")
175 let storedMax = UserDefaults.standard.integer(forKey: "liveTailMaxEntries")
169 if storedMax > 0 { maxEntries = storedMax }
176 if storedMax > 0 { maxEntries = storedMax }
171 isRunning = true
179 isRunning = true
172 isPaused = false
180 isPaused = false
173 errorMessage = nil
181 errorMessage = nil
@@ -195,6 +203,7 @@ final class LiveTailViewModel {
197 func stop() {
205 func stop() {
198 timer?.invalidate()
207 timer?.invalidate()
199 timer = nil
208 timer = nil
200 isRunning = false
209 isRunning = false
@@ -206,8 +215,14 @@ final class LiveTailViewModel {
208 func clear() {
217 func clear() {
209 entries = []
219 entries = []
210 seenFingerprints = []
220 seenFingerprints = []
221+ // Advance the look-back floor to now: without this, the next poll queries
222+ // from the old `lastTimestamp` with an empty fingerprint set and re-inserts
223+ // every just-cleared record still inside the ~90 s window, repopulating the
224+ // view the user just emptied. Matches start()'s reset.
225+ lastTimestamp = Date()
211 allKnownKeys = []
226 allKnownKeys = []
212 droppedCount = 0
227 droppedCount = 0
213 columns = []
228 columns = []
@@ -355,6 +370,7 @@ final class LiveTailViewModel {
355 guard !isPolling else { return }
370 guard !isPolling else { return }
356 isPolling = true
371 isPolling = true
357 defer { isPolling = false }
372 defer { isPolling = false }
373+ let generation = pollGeneration
359 let now = Date()
375 let now = Date()
360 // The Parseable server truncates query time-ranges to minute
376 // The Parseable server truncates query time-ranges to minute
@@ -374,6 +390,11 @@ final class LiveTailViewModel {
375 let records = try await client.query(sql: sql, startTime: queryStart, endTime: now)
391 let records = try await client.query(sql: sql, startTime: queryStart, endTime: now)
393+ // A stop()/clear()/start during the network round-trip supersedes this
394+ // poll; don't write stale results to a torn-down or cleared tail. Also
395+ // bail if the user paused mid-flight.
396+ guard isRunning, !isPaused, generation == pollGeneration else { return }
377 // Process records off the main actor using a snapshot of seen fingerprints
398 // Process records off the main actor using a snapshot of seen fingerprints
378 let seenSnapshot = seenFingerprints
399 let seenSnapshot = seenFingerprints
379 let pollTime = now
400 let pollTime = now
@@ -395,6 +416,10 @@ final class LiveTailViewModel {
395 return results
416 return results
419+ // Re-check after the detached processing hop: a stop/clear could have
420+ // landed while we were off the main actor.
421+ guard isRunning, !isPaused, generation == pollGeneration else { return }
398 // Authoritative dedup on main actor
423 // Authoritative dedup on main actor
399 var newEntries: [LiveTailEntry] = []
424 var newEntries: [LiveTailEntry] = []
400 for entry in candidateEntries {
425 for entry in candidateEntries {
@@ -451,7 +476,10 @@ final class LiveTailViewModel {
451 errorMessage = nil
476 errorMessage = nil
452 consecutiveErrors = 0
477 consecutiveErrors = 0
453 } catch {
478 } catch {
479+ // Drop the error if this poll was cancelled or superseded by a
480+ // stop/clear/start — don't bump the failure counter or surface an
481+ // error on a tail the user already tore down.
454− guard !Task.isCancelled else { return }
482+ guard !Task.isCancelled, isRunning, generation == pollGeneration else { return }
455 consecutiveErrors += 1
483 consecutiveErrors += 1
456 if consecutiveErrors >= Self.maxConsecutiveErrors {
484 if consecutiveErrors >= Self.maxConsecutiveErrors {
457 errorMessage = "\(ParseableError.userFriendlyMessage(for: error)) — stopped after \(consecutiveErrors) consecutive failures"
485 errorMessage = "\(ParseableError.userFriendlyMessage(for: error)) — stopped after \(consecutiveErrors) consecutive failures"