From 187b5c3ba55deef07c9d82bead45fa03df56984d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AF=94=E6=AF=94?= Date: Wed, 14 Oct 2026 11:38:32 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=AE=93=20SQLite=20=E9=95=B7=E6=9F=A5?= =?UTF-8?q?=E8=A9=A2=E4=B8=8D=E9=98=BB=E5=A1=9E=E7=95=AB=E9=9D=A2=E4=B8=A6?= =?UTF-8?q?=E5=8F=AF=E7=9C=9F=E6=AD=A3=E4=B8=AD=E6=96=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQLite 的 async ADO 呼叫仍同步執行,原先讓取消按鈕與快捷鍵在計算期間無法回應。SQL 與執行計畫改由背景工作執行,並用原生 progress handler 檢查取消,解除 callback 後才釋放連線。操作鎖仍等查詢真正停止才釋放,取消訊息不宣稱已提交 SQL 回滾。 加入隔離 SQLite 長計算、取消後查詢與已提交 DML 回歸,桌面 harness 改用正式 SQL 與 Cancel handlers 驗證 UI tick 和重複點擊。 --- CHANGELOG.md | 1 + docs/FEATURE_NOTES.md | 6 ++ .../Providers/AdoDatabaseSession.cs | 6 +- .../Providers/SqliteDatabaseSession.cs | 29 +++++++++ mySQLPunk.CrossPlatform.SmokeTests/Program.cs | 59 +++++++++++++++++++ mySQLPunk.Desktop/MainWindow.axaml.cs | 2 +- .../DesktopProfileOperationHarness/Program.cs | 24 ++++++-- 7 files changed, 117 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f832d3b..d5ebaca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ - **Linux/macOS SSH Tunnel**:跨平台連線設定可勾選「透過 SSH Tunnel 連線」,填入 SSH 主機、port、使用者、密碼或 OpenSSH/PEM 私鑰(含密語)與必填的主機金鑰 SHA256 指紋;連線時以 SSH.NET 建立只綁定 127.0.0.1 動態 port 的本機轉送,MySQL/MariaDB、PostgreSQL、SQL Server 的所有查詢與資料表操作都改走轉送端點,session 中斷或程式關閉時一併關閉;SSH 連線被跳板機中斷(閒置逾時、重啟、網路中斷)後,下一次資料庫操作會以同樣的指紋規則自動重建轉送,而不是把死掉的端點交給驅動程式。主機金鑰與指紋完全比對、不符即中止並回報伺服器實際指紋;MD5 或格式錯誤的指紋、缺主機/使用者/指紋、非絕對路徑私鑰、私鑰權限過寬與 MySQL/PostgreSQL 的 VerifyFull(127.0.0.1 主機名稱必定不符)一律 fail closed,SQL Server 則改以原始主機名稱驗證憑證。SSH 密碼與私鑰密語不寫入 `connections.json`(設定檔含這兩個欄位會拒絕載入),勾選系統密碼庫時與資料庫密碼一樣以獨立項目存入 Linux Secret Service/macOS Keychain,否則只保留到本次程式關閉;smoke test 會以本機臨時 sshd 實機驗證金鑰登入、加密私鑰、錯誤密語、指紋不符、權限過寬與 provider session 整合,實機矩陣另會經 tunnel 對 MySQL 8/MariaDB 11.4(Required)、PostgreSQL 16(VerifyCA+CA)與 SQL Server 2022(Mandatory+伺服器憑證,以原始主機名稱驗證)實際查詢與列出資料庫。 ### 🛠️ 問題修正與優化 +- SQLite SQL 與執行計畫改在背景執行,長查詢透過原生 progress handler 中斷,不再凍結 UI;取消後等實際語句停止才釋放操作鎖。取消訊息明示先前已提交語句不會自動回滾,避免盲目重試。 - SQL Server 相容搬移改用既有連線設定工廠,正常 SQL authentication 不再受已開啟連線隱藏密碼影響,SSH tunnel 與 provider owner 一起釋放。 - 桌面編輯、刪除與準備連線全程持有操作 gate,等待密碼或確認對話框時也不允許 SQL 重入;取消後不再更新設定或斷開現有 session。 diff --git a/docs/FEATURE_NOTES.md b/docs/FEATURE_NOTES.md index 498fec5..ec54067 100644 --- a/docs/FEATURE_NOTES.md +++ b/docs/FEATURE_NOTES.md @@ -618,3 +618,9 @@ ## SQL Server 搬移安全性 新增受限的 SQL Server 邏輯降版預檢與 Windows 確認介面;來源/目標版本、空目標、定序、feature 白名單、全交易及內容驗證的詳細範圍與未驗證矩陣,見 [MSSQL_COMPATIBILITY_MIGRATION.md](MSSQL_COMPATIBILITY_MIGRATION.md)。原生 RESTORE 的版本 guard 不等於完整邏輯降版;一般資料傳輸的列數驗證也不等於內容驗證。 +# SQLite SQL 背景執行與取消 + +`ExecuteAsync` 與 `ExplainAsync` 在背景工作執行,因 Microsoft.Data.Sqlite 的 async ADO 方法實際同步運作。每個查詢使用獨立連線;在讀取結果期間安裝 SQLitePCL 原生 progress handler,每 1,000 VM 指令檢查 token,取消時回傳非零使 SQLite 停止。callback 只在工作執行緒讀取 token,不從取消執行緒碰 native handle;reader 關閉後先解除 callback,才釋放連線,沒有跨連線中斷或 registration/dispose 競態。 + +桌面 gate 等完整 task 停止才釋放,取消結果不寫回 grid。隔離 smoke 實際執行 50,000,000 次 recursive 計算,驗證快速返回、三次取消與後續查詢、一般 SQL 錯誤及已提交 DML 不回滾。Xvfb harness 使用正式 SQL/Cancel handlers 驗證 UI tick、重複點擊、busy edit/delete/connect、取消後查詢。這不是資料同步或所有 metadata 路徑的背景化保證;鎖等待仍受 provider timeout 限制。 + diff --git a/mySQLPunk.Core/Providers/AdoDatabaseSession.cs b/mySQLPunk.Core/Providers/AdoDatabaseSession.cs index 62923f2..14c6480 100644 --- a/mySQLPunk.Core/Providers/AdoDatabaseSession.cs +++ b/mySQLPunk.Core/Providers/AdoDatabaseSession.cs @@ -135,7 +135,7 @@ public abstract Task> GetObjectsAsync( string database, CancellationToken cancellationToken = default); - public async Task ExecuteAsync( + public virtual async Task ExecuteAsync( string database, string sql, CancellationToken cancellationToken = default) @@ -437,7 +437,7 @@ public async Task ExecuteBatchAsync( return new StatementBatchResult(useTransaction, results); } - public async Task ExplainAsync( + public virtual async Task ExplainAsync( string database, string sql, CancellationToken cancellationToken = default) @@ -458,7 +458,7 @@ protected virtual Task ReadPlanRowsAsync( CancellationToken cancellationToken) => ReadResultAsync(connection, explainSql, cancellationToken); - protected async Task ReadResultAsync( + protected virtual async Task ReadResultAsync( DbConnection connection, string sql, CancellationToken cancellationToken) diff --git a/mySQLPunk.Core/Providers/SqliteDatabaseSession.cs b/mySQLPunk.Core/Providers/SqliteDatabaseSession.cs index 5817a32..8500fcd 100644 --- a/mySQLPunk.Core/Providers/SqliteDatabaseSession.cs +++ b/mySQLPunk.Core/Providers/SqliteDatabaseSession.cs @@ -10,6 +10,35 @@ public SqliteDatabaseSession(ConnectionProfile profile) { } + public override Task ExecuteAsync(string database, string sql, CancellationToken cancellationToken = default) => + Task.Run(() => base.ExecuteAsync(database, sql, cancellationToken), cancellationToken); + + public override Task ExplainAsync(string database, string sql, CancellationToken cancellationToken = default) => + Task.Run(() => base.ExplainAsync(database, sql, cancellationToken), cancellationToken); + + protected override async Task ReadResultAsync( + System.Data.Common.DbConnection connection, string sql, CancellationToken cancellationToken) + { + var handle = ((SqliteConnection)connection).Handle!; + SQLitePCL.raw.sqlite3_progress_handler(handle, 1000, + _ => cancellationToken.IsCancellationRequested ? 1 : 0, null); + try + { + cancellationToken.ThrowIfCancellationRequested(); + var result = await base.ReadResultAsync(connection, sql, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return result; + } + catch (SqliteException exception) when (exception.SqliteErrorCode == 9 && cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException("SQLite 已中止目前語句;先前自動提交的語句不會因此回滾。", exception, cancellationToken); + } + finally + { + SQLitePCL.raw.sqlite3_progress_handler(handle, 0, null, null); + } + } + protected override SqliteConnection CreateConnection(string? database) { var dataSource = string.IsNullOrWhiteSpace(database) ? Profile.Database : database; diff --git a/mySQLPunk.CrossPlatform.SmokeTests/Program.cs b/mySQLPunk.CrossPlatform.SmokeTests/Program.cs index 15ea07d..4aa4b85 100644 --- a/mySQLPunk.CrossPlatform.SmokeTests/Program.cs +++ b/mySQLPunk.CrossPlatform.SmokeTests/Program.cs @@ -32,6 +32,7 @@ ("Linux Secret Service 安全 round-trip", LinuxSecretServiceRoundTripAsync), ("macOS Keychain 安全 round-trip", MacOsKeychainRoundTripAsync), ("SQLite 查詢與 DDL/DML", SqliteExecutesQueriesAsync), + ("SQLite 背景查詢與原生取消", SqliteResponsiveCancellationAsync), ("執行計畫解析與安全規則", QueryPlanParsingAsync), ("資料字典結構與 HTML 匯出", DataDictionaryAsync), ("結構比較差異與報告", SchemaComparisonAsync), @@ -2197,6 +2198,64 @@ exit 2 } } +static async Task SqliteResponsiveCancellationAsync() +{ + var directory = CreateTemporaryDirectory(); + try + { + var profile = CreateSqliteProfile(Path.Combine(directory, "responsive.db")); + using var session = DatabaseProviderFactory.Create(profile); + await session.ExecuteAsync(profile.Database, "CREATE TABLE committed (id INTEGER); INSERT INTO committed VALUES (1);"); + const string calculation = "WITH RECURSIVE numbers(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM numbers WHERE n < 50000000) SELECT sum(n) FROM numbers;"; + for (var attempt = 0; attempt < 3; attempt++) + { + using var cancellation = new CancellationTokenSource(); + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var pending = session.ExecuteAsync(profile.Database, calculation, cancellation.Token); + Assert(stopwatch.Elapsed < TimeSpan.FromMilliseconds(250) && !pending.IsCompleted, + "呼叫不能同步執行整個 SQLite 計算"); + await Task.Delay(50); + cancellation.Cancel(); + try + { + await pending.WaitAsync(TimeSpan.FromSeconds(3)); + throw new InvalidOperationException("長計算應中斷"); + } + catch (OperationCanceledException) + { + Assert(pending.IsCompleted && stopwatch.Elapsed < TimeSpan.FromSeconds(3), "原生計算必須已停止,不只是放棄等待"); + } + var next = await session.ExecuteAsync(profile.Database, "SELECT 42;"); + Assert(Convert.ToInt64(next.Rows.Single().Single()) == 42, "取消後下一查詢仍成功"); + } + using var writeCancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + try + { + await session.ExecuteAsync(profile.Database, "INSERT INTO committed VALUES (2); " + calculation, writeCancellation.Token) + .WaitAsync(TimeSpan.FromSeconds(3)); + throw new InvalidOperationException("複合語句應取消"); + } + catch (OperationCanceledException) { } + var committed = await session.ExecuteAsync(profile.Database, "SELECT count(*) FROM committed;"); + Assert(Convert.ToInt64(committed.Rows.Single().Single()) == 2, "取消不能宣稱先前 autocommit INSERT 已回滾"); + using var alreadyCancelled = new CancellationTokenSource(); + alreadyCancelled.Cancel(); + try + { + await session.ExecuteAsync(profile.Database, "INSERT INTO committed VALUES (3);", alreadyCancelled.Token); + throw new InvalidOperationException("預先取消不應寫入"); + } + catch (OperationCanceledException) { } + try + { + await session.ExecuteAsync(profile.Database, "SELECT * FROM missing_table;"); + throw new InvalidOperationException("一般 SQL 錯誤不應被吞掉"); + } + catch (Microsoft.Data.Sqlite.SqliteException) { } + } + finally { Directory.Delete(directory, true); } +} + static async Task SqliteExecutesQueriesAsync() { var directory = CreateTemporaryDirectory(); diff --git a/mySQLPunk.Desktop/MainWindow.axaml.cs b/mySQLPunk.Desktop/MainWindow.axaml.cs index 886a62e..19583a6 100644 --- a/mySQLPunk.Desktop/MainWindow.axaml.cs +++ b/mySQLPunk.Desktop/MainWindow.axaml.cs @@ -1504,7 +1504,7 @@ private async Task RunOperationAsync(string status, Func pendingSql = async token => { await barrier.Task; token.ThrowIfCancellationRequested(); }; - var pending = (Task)Invoke(window, "RunOperationAsync", "synthetic pending SQL", pendingSql)!; + const string calculation = "WITH RECURSIVE numbers(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM numbers WHERE n < 50000000) SELECT sum(n) FROM numbers;"; + window.FindControl("SqlEditor")!.Text = calculation; + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var pending = (Task)Invoke(window, "ExecuteCurrentSqlAsync", false)!; + Check(stopwatch.Elapsed < TimeSpan.FromMilliseconds(250) && !pending.IsCompleted, "Actual SQL handler returns promptly during real SQLite calculation"); + bool ticked = false; + Dispatcher.UIThread.Post(() => ticked = true); + await Task.Delay(50); + Check(ticked && !pending.IsCompleted, "Single UI thread keeps ticking while SQLite is executing"); + var activeCancellation = Field(window, "_operationCancellation"); + Invoke(window, "ExecuteSql_Click", null!, null!); + Check(ReferenceEquals(activeCancellation, Field(window, "_operationCancellation")), "Repeated actual SQL click retains original operation"); foreach (var handler in new[] { "EditProfile_Click", "DeleteProfile_Click", "ConnectProfile_Click" }) Invoke(window, handler, null!, null!); Check(!window.OwnedWindows.Any() && ReferenceEquals(session, Field(window, "_session")), "Busy actual handlers reject edit/delete/connect without dialogs or disconnect"); - ((CancellationTokenSource)Field(window, "_operationCancellation")!).Cancel(); + Invoke(window, "CancelOperation_Click", null!, null!); Invoke(window, "EditProfile_Click", null!, null!); Check(Field(window, "_operationCancellation") != null && !window.OwnedWindows.Any(), "Cancel retains real lease until pending operation completes"); - barrier.SetResult(); - await pending; + await pending.WaitAsync(TimeSpan.FromSeconds(3)); + Check(stopwatch.Elapsed < TimeSpan.FromSeconds(3) && Field(window, "_operationCancellation") == null, "Actual Cancel stops SQLite before releasing operation gate"); + window.FindControl("SqlEditor")!.Text = "SELECT 42;"; + await (Task)Invoke(window, "ExecuteCurrentSqlAsync", false)!; + Check(!window.OwnedWindows.Any() && ReferenceEquals(session, Field(window, "_session")), "Query after native interruption succeeds without disconnecting"); foreach (var handler in new[] { "EditProfile_Click", "DeleteProfile_Click" }) { Invoke(window, handler, null!, null!);