diff --git a/local-agent/visiona-agent/app.go b/local-agent/visiona-agent/app.go index 3adca03..6969470 100644 --- a/local-agent/visiona-agent/app.go +++ b/local-agent/visiona-agent/app.go @@ -414,7 +414,8 @@ func (a *App) tryStartTunnel() { exchanger := tunnel.NewHTTPPairingExchanger(cloudAPIURL) exchanger.MockMode = mockMode exchanger.InsecureSkipTLSVerify = insecureSkipTLSVerify - // WP-0(ADR-018 序號地基):exchange 前撈本地 /api/devices 把 USB 序號 + // WP-0(ADR-018 序號地基):exchange 前打本地 /api/devices/scan 觸發 rescan + // 重新偵測 USB(解 agent 啟動時尚未插入 / 未偵測到的時序問題),再撈 USB 序號 // (kn_number)塞進 payload,雲端據此填 devices.serial_number。 // 撈失敗不會中斷配對(見 tunnel.DeviceLister 註解)。port 在上方已確認 > 0。 exchanger.DeviceLister = tunnel.NewLocalDeviceLister(port) diff --git a/local-agent/visiona-agent/internal/tunnel/pairing.go b/local-agent/visiona-agent/internal/tunnel/pairing.go index 5899f95..8c6caa1 100644 --- a/local-agent/visiona-agent/internal/tunnel/pairing.go +++ b/local-agent/visiona-agent/internal/tunnel/pairing.go @@ -410,11 +410,21 @@ func (e *HTTPPairingExchanger) logf(format string, args ...interface{}) { log.Printf(format, args...) } -// localDeviceListTimeout 是撈本地 /api/devices 的 timeout。 -// 比照 server_control.go probe 的 2 秒——序號是加值資訊,不能拖慢配對。 -const localDeviceListTimeout = 2 * time.Second +// localDeviceScanTimeout 是配對前觸發本地 `POST /api/devices/scan`(Rescan)的 +// timeout。 +// +// 為什麼比舊的 2s 長:舊版打 `GET /api/devices` 只讀 Manager 快取(毫秒級),2s +// 綽綽有餘。改打 scan 端點後會觸發真實 USB 偵測(kp.core.scan_devices 經 Python +// bridge),第一次插上、SDK 冷啟或多顆 dongle 時可能耗數秒。timeout 太短會讓 +// scan 還沒回就被 client 掐斷 → 每次配對都撈不到序號(fallback 不帶 devices), +// 等於這個修法白做。8s 給偵測足夠餘裕、又不至於在真的卡死時把配對拖太久 +// (逾時走 fallback、配對照常進行、不中斷)。 +const localDeviceScanTimeout = 8 * time.Second -// localDevicesEnvelope 對齊 local server `GET /api/devices` 的回應 envelope: +// localDevicesEnvelope 對齊 local server device 端點的回應 envelope。 +// `GET /api/devices`(ListDevices,快取)與 `POST /api/devices/scan`(ScanDevices, +// 重新偵測)回傳同一個 `{ "success": true, "data": { "devices": [...] } }` 形狀, +// 差別只在後者會先跑一次 USB 偵測。此 struct 兩者共用。 // // { "success": true, "data": { "devices": [ { "id", "serialNumber", "type", "firmwareVersion", ... } ] } } // @@ -430,24 +440,40 @@ type localDevicesEnvelope struct { } `json:"data"` } -// NewLocalDeviceLister 建立一個向本地 server(127.0.0.1:port)撈 -// `GET /api/devices` 的 DeviceLister。port 由 app.go 從 ServerController 取得 -// 後注入(Exchanger 不自己猜 port)。 +// NewLocalDeviceLister 建立一個向本地 server(127.0.0.1:port)觸發 +// `POST /api/devices/scan`(Rescan)的 DeviceLister。port 由 app.go 從 +// ServerController 取得後注入(Exchanger 不自己猜 port)。 +// +// 為什麼打 scan 而非 GET /api/devices(時序修正): +// agent 啟動時若 USB 尚未插好,Manager 沒有 device session(快取空)。之後才插 +// USB + 配對——但配對 exchange 若只讀快取(GET /api/devices)就撈到空,序號進不了 +// payload,雲端只能建 serial=NULL 的 device(前端顯示「尚未回報序號」)。改打 +// scan 端點讓配對前強制重新偵測一次 USB,確保剛插上的實體序號能被撈到。 +// +// scan 端點內部走 Manager.Rescan():對「序號身分未變」的既有 session 保持連線 +// 狀態不動(manager.go Rescan 的 serialIdentity 相等即 continue),只會 disconnect +// 真的被拔除 / 位移的 stale 裝置——因此配對前 rescan 不會誤斷仍在線的既有 tunnel / +// inference session,副作用安全。 +// +// 失敗 / 逾時不中斷配對:回 error 由 collectLocalDevices 轉為「不帶 devices」, +// 雲端 fallback 走現行 serial=NULL 路徑(見 DeviceLister 註解)。 func NewLocalDeviceLister(port int) DeviceLister { - client := &http.Client{Timeout: localDeviceListTimeout} - url := fmt.Sprintf("http://127.0.0.1:%d/api/devices", port) + client := &http.Client{Timeout: localDeviceScanTimeout} + url := fmt.Sprintf("http://127.0.0.1:%d/api/devices/scan", port) return func() ([]LocalDevice, error) { - resp, err := client.Get(url) + // POST(無 body)觸發 Rescan。ScanDevices handler 回傳與 ListDevices 同形狀 + // 的 envelope(含剛偵測到的 devices),一次呼叫即完成「rescan + 取清單」。 + resp, err := client.Post(url, "application/json", nil) if err != nil { - return nil, fmt.Errorf("local device list: %w", err) + return nil, fmt.Errorf("local device scan: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("local device list: http %d", resp.StatusCode) + return nil, fmt.Errorf("local device scan: http %d", resp.StatusCode) } var env localDevicesEnvelope if err := json.NewDecoder(resp.Body).Decode(&env); err != nil { - return nil, fmt.Errorf("local device list: decode: %w", err) + return nil, fmt.Errorf("local device scan: decode: %w", err) } out := make([]LocalDevice, 0, len(env.Data.Devices)) for _, d := range env.Data.Devices { diff --git a/local-agent/visiona-agent/internal/tunnel/pairing_test.go b/local-agent/visiona-agent/internal/tunnel/pairing_test.go index 8fb45b7..25c24a8 100644 --- a/local-agent/visiona-agent/internal/tunnel/pairing_test.go +++ b/local-agent/visiona-agent/internal/tunnel/pairing_test.go @@ -4,6 +4,7 @@ package tunnel import ( "encoding/json" "errors" + "fmt" "io" "net/http" "net/http/httptest" @@ -11,6 +12,7 @@ import ( "strconv" "strings" "testing" + "time" ) func TestValidatePairingToken(t *testing.T) { @@ -592,11 +594,128 @@ func TestExchangeReal_SuccessFalse_ReturnsClearError(t *testing.T) { } } -// TestNewLocalDeviceLister_ParsesEnvelope 驗證 NewLocalDeviceLister 能解析 -// local server GET /api/devices 的 envelope(success + data.devices)。 -func TestNewLocalDeviceLister_ParsesEnvelope(t *testing.T) { +// TestExchange_EndToEnd_ScanSerialReachesPayload 是端到端驗證:用真實的 +// NewLocalDeviceLister(打 POST /api/devices/scan)串起「配對前 rescan → 撈到序號 +// → 序號進 exchange payload」的完整鏈路。模擬時序 bug 修復後的正確流程:agent +// 啟動時快取空,配對觸發 rescan 才偵測到剛插上的實體 KL520。 +func TestExchange_EndToEnd_ScanSerialReachesPayload(t *testing.T) { + // 本地 server:GET /api/devices 回空(快取,模擬啟動時 USB 沒插好), + // POST /api/devices/scan 回剛偵測到的實體裝置。 + localSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/devices": + _, _ = w.Write([]byte(`{"success":true,"data":{"devices":[]}}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/devices/scan": + _, _ = w.Write([]byte(`{"success":true,"data":{"devices":[ + {"id":"kl520-0","type":"kneron_kl520","serialNumber":"0xB906162C","firmwareVersion":"KDP","status":"detected"} + ]}}`)) + default: + w.WriteHeader(404) + } + })) + defer localSrv.Close() + + // 雲端 exchange server:記下 payload 的 devices。 + var gotBody map[string]interface{} + cloudSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(exchangeResponse{ + Success: true, + Data: exchangeResponseData{SessionToken: "vAs_" + strings.Repeat("f", 64)}, + }) + })) + defer cloudSrv.Close() + + localPort := portFromTestServerURL(t, localSrv.URL) + ex := NewHTTPPairingExchanger(cloudSrv.URL) + ex.DeviceLister = NewLocalDeviceLister(localPort) // 真實 lister(打 scan 端點) + + if _, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef"); err != nil { + t.Fatalf("Exchange: %v", err) + } + devices, ok := gotBody["devices"].([]interface{}) + if !ok { + t.Fatalf("配對前 rescan 應撈到序號並帶進 payload,但 devices 欄位缺失:%v", gotBody) + } + if len(devices) != 1 { + t.Fatalf("devices 長度 = %d, want 1", len(devices)) + } + d0 := devices[0].(map[string]interface{}) + if d0["serial_number"] != "0xB906162C" { + t.Errorf("payload serial_number = %v, want 0xB906162C(rescan 撈到的序號)", d0["serial_number"]) + } +} + +// TestExchange_ScanTimeout_DoesNotBreakPairing 驗證核心安全守則:配對前 rescan +// 若逾時(真 SDK scan 卡住),不可中斷配對——DeviceLister 逾時回 error → +// collectLocalDevices fallback 不帶 devices → exchange 照常成功。 +// +// 用一個「回應慢於 lister timeout」的 scan 端點模擬 SDK 卡住。lister 的 +// localDeviceScanTimeout 是 8s,測試不想真的等 8s,改注入一個 timeout 極短的 +// 自訂 lister(等價語意:scan 回應慢於 client timeout → context deadline)。 +func TestExchange_ScanTimeout_DoesNotBreakPairing(t *testing.T) { + // scan 端點故意 sleep,超過下方短 timeout client。 + slowScan := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(200 * time.Millisecond) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"data":{"devices":[{"id":"kl520-0","serialNumber":"0xB906162C"}]}}`)) + })) + defer slowScan.Close() + scanPort := portFromTestServerURL(t, slowScan.URL) + + var gotBody map[string]interface{} + cloudSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(exchangeResponse{ + Success: true, + Data: exchangeResponseData{SessionToken: "vAs_" + strings.Repeat("a", 64)}, + }) + })) + defer cloudSrv.Close() + + // 短 timeout client 打 slow scan → 逾時 error(等價於 8s SDK scan 卡住)。 + shortClient := &http.Client{Timeout: 50 * time.Millisecond} + scanURL := fmt.Sprintf("http://127.0.0.1:%d/api/devices/scan", scanPort) + timingOutLister := func() ([]LocalDevice, error) { + resp, err := shortClient.Post(scanURL, "application/json", nil) + if err != nil { + return nil, err // 逾時走這裡 + } + defer resp.Body.Close() + return nil, nil + } + + var logged int + ex := NewHTTPPairingExchanger(cloudSrv.URL) + ex.DeviceLister = timingOutLister + ex.Logf = func(format string, args ...interface{}) { logged++ } + + if _, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef"); err != nil { + t.Fatalf("rescan 逾時不應中斷配對,但 Exchange 失敗:%v", err) + } + if _, has := gotBody["devices"]; has { + t.Errorf("rescan 逾時 fallback 時 payload 不應帶 devices:%v", gotBody["devices"]) + } + if logged == 0 { + t.Error("rescan 逾時應留 log(no silent failures)") + } +} + +// TestNewLocalDeviceLister_TriggersScan 驗證配對前的 DeviceLister 打的是 +// `POST /api/devices/scan`(Rescan、重新偵測 USB)而非 `GET /api/devices` +// (快取),並能解析回傳的 envelope(success + data.devices)。 +// +// 這是時序修正的核心行為:agent 啟動時 USB 未插好 → 快取空 → 配對前必須先 +// rescan 才撈得到剛插上的實體序號。 +func TestNewLocalDeviceLister_TriggersScan(t *testing.T) { + var gotMethod, gotPath string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/devices" { + gotMethod = r.Method + gotPath = r.URL.Path + if r.Method != http.MethodPost || r.URL.Path != "/api/devices/scan" { w.WriteHeader(404) return } @@ -619,6 +738,9 @@ func TestNewLocalDeviceLister_ParsesEnvelope(t *testing.T) { if err != nil { t.Fatalf("lister: %v", err) } + if gotMethod != http.MethodPost || gotPath != "/api/devices/scan" { + t.Fatalf("DeviceLister 應打 POST /api/devices/scan(觸發 rescan),實際 %s %s", gotMethod, gotPath) + } if len(devs) != 2 { t.Fatalf("devices = %d, want 2", len(devs)) } @@ -630,6 +752,39 @@ func TestNewLocalDeviceLister_ParsesEnvelope(t *testing.T) { } } +// TestNewLocalDeviceLister_ScanFindsFreshlyPluggedDevice 模擬時序 bug 的場景: +// agent 啟動時快取空(GET /api/devices 回 0 顆),插上 USB 後配對觸發 rescan +// (POST /api/devices/scan)才偵測到裝置。驗證 DeviceLister 走的是 scan 路徑、 +// 因此能撈到剛插上的序號。 +func TestNewLocalDeviceLister_ScanFindsFreshlyPluggedDevice(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/devices": + // 快取:啟動時 USB 沒插好 → 0 顆(若 lister 誤打這裡就撈不到序號)。 + _, _ = w.Write([]byte(`{"success":true,"data":{"devices":[]}}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/devices/scan": + // rescan:重新偵測到剛插上的實體 KL520。 + _, _ = w.Write([]byte(`{"success":true,"data":{"devices":[ + {"id":"kl520-0","type":"kneron_kl520","serialNumber":"0xB906162C","firmwareVersion":"KDP","status":"detected"} + ]}}`)) + default: + w.WriteHeader(404) + } + })) + defer srv.Close() + + port := portFromTestServerURL(t, srv.URL) + lister := NewLocalDeviceLister(port) + devs, err := lister() + if err != nil { + t.Fatalf("lister: %v", err) + } + if len(devs) != 1 || devs[0].SerialNumber != "0xB906162C" { + t.Fatalf("rescan 應撈到剛插上的序號 0xB906162C,實際 %+v", devs) + } +} + // TestNewLocalDeviceLister_Unreachable 驗證 local server 不可達時回 error // (由 collectLocalDevices 轉為「不帶 devices、exchange 照常」)。 func TestNewLocalDeviceLister_Unreachable(t *testing.T) {