Files
MyGoNavi/internal/app/methods_db_transaction_reload_test.go
Syngnat e6d77cd7ab 🐛 fix(transaction): 修复前端刷新后托管事务变成孤儿并长期持有行锁
复现路径:SQL 编辑器执行 DELETE 进入托管事务后不点提交、直接刷新前端,再执行同一条 DELETE,
会卡满 innodb_lock_wait_timeout(默认 50 秒)并报 Error 1205 Lock wait timeout exceeded,
只能重启应用才能恢复。

根因:待提交事务的 ID 只存在于前端组件内存(useSqlEditorTransactionController 的
useState/useRef),持久化状态里只有 commitMode/autoCommitDelayMs 这类设置。前端一旦刷新,
残留在 a.sqlTransactions 中的条目就再也无法被提交或回滚,却仍开着并持有 pinned 连接与行锁。
原先只有显式提交/回滚与应用退出(rollbackPendingSQLTransactionsOnShutdown)两条清理路径,
刷新不经过其中任何一条。

- 新增 Wails OnDomReady 钩子(每次 WebView 导航完成即触发,刷新必然命中),
  回滚所有已无法被引用的托管事务。首次加载时事务表为空,故无副作用;
  本应用是 React SPA,正常使用中不会产生整页导航,不会误杀活跃事务
- rollbackPendingSQLTransactionsOnShutdown 抽出公共实现,审计来源区分
  app_shutdown 与 frontend_reload,便于事后区分两类自动回滚
- 补 4 项回归测试:回滚并释放会话、空表无副作用、nil 容错、已完成事务不重复回滚。
  已确认禁用钩子后首个用例必定失败(回滚 0 次)

顺带说明:界面把 Error 1205 归类为「查询超时或被取消」并建议「检查执行计划、调整超时时间」
是误导——1205 是行锁等待超时,成因是另一事务持锁。该错误语义映射未在本次修改范围内。
2026-07-26 22:11:10 +08:00

91 lines
3.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"testing"
"GoNavi-Wails/internal/secretstore"
)
// TestHandleFrontendDomReadyRollsBackAbandonedTransactions 覆盖前端刷新导致的托管事务泄漏。
//
// 回归背景SQL 编辑器待提交事务的 ID 只存在于前端组件内存
// useSqlEditorTransactionController 的 useState/useRef持久化状态里只有
// commitMode/autoCommitDelayMs 这类设置。因此前端一旦刷新,残留在 a.sqlTransactions 中的
// 条目就再也无法被提交或回滚,却仍开着并持有数据库行锁,直到应用退出。
//
// 实测复现:执行 DELETE 进入托管事务后不点提交、直接刷新前端,再执行同一条 DELETE
// 会卡满 innodb_lock_wait_timeout默认 50 秒)并报
// Error 1205 (HY000): Lock wait timeout exceeded只能重启应用恢复。
//
// 修复后由 Wails 的 OnDomReady每次导航完成含刷新触发回滚。
func TestHandleFrontendDomReadyRollsBackAbandonedTransactions(t *testing.T) {
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
app.configDir = t.TempDir()
finisher := &fakeManagedTransactionFinisher{}
app.sqlTransactions["tx-abandoned"] = &managedSQLTransaction{
id: "tx-abandoned",
execer: finisher,
transactor: finisher,
dbType: "mysql",
}
HandleFrontendDomReady(app)
if finisher.rollbackCalls != 1 {
t.Fatalf("期望回滚 1 次,实际 %d 次(刷新后事务仍持有行锁)", finisher.rollbackCalls)
}
if finisher.closeCalls != 1 {
t.Errorf("期望关闭会话 1 次,实际 %d 次pinned 连接未释放)", finisher.closeCalls)
}
app.sqlTransactionMu.Lock()
remaining := len(app.sqlTransactions)
app.sqlTransactionMu.Unlock()
if remaining != 0 {
t.Errorf("回滚后事务表仍有 %d 条残留", remaining)
}
}
// TestHandleFrontendDomReadyIsNoOpWithoutPendingTransactions 首次加载时事务表为空,
// 本调用必须无副作用OnDomReady 在初次导航时也会触发)。
func TestHandleFrontendDomReadyIsNoOpWithoutPendingTransactions(t *testing.T) {
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
app.configDir = t.TempDir()
HandleFrontendDomReady(app)
app.sqlTransactionMu.Lock()
remaining := len(app.sqlTransactions)
app.sqlTransactionMu.Unlock()
if remaining != 0 {
t.Fatalf("空事务表被意外写入 %d 条", remaining)
}
}
// TestHandleFrontendDomReadyToleratesNilApp 防御 nil避免钩子在初始化竞态下 panic。
func TestHandleFrontendDomReadyToleratesNilApp(t *testing.T) {
HandleFrontendDomReady(nil)
}
// TestHandleFrontendDomReadySkipsAlreadyFinishedTransactions 已完成的事务不得重复回滚。
func TestHandleFrontendDomReadySkipsAlreadyFinishedTransactions(t *testing.T) {
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
app.configDir = t.TempDir()
finisher := &fakeManagedTransactionFinisher{}
app.sqlTransactions["tx-finished"] = &managedSQLTransaction{
id: "tx-finished",
execer: finisher,
transactor: finisher,
dbType: "mysql",
finished: true,
}
HandleFrontendDomReady(app)
if finisher.rollbackCalls != 0 {
t.Fatalf("已完成的事务被重复回滚 %d 次", finisher.rollbackCalls)
}
}