package main // supervise.go — 看守 collector 子行程(t194) // // 🔴 沒有這一段,這個 App 就只是個「會顯示設定的空殼」—— // daemon 的本體是 `collector direct`,它才在監看資料夾、萃卡、上傳。 // (寫 Wails 版時我一度漏了它,等於做出一個裝了也不會同步的東西。) // // 邊界:**複用既有的 supervisor 套件**(重起退避、狀態機、round 解析都在裡面, // 含 t191 的 phase:start/done ⇒ 「同步中…」)。這裡只做「找到執行檔、拉起來」。 import ( "fmt" "os" "os/exec" "os/signal" "path/filepath" "runtime" "strings" "syscall" "time" "arcrun-rag/collector/supervisor" ) var sup *supervisor.Supervisor // collectorBinPath 找同綑的 collector 執行檔。 // 規則與 arcrun-tray 一致:跟 App 執行檔同層(build 時會複製進 .app)。 func collectorBinPath() string { name := "arcrun-collector" if runtime.GOOS == "windows" { name += ".exe" } exe, err := os.Executable() if err != nil { return name } if p, err := filepath.EvalSymlinks(exe); err == nil { exe = p } return filepath.Join(filepath.Dir(exe), name) } // startSupervisor 在 App 啟動時拉起 collector;沒有 config 就先不啟動 // (使用者還沒連知識庫,啟動只會一直失敗刷 log)。 func startSupervisor() { bin := collectorBinPath() cfg := configPath() // 🔴 t195:這裡以前只 Stat(config) 就靜默 return,出問題完全查不到。 // leo 實撞:App 活著、collector 從沒啟動、畫面卻顯示「正在整理知識卡」。 // ⇒ 每個 return 點都要留下痕跡(寫進 collector.log 旁邊的 app.log)。 if _, err := os.Stat(bin); err != nil { appLog("啟動同步引擎失敗:找不到 %s(%v)", bin, err) return } if _, err := os.Stat(cfg); err != nil { appLog("尚未連上知識庫(沒有 %s),同步引擎先不啟動", cfg) return } sup = supervisor.New(bin, cfg) sup.Start() appLog("同步引擎已啟動:%s", bin) } // appLog 把 App 層的關鍵事件寫進 ~/.arcrun-rag/app.log。 // 沒有這個,「為什麼沒同步」就只能用猜的(leo 這次就卡在這)。 func appLog(format string, args ...any) { f, err := os.OpenFile(filepath.Join(appDir(), "app.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return } defer f.Close() fmt.Fprintf(f, "%s %s\n", time.Now().Format(time.RFC3339), fmt.Sprintf(format, args...)) } // restartWatch 在設定變更後重起看守,讓新設定立刻生效。 func restartWatch() { if sup == nil { return } sup.Stop() if _, err := os.Stat(configPath()); err == nil { sup.Start() } } // stopSupervisor 結束前停掉 collector 子行程。 // 沒有這一步,按「結束 Arcrun」後 collector 會變孤兒繼續跑 // ⇒ 使用者以為關了、其實還在同步,而且下次啟動會有兩個。 func stopSupervisor() { if sup != nil { sup.Stop() } } // installSignalHandler 讓 App 對 SIGTERM/SIGINT 有反應。 // // 🔴 leo 實測④:「**用強制結束把它關掉才能測試**,一般托盤程式不會顯示在強制結束列表中」。 // // 真兇:Wails 的 loop 不理會 TERM,而 collector 是我們自己 Start 的子行程 // ⇒ 主程式被殺時**子行程變孤兒繼續跑**(實測:kill -TERM 後 collector 還在)。 // ⇒ 收到訊號就先停子行程再退出,避免「關了卻還在同步」與「下次啟動有兩個」。 func installSignalHandler() { ch := make(chan os.Signal, 1) signal.Notify(ch, syscall.SIGTERM, syscall.SIGINT) go func() { <-ch stopSupervisor() // CommandContext 的 kill 是非同步的:Stop() 回來時子行程可能還在收屍。 // 實測若直接 os.Exit(0),collector 會變孤兒繼續跑(leo 撞到的④)。 // ⇒ 等它真的不見,最多 3 秒;逾時就自己補一刀。 waitCollectorGone(3 * time.Second) os.Exit(0) }() } // waitCollectorGone 等同綑的 collector 子行程真的結束。 // 逾時就直接殺——寧可強制,也不要留一個「使用者以為關了卻還在同步」的孤兒。 func waitCollectorGone(max time.Duration) { bin := collectorBinPath() deadline := time.Now().Add(max) for time.Now().Before(deadline) { if !processRunning(bin) { return } time.Sleep(120 * time.Millisecond) } _ = exec.Command("pkill", "-TERM", "-f", bin).Run() } // processRunning 用 pgrep 查有沒有這支執行檔在跑(純查詢,不動別人的行程)。 func processRunning(bin string) bool { out, _ := exec.Command("pgrep", "-f", bin).Output() return len(strings.TrimSpace(string(out))) > 0 } // collectorAlive 回報「同步引擎是不是真的在跑」。 // 🔴 t195:燈號不可以只憑「訊號檔存在」就說在同步——leo 實撞過 // collector 已死、畫面卻一直顯示「正在整理知識卡」13 分鐘。 func collectorAlive() bool { if sup == nil { return false } return processRunning(collectorBinPath()) }