| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317 |
- package main
- import (
- "bufio"
- "errors"
- "fmt"
- "io"
- "os"
- "os/exec"
- "path/filepath"
- "strconv"
- "strings"
- "sync"
- "time"
- )
- // Status 服务状态快照。
- type Status struct {
- Running bool
- PID int
- Restarts int
- StartedAt time.Time
- LastError string
- URL string
- Updating bool
- }
- // Supervisor 守护 dsh web:隐藏终端启动,异常退出自动重启。
- type Supervisor struct {
- mu sync.Mutex
- cfg *Config
- log *Logger
- cmd *exec.Cmd
- pid int
- running bool
- desired bool
- restarts int
- startedAt time.Time
- lastError string
- updating bool
- binCache string
- nodeCache string
- webURL string // dsh web 输出的完整 URL(含访问 token)
- }
- func NewSupervisor(cfg *Config, log *Logger) *Supervisor {
- return &Supervisor{cfg: cfg, log: log}
- }
- // Start 设置期望运行并拉起守护循环。
- func (s *Supervisor) Start() {
- s.mu.Lock()
- if s.desired {
- s.mu.Unlock()
- return
- }
- s.desired = true
- s.mu.Unlock()
- go s.monitor()
- }
- // Stop 停止守护并结束子进程树。
- func (s *Supervisor) Stop() {
- s.mu.Lock()
- s.desired = false
- pid := s.pid
- s.webURL = ""
- s.mu.Unlock()
- if pid > 0 {
- s.log.Printf("停止 dsh web pid=%d", pid)
- _ = killTree(pid)
- }
- }
- // Restart 重启服务。
- func (s *Supervisor) Restart() {
- s.log.Printf("重启 dsh web")
- s.Stop()
- time.Sleep(500 * time.Millisecond)
- s.Start()
- }
- func (s *Supervisor) Status() Status {
- s.mu.Lock()
- defer s.mu.Unlock()
- host := s.cfg.WebHost
- if host == "" {
- host = "127.0.0.1"
- }
- url := fmt.Sprintf("http://%s:%d", host, s.cfg.WebPort)
- if s.webURL != "" {
- url = s.webURL // 优先使用 dsh web 输出的带 token URL
- }
- return Status{
- Running: s.running,
- PID: s.pid,
- Restarts: s.restarts,
- StartedAt: s.startedAt,
- LastError: s.lastError,
- URL: url,
- Updating: s.updating,
- }
- }
- func (s *Supervisor) setError(err error) {
- s.mu.Lock()
- s.lastError = err.Error()
- s.mu.Unlock()
- s.log.Printf("守护错误: %v", err)
- }
- func (s *Supervisor) delay() time.Duration {
- sec := s.cfg.RestartDelaySec
- if sec <= 0 {
- sec = 5
- }
- return time.Duration(sec) * time.Second
- }
- // monitor 守护循环:启动 -> 等待退出 -> 期望运行时延时重启。
- func (s *Supervisor) monitor() {
- for {
- s.mu.Lock()
- desired := s.desired
- s.mu.Unlock()
- if !desired {
- return
- }
- exe, args, err := s.resolveCommand()
- if err != nil {
- s.setError(err)
- s.sleepInterruptible(s.delay())
- continue
- }
- cmd := exec.Command(exe, args...)
- hideWindow(cmd)
- stdout, errOut := cmd.StdoutPipe()
- stderr, errErr := cmd.StderrPipe()
- if errOut != nil || errErr != nil {
- s.setError(errors.New("无法创建输出管道"))
- s.sleepInterruptible(s.delay())
- continue
- }
- if err := cmd.Start(); err != nil {
- s.setError(err)
- s.sleepInterruptible(s.delay())
- continue
- }
- pid := cmd.Process.Pid
- s.mu.Lock()
- s.cmd = cmd
- s.pid = pid
- s.running = true
- s.startedAt = time.Now()
- s.lastError = ""
- s.webURL = ""
- s.mu.Unlock()
- s.log.Printf("dsh web 已启动 pid=%d: %s %s", pid, exe, strings.Join(args, " "))
- go s.pipeLog("out", stdout)
- go s.pipeLog("err", stderr)
- waitErr := cmd.Wait()
- s.mu.Lock()
- s.running = false
- s.cmd = nil
- s.pid = 0
- stillDesired := s.desired
- if stillDesired {
- s.restarts++
- }
- s.mu.Unlock()
- s.log.Printf("dsh web 退出(err=%v),期望运行=%v", waitErr, stillDesired)
- if !stillDesired {
- return
- }
- sleep := s.delay()
- s.log.Printf("%v 后自动重启", sleep)
- s.sleepInterruptible(sleep)
- }
- }
- func (s *Supervisor) sleepInterruptible(d time.Duration) {
- deadline := time.Now().Add(d)
- for time.Now().Before(deadline) {
- s.mu.Lock()
- desired := s.desired
- s.mu.Unlock()
- if !desired {
- return
- }
- time.Sleep(200 * time.Millisecond)
- }
- }
- // pipeLog 把子进程输出写入日志文件。
- func (s *Supervisor) pipeLog(tag string, r io.ReadCloser) {
- if r == nil {
- return
- }
- sc := bufio.NewScanner(r)
- sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
- for sc.Scan() {
- line := sc.Text()
- s.log.Printf("[dsh %s] %s", tag, line)
- // 捕获 dsh web 启动时输出的访问地址(含 token),供托盘直接打开
- if idx := strings.Index(line, "http://"); idx >= 0 {
- url := strings.TrimSpace(line[idx:])
- if i := strings.IndexAny(url, " \t"); i > 0 {
- url = url[:i]
- }
- if strings.HasPrefix(url, "http://") && len(url) > len("http://") {
- s.mu.Lock()
- if url != s.webURL {
- s.webURL = url
- s.log.Printf("捕获 Web 访问地址(含 token)")
- }
- s.mu.Unlock()
- }
- }
- }
- }
- // resolveCommand 解析启动命令:优先 node + dsh 入口 JS(无 shell 包装,最小化窗口风险)。
- func (s *Supervisor) resolveCommand() (string, []string, error) {
- node := s.cfg.NodePath
- if node == "" {
- node = s.nodePath()
- }
- binJS := s.cfg.DshBinJS
- if binJS == "" {
- binJS = s.dshBinJS()
- }
- if node != "" && binJS != "" {
- args := []string{binJS, "web", "--no-open", "--host", s.cfg.WebHost, "--port", strconv.Itoa(s.cfg.WebPort)}
- return node, args, nil
- }
- if p, err := exec.LookPath("dsh.cmd"); err == nil {
- args := []string{"/c", p, "web", "--no-open", "--port", strconv.Itoa(s.cfg.WebPort)}
- return "cmd.exe", args, nil
- }
- if p, err := exec.LookPath("dsh"); err == nil {
- args := []string{"web", "--no-open", "--port", strconv.Itoa(s.cfg.WebPort)}
- return p, args, nil
- }
- return "", nil, errors.New("未找到 dsh 入口:请确认已全局安装 @deepseek-ai/dsh,或在配置中指定 nodePath / dshBinJs")
- }
- func (s *Supervisor) nodePath() string {
- s.mu.Lock()
- if s.nodeCache != "" {
- v := s.nodeCache
- s.mu.Unlock()
- return v
- }
- s.mu.Unlock()
- p, err := exec.LookPath("node")
- if err != nil {
- return ""
- }
- s.mu.Lock()
- s.nodeCache = p
- s.mu.Unlock()
- return p
- }
- // dshBinJS 探测 dsh 的 bin.js:APPDATA\npm 与 npm root -g。
- func (s *Supervisor) dshBinJS() string {
- s.mu.Lock()
- if s.binCache != "" {
- v := s.binCache
- s.mu.Unlock()
- return v
- }
- s.mu.Unlock()
- var candidates []string
- if appdata := os.Getenv("APPDATA"); appdata != "" {
- candidates = append(candidates, filepath.Join(appdata, "npm", "node_modules", "@deepseek-ai", "dsh", "lib", "bin.js"))
- }
- if root := npmGlobalRoot(); root != "" {
- candidates = append(candidates, filepath.Join(root, "@deepseek-ai", "dsh", "lib", "bin.js"))
- }
- for _, c := range candidates {
- if fileExists(c) {
- s.mu.Lock()
- s.binCache = c
- s.mu.Unlock()
- s.log.Printf("dsh 入口: %s", c)
- return c
- }
- }
- return ""
- }
- // installedVersion 读取已安装 dsh 版本。
- func (s *Supervisor) installedVersion() string {
- binJS := s.cfg.DshBinJS
- if binJS == "" {
- binJS = s.dshBinJS()
- }
- if binJS == "" {
- return ""
- }
- pkg := filepath.Join(filepath.Dir(filepath.Dir(binJS)), "package.json")
- return readPackageVersion(pkg)
- }
- func fileExists(p string) bool {
- st, err := os.Stat(p)
- return err == nil && !st.IsDir()
- }
|