fix: 修复连接状态下切换配置后断开重连导致异常的问题
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s

问题现象:连接中切换配置 -> 断开 -> 连接,会出现连接后瞬间断开、
长时间无法重连。而先断开再切换配置则正常。

根因分析(三个层面的资源泄漏叠加):

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 连接时禁用配置下拉框
This commit is contained in:
2026-07-08 20:57:50 +08:00
parent bf4744bb1d
commit 3e7df0f4d8
4 changed files with 74 additions and 8 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ GO = go
CGO_ENABLED = 1 CGO_ENABLED = 1
WINDRES ?= x86_64-w64-mingw32-windres WINDRES ?= x86_64-w64-mingw32-windres
MINGW_CC ?= x86_64-w64-mingw32-gcc 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) GIT_HASH = $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
VERSION = $(SEMVER)-$(GIT_HASH) VERSION = $(SEMVER)-$(GIT_HASH)
LDFLAGS = -s -w -X lmvpn/internal/version.Version=$(VERSION) LDFLAGS = -s -w -X lmvpn/internal/version.Version=$(VERSION)
+16 -3
View File
@@ -19,6 +19,7 @@ import (
"net" "net"
"os" "os"
"os/signal" "os/signal"
"sync"
"syscall" "syscall"
"lmvpn/internal/ipc" "lmvpn/internal/ipc"
@@ -92,6 +93,7 @@ func chownToUser(path string, uid, gid int) {
type daemon struct { type daemon struct {
server *ipc.Server server *ipc.Server
mu sync.Mutex
session *vpn.SessionManager session *vpn.SessionManager
cancel context.CancelFunc cancel context.CancelFunc
} }
@@ -109,8 +111,11 @@ func (d *daemon) handle(conn net.Conn, req ipc.Request) {
d.server.Close() d.server.Close()
os.Exit(0) os.Exit(0)
case ipc.CmdStats: case ipc.CmdStats:
if d.session != nil { d.mu.Lock()
snap := d.session.Stats().Snapshot() sess := d.session
d.mu.Unlock()
if sess != nil {
snap := sess.Stats().Snapshot()
d.server.Broadcast(ipc.Event{Event: ipc.EvStats, Stats: &snap}) d.server.Broadcast(ipc.Event{Event: ipc.EvStats, Stats: &snap})
} }
_ = ipc.WriteOK(conn) _ = 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) { func (d *daemon) startSession(conn net.Conn, req ipc.Request) {
d.mu.Lock()
defer d.mu.Unlock()
if req.Config == nil { if req.Config == nil {
_ = ipc.WriteErr(conn, "missing config") _ = ipc.WriteErr(conn, "missing config")
return return
} }
if d.session != nil { if d.session != nil {
d.stopSession() d.stopSessionLocked()
} }
cfg := vpn.SessionConfig{ cfg := vpn.SessionConfig{
@@ -170,6 +177,12 @@ func (d *daemon) startSession(conn net.Conn, req ipc.Request) {
} }
func (d *daemon) stopSession() { func (d *daemon) stopSession() {
d.mu.Lock()
defer d.mu.Unlock()
d.stopSessionLocked()
}
func (d *daemon) stopSessionLocked() {
if d.cancel != nil { if d.cancel != nil {
d.cancel() d.cancel()
d.cancel = nil d.cancel = nil
+41 -2
View File
@@ -39,6 +39,13 @@ func (a *App) setConnButtons(connectEnabled, disconnectEnabled bool) {
} else { } else {
a.disconnectBtn.Disable() a.disconnectBtn.Disable()
} }
if a.profileSelect != nil {
if connectEnabled {
a.profileSelect.Enable()
} else {
a.profileSelect.Disable()
}
}
a.setupTray() a.setupTray()
} }
@@ -132,8 +139,13 @@ func (a *App) onConnect() {
} }
a.mu.Lock() a.mu.Lock()
oldClient := a.ipcClient
a.ipcClient = client a.ipcClient = client
a.mu.Unlock() a.mu.Unlock()
if oldClient != nil {
_ = ipc.SendStop(oldClient)
oldClient.Close()
}
// Get password from keychain. // Get password from keychain.
password, err := a.kc.GetPassword(a.currentProfile.Name) password, err := a.kc.GetPassword(a.currentProfile.Name)
@@ -190,13 +202,24 @@ func (a *App) onConnect() {
func (a *App) onDisconnect() { func (a *App) onDisconnect() {
a.mu.Lock() a.mu.Lock()
client := a.ipcClient client := a.ipcClient
a.ipcClient = nil
a.mu.Unlock() a.mu.Unlock()
if client == nil { if client == nil {
return return
} }
_ = ipc.SendStop(client) _ = ipc.SendStop(client)
client.Close()
a.setConnButtons(true, false) a.setConnButtons(true, false)
a.stateLabel.SetText(i18n.T("StateDisconnected")) 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. // eventLoop reads IPC events from the daemon and updates the UI.
@@ -234,17 +257,33 @@ func (a *App) eventLoop() {
switch ev.Event { switch ev.Event {
case ipc.EvState: case ipc.EvState:
fyne.Do(func() { 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: case ipc.EvStats:
if ev.Stats != nil { if ev.Stats != nil {
s := *ev.Stats s := *ev.Stats
fyne.Do(func() { fyne.Do(func() {
a.applyStats(s) a.mu.Lock()
current := a.ipcClient
a.mu.Unlock()
if current == client {
a.applyStats(s)
}
}) })
} }
case ipc.EvError: case ipc.EvError:
fyne.Do(func() { fyne.Do(func() {
a.mu.Lock()
current := a.ipcClient
a.mu.Unlock()
if current != client {
return
}
if ev.Code == "tls_error" { if ev.Code == "tls_error" {
showError(i18n.T("DlgTLSError"), showError(i18n.T("DlgTLSError"),
i18n.T("TLSErrorVerification")+"\n\n"+ev.Message, a.window) i18n.T("TLSErrorVerification")+"\n\n"+ev.Message, a.window)
+16 -2
View File
@@ -60,6 +60,7 @@ type SessionManager struct {
dev tun.Device dev tun.Device
routeMgr *route.Manager routeMgr *route.Manager
conn *transport.Conn conn *transport.Conn
done chan struct{}
// EWMA speed smoothing state. Only touched by reportStats (single // EWMA speed smoothing state. Only touched by reportStats (single
// goroutine), so no lock needed. ewma* fields hold the smoothed // 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) ctx, cancel := context.WithCancel(ctx)
sm.cancel = cancel sm.cancel = cancel
sm.running = true sm.running = true
sm.done = make(chan struct{})
sm.mu.Unlock() sm.mu.Unlock()
go sm.run(ctx, cfg) go sm.run(ctx, cfg)
@@ -126,21 +128,32 @@ func (sm *SessionManager) Disconnect() {
if cancel != nil { if cancel != nil {
cancel() cancel()
} }
// Close the transport to unblock the packet pump. // Close the transport to unblock the WS->TUN goroutine.
sm.mu.Lock() sm.mu.Lock()
conn := sm.conn conn := sm.conn
dev := sm.dev
sm.mu.Unlock() sm.mu.Unlock()
if conn != nil { if conn != nil {
conn.Close() 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 // run is the main session loop with exponential-backoff reconnection
// and CDN IP failover. // and CDN IP failover.
func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) { func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
fatal := false fatal := false
defer close(sm.done)
defer func() { defer func() {
if !fatal { if !fatal && ctx.Err() == nil {
sm.setState(stats.StateDisconnected) sm.setState(stats.StateDisconnected)
} }
}() }()
@@ -154,6 +167,7 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
for { for {
if ctx.Err() != nil { if ctx.Err() != nil {
sm.cleanup()
return return
} }