Sfoglia il codice sorgente

fix: v2.3.0 三次迭代(竞态修复 + 线程安全配置 + 更新前停服务)

- 修复停止/启动竞态:停止请求落在 cmd.Start 与 pid 赋值之间时不再残留进程

- 配置写入线程安全:SetAutoUpdate/SetAutoStart 加锁并立即持久化

- 更新前先停止服务(避免 Windows 文件占用导致 npm 安装失败),成败都恢复运行

- 定期更新与立即更新统一走 UpdateAndRestart

- 新增 4 个单元测试(配置读写/BOM/损坏、setter、守护生命周期)+ build.ps1 一键构建

- 可测试性:支持注入数据目录,测试不触碰真实用户配置
caesar 1 giorno fa
parent
commit
7f0dafd489
8 ha cambiato i file con 217 aggiunte e 22 eliminazioni
  1. 15 4
      README.md
  2. 25 0
      build.ps1
  3. 6 2
      config.go
  4. 1 1
      main.go
  5. 42 0
      supervisor.go
  6. 7 13
      tray.go
  7. 1 2
      updater.go
  8. 120 0
      util_test.go

+ 15 - 4
README.md

@@ -13,20 +13,27 @@ DeepSeek Harness **Web 版桌面包装**(Windows 系统托盘 + 守护进程
 - **端口预检**:端口已被占用(例如已有实例)时不启动,改为温和等待(封顶 30s 轮询),避免端口冲突
 - **端口预检**:端口已被占用(例如已有实例)时不启动,改为温和等待(封顶 30s 轮询),避免端口冲突
 - **启停管理**:托盘一键启动 / 停止 / 重启;停止为**同步等待**(最多 8s),重启前等待端口释放
 - **启停管理**:托盘一键启动 / 停止 / 重启;停止为**同步等待**(最多 8s),重启前等待端口释放
 - **开机自启**:托盘勾选或 `autostart on|off`(写 HKCU Run 键,无需管理员权限)
 - **开机自启**:托盘勾选或 `autostart on|off`(写 HKCU Run 键,无需管理员权限)
-- **一键更新**:执行 `npm install -g @deepseek-ai/dsh@latest --allow-scripts=...`;可开启定期后台更新(**常驻单例循环**:关闭仅暂停计时,再次开启立即恢复)
+- **一键更新**:执行 `npm install -g @deepseek-ai/dsh@latest --allow-scripts=...`,**先停服务再更新**(避免 Windows 上 native 模块文件占用导致安装失败),无论成败都恢复原有运行状态;可开启定期后台更新(**常驻单例循环**:关闭仅暂停计时,再次开启立即恢复)
 - **带 token 打开**:捕获 `dsh web` 输出的访问地址;菜单标题只显示 origin,token 只存内存
 - **带 token 打开**:捕获 `dsh web` 输出的访问地址;菜单标题只显示 origin,token 只存内存
 - **安全**:日志自动脱敏 `token=***`;打开 URL/路径不经 shell 解析(`rundll32` / `explorer`)
 - **安全**:日志自动脱敏 `token=***`;打开 URL/路径不经 shell 解析(`rundll32` / `explorer`)
-- **健壮**:配置原子写入(临时文件 + rename)、支持 UTF-8 BOM、损坏配置回退默认值;日志按天轮转;进程树终止失败时退化为直接终止
+- **健壮**:配置原子写入(临时文件 + rename)且**写入线程安全**、支持 UTF-8 BOM、损坏配置回退默认值;日志按天轮转;进程树终止失败时退化为直接终止;**停止与启动过程的竞态已处理**(停止请求落在启动窗口时不会残留进程)
 - **单实例**:`Local` 命名互斥体(普通用户可创建,避免 `Global` 权限失败导致失效)
 - **单实例**:`Local` 命名互斥体(普通用户可创建,避免 `Global` 权限失败导致失效)
-- **可验证**:8 个单元测试覆盖脱敏、URL 提取、配置归一、端口探测、退避、日志轮转
+- **可验证**:11 个单元测试覆盖脱敏、URL 提取、配置读写与归一、端口探测、退避、日志轮转、守护生命周期
 - **低依赖**:第三方仅 2 个(`getlantern/systray`、`golang.org/x/sys`),单文件分发约 5 MB
 - **低依赖**:第三方仅 2 个(`getlantern/systray`、`golang.org/x/sys`),单文件分发约 5 MB
 
 
 ## 构建与测试
 ## 构建与测试
 
 
 ```powershell
 ```powershell
+.\build.ps1          # 一键:格式检查 + go vet + go test + 构建
+```
+
+分步执行:
+
+```powershell
+gofmt -l .          # 格式检查(应无输出)
 go vet ./...        # 静态检查
 go vet ./...        # 静态检查
 go test ./...       # 单元测试
 go test ./...       # 单元测试
-go build -ldflags "-H=windowsgui -s -w" -o deepseek-tray.exe .
+go build -trimpath -ldflags "-H:windowsgui -s -w" -o deepseek-tray.exe .
 ```
 ```
 
 
 ## 运行方式
 ## 运行方式
@@ -117,6 +124,9 @@ nssm start DeepSeekTray
 | TestIsPortOpen | 真实监听端口的探测 |
 | TestIsPortOpen | 真实监听端口的探测 |
 | TestBackoff | 退避序列与封顶(**此测试发现并修复了上限未生效问题**) |
 | TestBackoff | 退避序列与封顶(**此测试发现并修复了上限未生效问题**) |
 | TestLoggerRotate | 日志按天轮转 |
 | TestLoggerRotate | 日志按天轮转 |
+| TestConfigRoundTrip | 配置首次生成 / 保存重载 / BOM 容忍 / 损坏回退(隔离到临时目录) |
+| TestSetAutoUpdatePersists | 线程安全 setter 同时更新内存与磁盘 |
+| TestSupervisorLifecycle | 守护状态机:启动 / 自动重启 / 停止 |
 
 
 ### 运行时验证(v2.2.0 回归)
 ### 运行时验证(v2.2.0 回归)
 
 
@@ -139,6 +149,7 @@ nssm start DeepSeekTray
 
 
 ## 版本
 ## 版本
 
 
+- v2.3.0 — 三次迭代:修复**停止/启动竞态**(不再残留进程)、配置写入线程安全、更新前先停服务、配置读写单元测试与守护生命周期测试、`build.ps1` 一键构建
 - v2.2.0 — 二次迭代:**新增单元测试**(并借其修复退避上限问题)、修复定期更新"关闭后再开启"失效、职责拆分(probe/util)、日志按天轮转、进程终止兜底、daemon 优雅退出
 - v2.2.0 — 二次迭代:**新增单元测试**(并借其修复退避上限问题)、修复定期更新"关闭后再开启"失效、职责拆分(probe/util)、日志按天轮转、进程终止兜底、daemon 优雅退出
 - v2.1.0 — 自检优化:端口预检 + 指数退避 + 定期更新单例 + `Local` 互斥体 + 日志脱敏 + 原子配置 + `autostart` 子命令
 - v2.1.0 — 自检优化:端口预检 + 指数退避 + 定期更新单例 + `Local` 互斥体 + 日志脱敏 + 原子配置 + `autostart` 子命令
 - v2.0.0 — 重写为 `dsh web` 托盘守护(旧版余额查询功能归档于 `legacy-v1` 标签)
 - v2.0.0 — 重写为 `dsh web` 托盘守护(旧版余额查询功能归档于 `legacy-v1` 标签)

+ 25 - 0
build.ps1

@@ -0,0 +1,25 @@
+# deepseek-tray 构建脚本:格式检查 -> 静态检查 -> 单元测试 -> 构建
+$ErrorActionPreference = 'Stop'
+Push-Location $PSScriptRoot
+try {
+    Write-Host '[1/4] gofmt 检查'
+    $unformatted = gofmt -l .
+    if ($unformatted) { throw ('以下文件未格式化: ' + ($unformatted -join ', ')) }
+
+    Write-Host '[2/4] go vet'
+    go vet ./...
+    if ($LASTEXITCODE -ne 0) { throw 'go vet 失败' }
+
+    Write-Host '[3/4] go test'
+    go test ./...
+    if ($LASTEXITCODE -ne 0) { throw 'go test 失败' }
+
+    Write-Host '[4/4] go build'
+    go build -trimpath -ldflags '-H=windowsgui -s -w' -o deepseek-tray.exe .
+    if ($LASTEXITCODE -ne 0) { throw 'go build 失败' }
+
+    $size = [math]::Round((Get-Item 'deepseek-tray.exe').Length / 1KB, 0)
+    Write-Host ('构建成功: deepseek-tray.exe (' + $size + ' KB)')
+} finally {
+    Pop-Location
+}

+ 6 - 2
config.go

@@ -33,12 +33,16 @@ func defaultConfig() *Config {
 }
 }
 
 
 var (
 var (
-	dataDirOnce sync.Once
-	dataDirPath string
+	dataDirOnce   sync.Once
+	dataDirPath   string
+	dataDirForced string // 测试注入:非空时直接使用(便于隔离文件系统)
 )
 )
 
 
 // appDataDir 返回配置与日志目录(仅首次创建)。
 // appDataDir 返回配置与日志目录(仅首次创建)。
 func appDataDir() string {
 func appDataDir() string {
+	if dataDirForced != "" {
+		return dataDirForced
+	}
 	dataDirOnce.Do(func() {
 	dataDirOnce.Do(func() {
 		base := os.Getenv("APPDATA")
 		base := os.Getenv("APPDATA")
 		if base == "" {
 		if base == "" {

+ 1 - 1
main.go

@@ -8,7 +8,7 @@ import (
 	"syscall"
 	"syscall"
 )
 )
 
 
-const appVersion = "2.2.0"
+const appVersion = "2.3.0"
 
 
 // 会话级命名互斥体(Local 前缀无需特权;Global 前缀普通用户常创建失败,会导致单实例失效)
 // 会话级命名互斥体(Local 前缀无需特权;Global 前缀普通用户常创建失败,会导致单实例失效)
 const mutexName = "Local\\DeepSeekTraySingleton"
 const mutexName = "Local\\DeepSeekTraySingleton"

+ 42 - 0
supervisor.go

@@ -83,6 +83,41 @@ func (s *Supervisor) Stop() {
 	}
 	}
 }
 }
 
 
+// SetAutoUpdate 设置定期更新开关(加锁 + 立即持久化;开启时确保循环已启动)。
+func (s *Supervisor) SetAutoUpdate(enabled bool) error {
+	s.mu.Lock()
+	s.cfg.AutoUpdate = enabled
+	snapshot := *s.cfg
+	s.mu.Unlock()
+	if enabled {
+		s.startAutoUpdate()
+	}
+	return saveConfig(&snapshot)
+}
+
+// SetAutoStart 设置开机自启开关(加锁 + 立即持久化;注册表由调用方处理)。
+func (s *Supervisor) SetAutoStart(enabled bool) error {
+	s.mu.Lock()
+	s.cfg.AutoStart = enabled
+	snapshot := *s.cfg
+	s.mu.Unlock()
+	return saveConfig(&snapshot)
+}
+
+// UpdateAndRestart 先停止服务再更新,避免 Windows 上 native 模块文件被占用导致安装失败;
+// 无论更新成功与否都恢复到原有运行意图。
+func (s *Supervisor) UpdateAndRestart() (string, error) {
+	wasDesired := s.isDesired()
+	if wasDesired {
+		s.Stop()
+	}
+	out, err := s.Update()
+	if wasDesired {
+		s.Start()
+	}
+	return out, err
+}
+
 // Restart 重启并等待端口释放,避免新旧实例争抢端口。
 // Restart 重启并等待端口释放,避免新旧实例争抢端口。
 func (s *Supervisor) Restart() {
 func (s *Supervisor) Restart() {
 	s.log.Printf("重启 dsh web")
 	s.log.Printf("重启 dsh web")
@@ -221,8 +256,15 @@ func (s *Supervisor) monitor() {
 		s.startedAt = startedAt
 		s.startedAt = startedAt
 		s.lastError = ""
 		s.lastError = ""
 		s.webURL = ""
 		s.webURL = ""
+		desiredNow := s.desired
 		s.mu.Unlock()
 		s.mu.Unlock()
 		s.log.Printf("dsh web 已启动 pid=%d: %s %s", pid, exe, strings.Join(args, " "))
 		s.log.Printf("dsh web 已启动 pid=%d: %s %s", pid, exe, strings.Join(args, " "))
+		if !desiredNow {
+			// 竞态窗口:停止请求发生在 cmd.Start 之后、pid 赋值之前。
+			// 此时 Stop 读到的 pid 为 0 不会终止进程,这里补一次终止,避免残留。
+			s.log.Printf("启动期间收到停止请求,立即结束 pid=%d", pid)
+			_ = killTree(pid)
+		}
 
 
 		go s.pipeLog("out", stdout)
 		go s.pipeLog("out", stdout)
 		go s.pipeLog("err", stderr)
 		go s.pipeLog("err", stderr)

+ 7 - 13
tray.go

@@ -118,8 +118,7 @@ func trayReady(sup *Supervisor, cfg *Config, log *Logger) {
 				} else {
 				} else {
 					mAutoStart.Uncheck()
 					mAutoStart.Uncheck()
 				}
 				}
-				cfg.AutoStart = next
-				if err := saveConfig(cfg); err != nil {
+				if err := sup.SetAutoStart(next); err != nil {
 					log.Printf("保存配置失败: %v", err)
 					log.Printf("保存配置失败: %v", err)
 				}
 				}
 				log.Printf("开机自启 = %v", next)
 				log.Printf("开机自启 = %v", next)
@@ -130,23 +129,18 @@ func trayReady(sup *Supervisor, cfg *Config, log *Logger) {
 				} else {
 				} else {
 					mAutoUpdate.Uncheck()
 					mAutoUpdate.Uncheck()
 				}
 				}
-				cfg.AutoUpdate = next
-				if err := saveConfig(cfg); err != nil {
+				if err := sup.SetAutoUpdate(next); err != nil {
 					log.Printf("保存配置失败: %v", err)
 					log.Printf("保存配置失败: %v", err)
 				}
 				}
-				log.Printf("定期自动更新 = %v(间隔 %d 小时)", next, cfg.UpdateIntervalH)
-				if next {
-					sup.startAutoUpdate()
-				}
+				log.Printf("定期自动更新 = %v", next)
 			case <-mUpdateNow.ClickedCh:
 			case <-mUpdateNow.ClickedCh:
 				go func() {
 				go func() {
-					log.Printf("菜单:立即更新")
-					if _, err := sup.Update(); err != nil {
+					log.Printf("菜单:立即更新(先停服务再更新)")
+					if _, err := sup.UpdateAndRestart(); err != nil {
 						log.Printf("更新失败: %v", err)
 						log.Printf("更新失败: %v", err)
-						return
+					} else {
+						log.Printf("更新完成,服务已恢复运行")
 					}
 					}
-					log.Printf("更新完成,重启服务")
-					sup.Restart()
 					mVersion.SetTitle("版本:" + sup.installedVersion())
 					mVersion.SetTitle("版本:" + sup.installedVersion())
 				}()
 				}()
 			case <-mLogs.ClickedCh:
 			case <-mLogs.ClickedCh:

+ 1 - 2
updater.go

@@ -109,9 +109,8 @@ func (s *Supervisor) runScheduledUpdate() {
 		return
 		return
 	}
 	}
 	s.log.Printf("定期更新:发现新版本 %s(当前 %s),开始更新", latest, current)
 	s.log.Printf("定期更新:发现新版本 %s(当前 %s),开始更新", latest, current)
-	if _, err := s.Update(); err != nil {
+	if _, err := s.UpdateAndRestart(); err != nil {
 		s.log.Printf("定期更新失败: %v", err)
 		s.log.Printf("定期更新失败: %v", err)
 		return
 		return
 	}
 	}
-	s.Restart()
 }
 }

+ 120 - 0
util_test.go

@@ -3,6 +3,7 @@ package main
 import (
 import (
 	"net"
 	"net"
 	"os"
 	"os"
+	"path/filepath"
 	"strings"
 	"strings"
 	"testing"
 	"testing"
 	"time"
 	"time"
@@ -109,6 +110,125 @@ func TestIsPortOpen(t *testing.T) {
 	}
 	}
 }
 }
 
 
+// TestConfigRoundTrip 覆盖配置的首次生成、保存重载、BOM 容忍与损坏回退。
+// 通过 dataDirForced 注入临时目录,避免触碰真实用户配置。
+func TestConfigRoundTrip(t *testing.T) {
+	dataDirForced = t.TempDir()
+	defer func() { dataDirForced = "" }()
+
+	// 首次加载:生成默认配置
+	c := loadConfig()
+	if c.WebPort != 3080 || c.AutoUpdate || c.RestartDelaySec != 5 {
+		t.Fatalf("默认配置异常: %+v", c)
+	}
+	if _, err := os.Stat(configPath()); err != nil {
+		t.Errorf("首次加载应生成配置文件: %v", err)
+	}
+
+	// 保存后重载
+	c.WebPort = 3099
+	c.AutoUpdate = true
+	if err := saveConfig(c); err != nil {
+		t.Fatalf("保存失败: %v", err)
+	}
+	c2 := loadConfig()
+	if c2.WebPort != 3099 || !c2.AutoUpdate {
+		t.Errorf("重载不一致: %+v", c2)
+	}
+
+	// BOM 容忍
+	bom := append([]byte{0xEF, 0xBB, 0xBF}, []byte(`{"webPort":1234}`)...)
+	if err := os.WriteFile(configPath(), bom, 0o600); err != nil {
+		t.Fatal(err)
+	}
+	if got := loadConfig().WebPort; got != 1234 {
+		t.Errorf("BOM 配置应解析为 1234,得到 %d", got)
+	}
+
+	// 损坏配置回退默认值
+	if err := os.WriteFile(configPath(), []byte(`{ broken`), 0o600); err != nil {
+		t.Fatal(err)
+	}
+	if got := loadConfig().WebPort; got != 3080 {
+		t.Errorf("损坏配置应回退 3080,得到 %d", got)
+	}
+}
+
+// TestSupervisorLifecycle 用"立即退出的假命令"验证守护状态机:启动、自动重启、停止。
+func TestSupervisorLifecycle(t *testing.T) {
+	cmdExe := os.Getenv("COMSPEC")
+	if cmdExe == "" {
+		t.Skip("缺少 COMSPEC,跳过守护生命周期测试")
+	}
+	dir := t.TempDir()
+	dataDirForced = dir
+	defer func() { dataDirForced = "" }()
+
+	// 假 dsh:立即退出(触发守护重启)
+	bat := filepath.Join(dir, "fake-dsh.bat")
+	if err := os.WriteFile(bat, []byte("@echo off\r\nexit /b 1\r\n"), 0o600); err != nil {
+		t.Fatal(err)
+	}
+
+	cfg := defaultConfig()
+	cfg.NodePath = cmdExe
+	cfg.DshBinJS = bat
+	cfg.WebPort = 1 // 无监听端口,避免误判"端口被占用"
+	cfg.RestartDelaySec = 1
+
+	log := NewLogger()
+	defer log.Close()
+	s := NewSupervisor(cfg, log)
+
+	s.Start()
+	if !s.isDesired() {
+		t.Errorf("Start 后 desired 应为 true")
+	}
+	time.Sleep(2500 * time.Millisecond)
+	if got := s.Status().Restarts; got < 1 {
+		t.Errorf("假命令立即退出应至少触发一次自动重启,得到 %d", got)
+	}
+
+	s.Stop()
+	if s.isDesired() {
+		t.Errorf("Stop 后 desired 应为 false")
+	}
+	time.Sleep(600 * time.Millisecond)
+	if s.Status().Running {
+		t.Errorf("Stop 后不应处于运行态")
+	}
+}
+
+// TestSetAutoUpdatePersists 验证线程安全 setter 同时更新内存与磁盘。
+func TestSetAutoUpdatePersists(t *testing.T) {
+	dataDirForced = t.TempDir()
+	defer func() { dataDirForced = "" }()
+
+	log := NewLogger()
+	defer log.Close()
+	s := NewSupervisor(loadConfig(), log)
+
+	if err := s.SetAutoUpdate(true); err != nil {
+		t.Fatalf("SetAutoUpdate 失败: %v", err)
+	}
+	if !loadConfig().AutoUpdate {
+		t.Errorf("配置未持久化到磁盘")
+	}
+	s.mu.Lock()
+	inMemory := s.cfg.AutoUpdate
+	s.mu.Unlock()
+	if !inMemory {
+		t.Errorf("内存配置未更新")
+	}
+
+	if err := s.SetAutoUpdate(false); err != nil {
+		t.Fatalf("SetAutoUpdate(false) 失败: %v", err)
+	}
+	if loadConfig().AutoUpdate {
+		t.Errorf("关闭后配置仍为 true")
+	}
+}
+
 // TestLoggerRotate 验证日志按天轮转(切换文件),并清理测试产物。
 // TestLoggerRotate 验证日志按天轮转(切换文件),并清理测试产物。
 func TestLoggerRotate(t *testing.T) {
 func TestLoggerRotate(t *testing.T) {
 	l := NewLogger()
 	l := NewLogger()