1
0

fix: 桌面应用跨平台编译和单实例锁

- 使用 gofrs/flock 替代 syscall.Flock 以支持 Windows
- 引入 SingletonLock 结构体,支持锁路径参数化(测试与生产隔离)
- 对齐服务初始化流程与 cmd/server(RoutingCache、StatsBuffer)
- 添加 gofrs/flock 依赖
- 重写单例测试,覆盖加锁/解锁/重复加锁场景
- 更新 desktop-app 规范,补充跨平台锁细节
- 新增 cross-platform-singleton 规范
This commit is contained in:
2026-04-22 22:32:39 +08:00
parent 380586afa6
commit 15f08ee2ca
6 changed files with 141 additions and 52 deletions

View File

@@ -12,11 +12,11 @@ import (
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/getlantern/systray"
"github.com/gofrs/flock"
"github.com/pressly/goose/v3"
"go.uber.org/zap"
"gorm.io/driver/sqlite"
@@ -47,11 +47,12 @@ var (
func main() {
port := 9826
if err := acquireSingleInstance(); err != nil {
singleLock := NewSingletonLock(filepath.Join(os.TempDir(), "nex-gateway.lock"))
if err := singleLock.Lock(); err != nil {
showError("Nex Gateway", "已有 Nex 实例运行")
os.Exit(1)
}
defer releaseSingleInstance()
defer singleLock.Unlock()
if err := checkPortAvailable(port); err != nil {
showError("Nex Gateway", err.Error())
@@ -89,10 +90,21 @@ func main() {
modelRepo := repository.NewModelRepository(db)
statsRepo := repository.NewStatsRepository(db)
providerService := service.NewProviderService(providerRepo, modelRepo)
modelService := service.NewModelService(modelRepo, providerRepo)
routingService := service.NewRoutingService(modelRepo, providerRepo)
statsService := service.NewStatsService(statsRepo)
routingCache := service.NewRoutingCache(modelRepo, providerRepo, zapLogger)
if err := routingCache.Preload(); err != nil {
zapLogger.Warn("缓存预热失败,将使用懒加载", zap.Error(err))
}
statsBuffer := service.NewStatsBuffer(statsRepo, zapLogger,
service.WithFlushInterval(5*time.Second),
service.WithFlushThreshold(100))
statsBuffer.Start()
defer statsBuffer.Stop()
providerService := service.NewProviderService(providerRepo, modelRepo, routingCache)
modelService := service.NewModelService(modelRepo, providerRepo, routingCache)
routingService := service.NewRoutingService(routingCache)
statsService := service.NewStatsService(statsRepo, statsBuffer)
registry := conversion.NewMemoryRegistry()
if err := registry.Register(openai.NewAdapter()); err != nil {
@@ -378,31 +390,29 @@ func checkPortAvailable(port int) error {
return nil
}
var lockFile *os.File
type SingletonLock struct {
flock *flock.Flock
}
func acquireSingleInstance() error {
lockPath := filepath.Join(os.TempDir(), "nex-gateway.lock")
func NewSingletonLock(lockPath string) *SingletonLock {
return &SingletonLock{
flock: flock.New(lockPath),
}
}
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0666)
func (s *SingletonLock) Lock() error {
locked, err := s.flock.TryLock()
if err != nil {
return err
}
err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
if err != nil {
f.Close()
if !locked {
return fmt.Errorf("已有实例运行")
}
lockFile = f
return nil
}
func releaseSingleInstance() {
if lockFile != nil {
syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN)
lockFile.Close()
}
func (s *SingletonLock) Unlock() {
s.flock.Unlock()
}
func openBrowser(url string) error {

View File

@@ -3,37 +3,56 @@ package main
import (
"os"
"path/filepath"
"syscall"
"testing"
)
func TestAcquireSingleInstance(t *testing.T) {
lockPath := filepath.Join(os.TempDir(), "nex-gateway-test.lock")
origLockFile := lockFile
lockFile = nil
defer func() { lockFile = origLockFile }()
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0666)
if err != nil {
t.Fatalf("无法创建锁文件: %v", err)
}
defer f.Close()
func TestSingletonLock_FirstLockSuccess(t *testing.T) {
lockPath := filepath.Join(os.TempDir(), "nex-gateway-test-first.lock")
defer os.Remove(lockPath)
err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
if err != nil {
t.Fatalf("无法获取文件锁: %v", err)
lock := NewSingletonLock(lockPath)
if err := lock.Lock(); err != nil {
t.Fatalf("首次加锁应成功,但返回错误: %v", err)
}
defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
t.Log("单实例锁测试通过")
defer lock.Unlock()
}
func TestReleaseSingleInstance(t *testing.T) {
lockFile = nil
releaseSingleInstance()
t.Log("释放空锁测试通过")
func TestSingletonLock_DuplicateLockFails(t *testing.T) {
lockPath := filepath.Join(os.TempDir(), "nex-gateway-test-dup.lock")
defer os.Remove(lockPath)
lock1 := NewSingletonLock(lockPath)
if err := lock1.Lock(); err != nil {
t.Fatalf("首次加锁应成功: %v", err)
}
defer lock1.Unlock()
lock2 := NewSingletonLock(lockPath)
err := lock2.Lock()
if err == nil {
lock2.Unlock()
t.Fatal("重复加锁应失败,但返回 nil")
}
}
func TestSingletonLock_UnlockThenRelock(t *testing.T) {
lockPath := filepath.Join(os.TempDir(), "nex-gateway-test-relock.lock")
defer os.Remove(lockPath)
lock1 := NewSingletonLock(lockPath)
if err := lock1.Lock(); err != nil {
t.Fatalf("首次加锁应成功: %v", err)
}
lock1.Unlock()
lock2 := NewSingletonLock(lockPath)
if err := lock2.Lock(); err != nil {
t.Fatalf("释放后重新加锁应成功: %v", err)
}
lock2.Unlock()
}
func TestSingletonLock_UnlockWithoutLock(t *testing.T) {
lock := NewSingletonLock(filepath.Join(os.TempDir(), "nex-gateway-test-nil.lock"))
lock.Unlock()
}

View File

@@ -13,6 +13,7 @@ require (
github.com/getlantern/systray v1.2.2
github.com/gin-gonic/gin v1.12.0
github.com/go-playground/validator/v10 v10.30.2
github.com/gofrs/flock v0.13.0
github.com/google/uuid v1.6.0
github.com/mitchellh/mapstructure v1.5.0
github.com/pressly/goose/v3 v3.27.0
@@ -102,7 +103,6 @@ require (
github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
github.com/golangci/go-printf-func-name v0.1.0 // indirect

View File

@@ -239,8 +239,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=