@@ -80,6 +80,11 @@ final class QueryViewModel {
80 /// The stream of the most recent `loadSchema` call, used to drop a stale
80 /// The stream of the most recent `loadSchema` call, used to drop a stale
81 /// in-flight response when the user rapidly switches streams.
81 /// in-flight response when the user rapidly switches streams.
82 private(set) var latestSchemaStream: String?
82 private(set) var latestSchemaStream: String?
83+ /// The stream of the most recent `executeQuery` call. Defense-in-depth
84+ /// alongside `queryGeneration`/task cancellation: drops a stale in-flight
85+ /// query response if a newer query for a different stream supersedes it
86+ /// without going through the normal cancel path.
87+ private var latestQueryStream: String?
84 // Query task for cancellation
89 // Query task for cancellation
85 private var queryTask: Task<Void, Never>?
90 private var queryTask: Task<Void, Never>?
@@ -415,6 +420,7 @@ final class QueryViewModel {
415 // the loading indicator during this query).
420 // the loading indicator during this query).
416 queryGeneration &+= 1
421 queryGeneration &+= 1
417 let generation = queryGeneration
422 let generation = queryGeneration
423+ latestQueryStream = stream
419 isLoading = true
425 isLoading = true
420 errorMessage = nil
426 errorMessage = nil
@@ -442,6 +448,9 @@ final class QueryViewModel {
442 // cancellation landing just after the check above would otherwise
448 // cancellation landing just after the check above would otherwise
443 // let a stale query clobber the newer one's results/columns.
449 // let a stale query clobber the newer one's results/columns.
444 guard !Task.isCancelled else { return }
450 guard !Task.isCancelled else { return }
451+ // Drop the response if a newer query (possibly for a different
452+ // stream) has since been launched without cancelling this task.
453+ guard latestQueryStream == stream else { return }
446 results = queryResults
455 results = queryResults
447 // Drop any prior row selection: the new result set reassigns row
456 // Drop any prior row selection: the new result set reassigns row
@@ -484,6 +493,9 @@ final class QueryViewModel {
485 } catch {
494 } catch {
486 guard !Task.isCancelled else { return }
495 guard !Task.isCancelled else { return }
496+ // Same supersession guard as the success path: a stale failed
497+ // query must not clobber a newer query's state.
498+ guard latestQueryStream == stream else { return }
487 errorMessage = ParseableError.userFriendlyMessage(for: error)
499 errorMessage = ParseableError.userFriendlyMessage(for: error)
488 if let serverError = error as? ParseableError,
500 if let serverError = error as? ParseableError,
489 case .serverError(_, let msg) = serverError,
501 case .serverError(_, let msg) = serverError,
@@ -682,6 +694,10 @@ final class QueryViewModel {
682 errorRange = nil
694 errorRange = nil
683 resultsTruncated = false
695 resultsTruncated = false
684 schemaFields = []
696 schemaFields = []
697+ // Keep the stream trackers consistent with the cleared schema/results so
698+ // a later reload guard never sees a non-nil stream paired with empty data.
699+ latestSchemaStream = nil
700+ latestQueryStream = nil
687 /// Modifies `sqlQuery` to add a column-value filter condition.
703 /// Modifies `sqlQuery` to add a column-value filter condition.