Files
arcrun-collector/cmd/arcrun-tray/singleinstance_test.go
T
Leo f903a3f53f t176 daemon:LLM 設定移回地端+托盤單一實例(leo 08-03 架構翻案)
leo 回報 Windows daemon 三症狀,查完 ①③ 同根,根在「雲端控制地端」這個設計。

【①③ 真兇】雲端 extractor_config 是**全租戶共用一把 KV**
(arcrun:portal.ts:43 portalTenant = worker 層級變數,不分用戶),
任一處設了 claude → 所有人的 daemon 都收到 claude。沒裝 Claude Code 的機器
FindClaudeBin 失敗 → 每檔萃取 failed、一張卡都沒建;想去 portal 改回 gemini,
checkbox 卻恆 disabled(claude_available 恆 false,因 daemon 從未實作
report-capabilities 回報 → daemon_caps KV 永遠空)⇒ 用戶自己解不開。
awindhon 實證:雲端同步成功、Gemini key 有效、零張卡,config.json extractor="claude"。

【leo 裁示】「地端要用什麼模型就在 daemon 上輸入 API Key 設置,而不是雲端設置後
控制地端」「地端先限制 Gemini API Key 配合客戶要求」「雲端就是 Workers AI」。

本次(daemon 端):
- addOrUpdateAccount 不再接受雲端下發的 extractor/gemini_api_key/llm_model,
  只收連線欄位。t126「每帳號一份萃取設定」照舊保留——t126 修的是「存在哪一層」,
  本次改的是「值從哪來」,兩者正交。
- 托盤新增「AI 設定…」:使用者自己填 Gemini API Key,寫本地 config 後立即生效。
- 萃取一律走 gemini:殘留的 extractor:"claude" 正規化為 gemma;claude 路退役。
  ⚠️ 這不是「自動偵測有無 claude」(leo 07-27 已否決的 B 案),是整條路先不支援。
- 清掉隨之死亡的 claude_bin 回寫(死代碼=錯誤的環境信號)。
- 托盤單一實例(症狀②):pidfile + 跨平台 processAlive。
  mac 之前不多開是借 macOS Launch Services 的巧合,Windows 沒有該層 ⇒ 每點一次多一個 icon。
  Unix 用 signal 0(EPERM 也算活著,測試抓到的實際 bug)/Windows 用 OpenProcess+ExitCode。

測試:collector 全綠、tray 全綠。5 個原本用 claude stub 的測試改走**真實 gemma 路**
(httptest 替身注入 gemmaBaseURL),不是改斷言充綠;t126②③ 兩案翻轉成
「雲端下發一律被忽略」的回歸守衛;新增 4 案 single-instance。

未送達:本 commit 只到 code,尚未打包出貨;雲端側(刪 portal AI 設定區塊、
extractor 下發、admin/extractor)未動,待部署授權。CP rag-beta 步驟仍為 ◐。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:48:10 +08:00

86 lines
2.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// singleinstance_test.go — t176:一台機器只跑一個托盤(leo 08-03「點多次產生多個 icon」)。
package main
import (
"os"
"path/filepath"
"strconv"
"testing"
)
// withTempLock 把鎖檔導到暫存目錄,避免測試碰到使用者真正的 ~/.arcrun-rag。
func withTempLock(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "tray.lock")
orig := trayLockPath
trayLockPath = func() string { return path }
t.Cleanup(func() { trayLockPath = orig })
return path
}
// 第一個實例拿得到鎖;release 後鎖檔消失。
func TestSingleInstanceFirstAcquires(t *testing.T) {
path := withTempLock(t)
ok, release := acquireSingleInstance()
if !ok {
t.Fatal("第一個實例應該拿得到鎖")
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("鎖檔應被建立:%v", err)
}
if got, _ := strconv.Atoi(string(data)); got != os.Getpid() {
t.Errorf("鎖檔應寫入自己的 pidgot %q want %d", data, os.Getpid())
}
release()
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Error("release 後鎖檔應被刪除(否則下次啟動要靠 pid 檢查兜底)")
}
}
// 鎖檔記著一個「還活著」的 pid(就用自己的父流程:本測試行程自己)→ 第二個實例被擋。
// 這正是 leo 撞到的情境:點第二次不該再開一個托盤。
func TestSingleInstanceSecondBlocked(t *testing.T) {
path := withTempLock(t)
// 寫入一個確定活著、且不等於自己的 pid:用 pid 1init/launchd,任何系統上都在跑)。
if err := os.WriteFile(path, []byte("1"), 0o644); err != nil {
t.Fatal(err)
}
if ok, _ := acquireSingleInstance(); ok {
t.Error("已有活著的實例時,第二個應該被擋下(不然就會多一個托盤 icon)")
}
}
// 殘留鎖檔(上次當機留下、pid 已死)不應永久卡死啟動——要能接管。
func TestSingleInstanceStaleLockTakenOver(t *testing.T) {
path := withTempLock(t)
// 挑一個幾乎不可能存在的 pid:先開一個行程再等它結束太慢,直接用超大值。
if err := os.WriteFile(path, []byte("4194303"), 0o644); err != nil {
t.Fatal(err)
}
ok, release := acquireSingleInstance()
if !ok {
t.Fatal("殘留鎖檔(pid 已死)應可接管,否則使用者永遠打不開")
}
defer release()
data, _ := os.ReadFile(path)
if got, _ := strconv.Atoi(string(data)); got != os.Getpid() {
t.Errorf("接管後鎖檔應改寫成自己的 pidgot %q", data)
}
}
// 壞掉的鎖檔內容(非數字)不應擋住啟動。
func TestSingleInstanceGarbageLock(t *testing.T) {
path := withTempLock(t)
if err := os.WriteFile(path, []byte("not-a-pid"), 0o644); err != nil {
t.Fatal(err)
}
ok, release := acquireSingleInstance()
if !ok {
t.Fatal("鎖檔內容壞掉時應放行(寧可容忍重複開,也不要讓人完全打不開)")
}
release()
}