From 3e7df0f4d8ddd0d1da3ab3c8ac4fc8f03e9a5673 Mon Sep 17 00:00:00 2001 From: kevin Date: Wed, 8 Jul 2026 20:57:50 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E4=B8=8B=E5=88=87=E6=8D=A2=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=90=8E=E6=96=AD=E5=BC=80=E9=87=8D=E8=BF=9E=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E5=BC=82=E5=B8=B8=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题现象:连接中切换配置 -> 断开 -> 连接,会出现连接后瞬间断开、 长时间无法重连。而先断开再切换配置则正常。 根因分析(三个层面的资源泄漏叠加): 1. pumpPackets 死锁 (session.go) Disconnect() 只关闭 WebSocket transport,不关闭 TUN 设备。 TUN->WS goroutine 阻塞在 dev.Read(),pumpPackets 的 wg.Wait() 永远不返回,cleanup()(关闭 TUN/删路由)无法执行,导致 TUN 设备和路由泄漏。 2. 僵尸 eventLoop + 僵尸 IPC 连接 (view.go) onDisconnect 不清空 ipcClient、不关闭 IPC 连接,旧 eventLoop 持续运行接收广播事件。eventLoop 正常事件处理无身份校验, 旧 session 的 disconnected 广播会覆盖新 session 的 connected 状态。 3. stopSession 不等待 goroutine 退出 (daemon.go) d.session = nil 在 goroutine 仍在运行时即设置,新旧 session 并发执行导致 TUN/路由冲突。无 mutex 保护并发访问。 修复内容(10 项,3 个文件): session.go: - Disconnect() 新增 dev.Close() 解除 pumpPackets 死锁 - 新增 done channel,Disconnect() 阻塞等待 run goroutine 完全退出 - run() deferred setState 跳过用户主动断开时的冗余广播 - run() 顶部 ctx.Err() 检查后补 cleanup() daemon.go: - 新增 sync.Mutex 保护 session/cancel 并发访问 - 拆分 stopSession/stopSessionLocked 避免死锁 view.go: - onDisconnect 置 ipcClient=nil + client.Close() 终止僵尸 eventLoop - eventLoop 三个事件分支均加 if current == client 身份校验 - onConnect 覆盖前清理旧 IPC client - setConnButtons 连接时禁用配置下拉框 --- Makefile | 2 +- internal/daemon/daemon.go | 19 ++++++++++++++--- internal/ui/view.go | 43 +++++++++++++++++++++++++++++++++++++-- internal/vpn/session.go | 18 ++++++++++++++-- 4 files changed, 74 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index 8e07c54..5f59cd5 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ GO = go CGO_ENABLED = 1 WINDRES ?= x86_64-w64-mingw32-windres MINGW_CC ?= x86_64-w64-mingw32-gcc -SEMVER ?= 0.4.1 +SEMVER ?= 0.4.2 GIT_HASH = $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) VERSION = $(SEMVER)-$(GIT_HASH) LDFLAGS = -s -w -X lmvpn/internal/version.Version=$(VERSION) diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 47da857..63d4c54 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -19,6 +19,7 @@ import ( "net" "os" "os/signal" + "sync" "syscall" "lmvpn/internal/ipc" @@ -92,6 +93,7 @@ func chownToUser(path string, uid, gid int) { type daemon struct { server *ipc.Server + mu sync.Mutex session *vpn.SessionManager cancel context.CancelFunc } @@ -109,8 +111,11 @@ func (d *daemon) handle(conn net.Conn, req ipc.Request) { d.server.Close() os.Exit(0) case ipc.CmdStats: - if d.session != nil { - snap := d.session.Stats().Snapshot() + d.mu.Lock() + sess := d.session + d.mu.Unlock() + if sess != nil { + snap := sess.Stats().Snapshot() d.server.Broadcast(ipc.Event{Event: ipc.EvStats, Stats: &snap}) } _ = ipc.WriteOK(conn) @@ -122,12 +127,14 @@ func (d *daemon) handle(conn net.Conn, req ipc.Request) { } func (d *daemon) startSession(conn net.Conn, req ipc.Request) { + d.mu.Lock() + defer d.mu.Unlock() if req.Config == nil { _ = ipc.WriteErr(conn, "missing config") return } if d.session != nil { - d.stopSession() + d.stopSessionLocked() } cfg := vpn.SessionConfig{ @@ -170,6 +177,12 @@ func (d *daemon) startSession(conn net.Conn, req ipc.Request) { } func (d *daemon) stopSession() { + d.mu.Lock() + defer d.mu.Unlock() + d.stopSessionLocked() +} + +func (d *daemon) stopSessionLocked() { if d.cancel != nil { d.cancel() d.cancel = nil diff --git a/internal/ui/view.go b/internal/ui/view.go index 34c792d..b15f5ed 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -39,6 +39,13 @@ func (a *App) setConnButtons(connectEnabled, disconnectEnabled bool) { } else { a.disconnectBtn.Disable() } + if a.profileSelect != nil { + if connectEnabled { + a.profileSelect.Enable() + } else { + a.profileSelect.Disable() + } + } a.setupTray() } @@ -132,8 +139,13 @@ func (a *App) onConnect() { } a.mu.Lock() + oldClient := a.ipcClient a.ipcClient = client a.mu.Unlock() + if oldClient != nil { + _ = ipc.SendStop(oldClient) + oldClient.Close() + } // Get password from keychain. password, err := a.kc.GetPassword(a.currentProfile.Name) @@ -190,13 +202,24 @@ func (a *App) onConnect() { func (a *App) onDisconnect() { a.mu.Lock() client := a.ipcClient + a.ipcClient = nil a.mu.Unlock() if client == nil { return } _ = ipc.SendStop(client) + client.Close() a.setConnButtons(true, false) a.stateLabel.SetText(i18n.T("StateDisconnected")) + a.ipLabel.SetText(i18n.T("IpNone")) + a.ip6Label.SetText(i18n.T("Ip6None")) + a.uptimeLabel.SetText(i18n.T("UptimeNone")) + a.rxV4Label.SetText(i18n.T("RxV4Zero")) + a.txV4Label.SetText(i18n.T("TxV4Zero")) + a.rxV6Label.SetText(i18n.T("RxV6Zero")) + a.txV6Label.SetText(i18n.T("TxV6Zero")) + a.rxTotalLabel.SetText(i18n.T("RxTotalZero")) + a.txTotalLabel.SetText(i18n.T("TxTotalZero")) } // eventLoop reads IPC events from the daemon and updates the UI. @@ -234,17 +257,33 @@ func (a *App) eventLoop() { switch ev.Event { case ipc.EvState: fyne.Do(func() { - a.applyState(ev.State) + a.mu.Lock() + current := a.ipcClient + a.mu.Unlock() + if current == client { + a.applyState(ev.State) + } }) case ipc.EvStats: if ev.Stats != nil { s := *ev.Stats fyne.Do(func() { - a.applyStats(s) + a.mu.Lock() + current := a.ipcClient + a.mu.Unlock() + if current == client { + a.applyStats(s) + } }) } case ipc.EvError: fyne.Do(func() { + a.mu.Lock() + current := a.ipcClient + a.mu.Unlock() + if current != client { + return + } if ev.Code == "tls_error" { showError(i18n.T("DlgTLSError"), i18n.T("TLSErrorVerification")+"\n\n"+ev.Message, a.window) diff --git a/internal/vpn/session.go b/internal/vpn/session.go index c57a542..f76d74b 100644 --- a/internal/vpn/session.go +++ b/internal/vpn/session.go @@ -60,6 +60,7 @@ type SessionManager struct { dev tun.Device routeMgr *route.Manager conn *transport.Conn + done chan struct{} // EWMA speed smoothing state. Only touched by reportStats (single // goroutine), so no lock needed. ewma* fields hold the smoothed @@ -106,6 +107,7 @@ func (sm *SessionManager) Connect(ctx context.Context, cfg SessionConfig) error ctx, cancel := context.WithCancel(ctx) sm.cancel = cancel sm.running = true + sm.done = make(chan struct{}) sm.mu.Unlock() go sm.run(ctx, cfg) @@ -126,21 +128,32 @@ func (sm *SessionManager) Disconnect() { if cancel != nil { cancel() } - // Close the transport to unblock the packet pump. + // Close the transport to unblock the WS->TUN goroutine. sm.mu.Lock() conn := sm.conn + dev := sm.dev sm.mu.Unlock() if conn != nil { conn.Close() } + // Close the TUN device to unblock the TUN->WS goroutine's dev.Read(). + if dev != nil { + dev.Close() + } + // Wait for the run goroutine to fully exit so that cleanup + // (route removal, TUN teardown) is complete before returning. + if sm.done != nil { + <-sm.done + } } // run is the main session loop with exponential-backoff reconnection // and CDN IP failover. func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) { fatal := false + defer close(sm.done) defer func() { - if !fatal { + if !fatal && ctx.Err() == nil { sm.setState(stats.StateDisconnected) } }() @@ -154,6 +167,7 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) { for { if ctx.Err() != nil { + sm.cleanup() return }