fix(datagrid): stop every second refresh from cancelling its own query (#2021) - #2024
Merged
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #2021.
The bug
Clicking Refresh on a table's Data tab fails with a red "Query cancelled" banner on every second click. The grid keeps the old rows underneath.
Root cause
Three defects stacked. The every-second-time pattern is what unpicks them.
1. A finished task drove a real database cancel.
PaginationCoordinator.cancelCurrentQuery()decided whether to cancel the database fromcurrentQueryTask != nil || currentRowCountTask != nil.currentQueryTaskis cleared on every completion path;currentRowCountTaskwas never cleared, so after a successful load it held a task that had already finished, and the next refresh sent a cancel to an idle connection. It was aimed at the wrong connection anyway: row counting runs on a separate pooled driver throughwithMetadataDriver, which the main connection's cancel cannot reach.2. The libpq cancel flag was sticky and belonged to no particular query.
_isCancelledwas set unconditionally and consumed only inside the row loop, so a cancel arriving while the connection was idle stayed armed and killed the next query at row 0. A zero-row result never consumed it and left it armed indefinitely.3. A cancellation was rendered as a failure. The existing guard only caught Swift's
CancellationError, and PostgreSQL threw a plain plugin error, so it reached the error banner and was recorded in query history as a failed query.The alternation follows: a failed query never launches phase-2 row counting, so the leaked handle is absent on the next pass.
currentRowCountTaskWhy this is fixed in the driver and not in the boolean
This is a regression of #1655, which fixed the same symptom on 11 June by narrowing that same expression to
currentQueryTask != nil. #1999 widened it again on 30 July and the bug came back. Before #1655 the cancel was unconditional and every refresh failed (#1637). Narrowing the expression a third time leaves the real defect in place: a cancel that arrives with nothing running must not be able to reach a later query. Fixed at that level, no future mistake in the caller can poison a query again.An audit of all 20 driver plugins found the same sticky-latch shape in three of them. MSSQL and MongoDB already reset at query start and were left alone.
Changes
A shared primitive.
PluginQueryCancellationGatein TableProPluginKit, a generation guard in the shape ofConnectionAttemptRegistry.cancel()returnsnilwhen nothing is running, so a driver knows not to send a transport-level cancel at all, and a recorded cancel can never match a later query's generation.PostgreSQL adopts it, covering Redshift and CockroachDB too through
LibPQBackedDriver. ThePQcancelis now gated on a query actually running, and the streaming path is bracketed so Stop still cancels a long export.MySQL/MariaDB adopts it. This also closes two leaks the report did not cover: the flag survived every non-row-returning statement (INSERT/UPDATE/DDL) and every zero-row prepared statement, and
KILL QUERYwas sent to idle connections.Redis adopts it. A stale flag there could fail a schema refresh, a ping, or a
MULTI/EXECcommit. The reset moves from 4 scattered driver entry points to the 3 shared chokepoints every command already funnels through, so it cannot be forgotten again.App layer.
cancelCurrentQuery()delegates to the already-correctcancelInFlightQueryTask(), removing the second, wrong notion of "is a query in flight". Both row-count tasks now clear their own handle behind a generation guard.Cancellation is no longer an error. New
DatabaseCancellationDiagnosis, applied at the banner sink itself so every caller is covered, following AppKit's own contract:presentError:silently ignoresNSUserCancelledError, and Apple's error guide says a user cancellation must not show an error dialog. Pressing Stop on PostgreSQL showed a red banner before this change.Deliberately no SQLSTATE table: PostgreSQL reports
57014forstatement_timeoutas well as for a user cancel, and MySQL1317coversmax_execution_time, so matching on those codes would have silently swallowed genuine timeout errors. Each driver reports a cancellation only when it caused one. Two tests pin that a real timeout still reaches the user.Testing
PluginQueryCancellationGateTests: idle cancel is a no-op, a cancel never matches a later generation, a zero-row query leaves nothing armed, a staleendQuerydoes not clear the running query.DatabaseCancellationDiagnosisTests, including the two tests that a57014timeout and a1317interruption are still surfaced.MainContentCoordinatorRefreshTestsgainscancelWithStaleRowCountHandleDoesNotTouchDriverandrepeatedIdleRefreshNeverCancelsDriver. Both were confirmed to fail on the pre-fix code and pass after it.PluginKit ABI
Additive.
scripts/check-pluginkit-abi.shreports one added public type and nothing removed or changed, so nocurrentPluginKitVersionbump and no registry re-release. All three drivers are bundled and ship with the app. Needs theabi-additivelabel.