fix(local-agent): 配對前自動 rescan USB,解序號偵測時序問題

問題:配對時 exchange 撈 GET /api/devices(ListDevices=讀快取、不重新
偵測)。若 agent 啟動時 USB 尚未插入/未偵測到,快取為空 → 配對撈不到
序號 → payload 不帶 devices → 雲端裝置「尚未回報序號」,即便之後插上
USB 也不會自動重偵測。

修法(方案 A、最小侵入):
- NewLocalDeviceLister 改打 POST /api/devices/scan(ScanDevices →
  Manager.Rescan() 重新偵測 USB),配對前強制重掃一次
- localDeviceListTimeout 2s → localDeviceScanTimeout 8s(真 SDK scan
  kp.core.scan_devices 較慢、給餘裕,逾時走 fallback 不卡配對)
- app.go DeviceLister 注入註解同步更新(Reviewer Mi-1)

不變量保留(Reviewer 獨立驗證):
- rescan 失敗/逾時 → lister return nil(不 error)→ payload omit devices
  → 配對照常(序號是加值資訊,不中斷配對)
- Rescan 對序號身分未變的 session 保持連線(serialIdentity 相等 continue)、
  只斷被拔除的 → 不誤斷在線 tunnel/inference session
- Pair 由 lifecycleMu 序列化、Rescan write lock 全量重建 → 重試冪等安全

Reviewer 通過(0C/0M/1Mi/2Sug)。兩 module build/vet/test/race 全綠、
8 個新 tunnel 行為測試 PASS(端到端快取空→rescan→序號進 payload +
逾時 fallback)、gitleaks 0。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-07-16 15:02:31 +08:00
parent fc7e1e0bc1
commit fac07c39d0
3 changed files with 200 additions and 18 deletions

View File

@ -414,7 +414,8 @@ func (a *App) tryStartTunnel() {
exchanger := tunnel.NewHTTPPairingExchanger(cloudAPIURL)
exchanger.MockMode = mockMode
exchanger.InsecureSkipTLSVerify = insecureSkipTLSVerify
// WP-0ADR-018 序號地基exchange 前撈本地 /api/devices 把 USB 序號
// WP-0ADR-018 序號地基exchange 前打本地 /api/devices/scan 觸發 rescan
// 重新偵測 USB解 agent 啟動時尚未插入 / 未偵測到的時序問題),再撈 USB 序號
// kn_number塞進 payload雲端據此填 devices.serial_number。
// 撈失敗不會中斷配對(見 tunnel.DeviceLister 註解。port 在上方已確認 > 0。
exchanger.DeviceLister = tunnel.NewLocalDeviceLister(port)

View File

@ -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 建立一個向本地 server127.0.0.1:port
// `GET /api/devices` 的 DeviceLister。port 由 app.go 從 ServerController 取得
// 後注入Exchanger 不自己猜 port
// NewLocalDeviceLister 建立一個向本地 server127.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 {

View File

@ -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 的 envelopesuccess + 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) {
// 本地 serverGET /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 0xB906162Crescan 撈到的序號)", 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 逾時應留 logno silent failures")
}
}
// TestNewLocalDeviceLister_TriggersScan 驗證配對前的 DeviceLister 打的是
// `POST /api/devices/scan`Rescan、重新偵測 USB而非 `GET /api/devices`
// (快取),並能解析回傳的 envelopesuccess + 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) {