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:
parent
fc7e1e0bc1
commit
fac07c39d0
@ -414,7 +414,8 @@ func (a *App) tryStartTunnel() {
|
|||||||
exchanger := tunnel.NewHTTPPairingExchanger(cloudAPIURL)
|
exchanger := tunnel.NewHTTPPairingExchanger(cloudAPIURL)
|
||||||
exchanger.MockMode = mockMode
|
exchanger.MockMode = mockMode
|
||||||
exchanger.InsecureSkipTLSVerify = insecureSkipTLSVerify
|
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。
|
// (kn_number)塞進 payload,雲端據此填 devices.serial_number。
|
||||||
// 撈失敗不會中斷配對(見 tunnel.DeviceLister 註解)。port 在上方已確認 > 0。
|
// 撈失敗不會中斷配對(見 tunnel.DeviceLister 註解)。port 在上方已確認 > 0。
|
||||||
exchanger.DeviceLister = tunnel.NewLocalDeviceLister(port)
|
exchanger.DeviceLister = tunnel.NewLocalDeviceLister(port)
|
||||||
|
|||||||
@ -410,11 +410,21 @@ func (e *HTTPPairingExchanger) logf(format string, args ...interface{}) {
|
|||||||
log.Printf(format, args...)
|
log.Printf(format, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// localDeviceListTimeout 是撈本地 /api/devices 的 timeout。
|
// localDeviceScanTimeout 是配對前觸發本地 `POST /api/devices/scan`(Rescan)的
|
||||||
// 比照 server_control.go probe 的 2 秒——序號是加值資訊,不能拖慢配對。
|
// timeout。
|
||||||
const localDeviceListTimeout = 2 * time.Second
|
//
|
||||||
|
// 為什麼比舊的 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", ... } ] } }
|
// { "success": true, "data": { "devices": [ { "id", "serialNumber", "type", "firmwareVersion", ... } ] } }
|
||||||
//
|
//
|
||||||
@ -430,24 +440,40 @@ type localDevicesEnvelope struct {
|
|||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLocalDeviceLister 建立一個向本地 server(127.0.0.1:port)撈
|
// NewLocalDeviceLister 建立一個向本地 server(127.0.0.1:port)觸發
|
||||||
// `GET /api/devices` 的 DeviceLister。port 由 app.go 從 ServerController 取得
|
// `POST /api/devices/scan`(Rescan)的 DeviceLister。port 由 app.go 從
|
||||||
// 後注入(Exchanger 不自己猜 port)。
|
// 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 {
|
func NewLocalDeviceLister(port int) DeviceLister {
|
||||||
client := &http.Client{Timeout: localDeviceListTimeout}
|
client := &http.Client{Timeout: localDeviceScanTimeout}
|
||||||
url := fmt.Sprintf("http://127.0.0.1:%d/api/devices", port)
|
url := fmt.Sprintf("http://127.0.0.1:%d/api/devices/scan", port)
|
||||||
return func() ([]LocalDevice, error) {
|
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 {
|
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()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusOK {
|
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
|
var env localDevicesEnvelope
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
|
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))
|
out := make([]LocalDevice, 0, len(env.Data.Devices))
|
||||||
for _, d := range env.Data.Devices {
|
for _, d := range env.Data.Devices {
|
||||||
|
|||||||
@ -4,6 +4,7 @@ package tunnel
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@ -11,6 +12,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestValidatePairingToken(t *testing.T) {
|
func TestValidatePairingToken(t *testing.T) {
|
||||||
@ -592,11 +594,128 @@ func TestExchangeReal_SuccessFalse_ReturnsClearError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestNewLocalDeviceLister_ParsesEnvelope 驗證 NewLocalDeviceLister 能解析
|
// TestExchange_EndToEnd_ScanSerialReachesPayload 是端到端驗證:用真實的
|
||||||
// local server GET /api/devices 的 envelope(success + data.devices)。
|
// NewLocalDeviceLister(打 POST /api/devices/scan)串起「配對前 rescan → 撈到序號
|
||||||
func TestNewLocalDeviceLister_ParsesEnvelope(t *testing.T) {
|
// → 序號進 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) {
|
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)
|
w.WriteHeader(404)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -619,6 +738,9 @@ func TestNewLocalDeviceLister_ParsesEnvelope(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("lister: %v", err)
|
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 {
|
if len(devs) != 2 {
|
||||||
t.Fatalf("devices = %d, want 2", len(devs))
|
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
|
// TestNewLocalDeviceLister_Unreachable 驗證 local server 不可達時回 error
|
||||||
// (由 collectLocalDevices 轉為「不帶 devices、exchange 照常」)。
|
// (由 collectLocalDevices 轉為「不帶 devices、exchange 照常」)。
|
||||||
func TestNewLocalDeviceLister_Unreachable(t *testing.T) {
|
func TestNewLocalDeviceLister_Unreachable(t *testing.T) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user