diff --git a/client.go b/client.go index 39bb866..b34f2c6 100644 --- a/client.go +++ b/client.go @@ -61,6 +61,7 @@ func WithQRWriter(writer io.Writer) Option { type Client struct { authPath string + deviceCacheDir string authDataChanged func(AuthData) error authMu sync.RWMutex authData AuthData @@ -89,6 +90,7 @@ func NewClient(authPath string, options ...Option) (*Client, error) { return nil, err } client.authPath = resolvedPath + client.deviceCacheDir = filepath.Dir(resolvedPath) if err := client.loadAuthData(); err != nil { return nil, err } @@ -135,10 +137,10 @@ func newClient(options ...ClientOption) (*Client, error) { } // WithAuthDataChanged configures synchronous persistence for in-memory auth updates. -// The callback is serialized by the client's login lock and may acquire unrelated -// application locks, but it must not call Client methods other than AuthData. -// In particular, calling Login or another method that may refresh authentication -// will deadlock. Returning an error leaves the client's authentication unchanged. +// The callback runs under the client's internal login serialization. It must not +// call Client methods other than AuthData or acquire a lock that may be held by any +// goroutine calling Client. Callers must not call Client while holding a lock that +// the callback may acquire. Returning an error leaves authentication unchanged. func WithAuthDataChanged(callback func(AuthData) error) ClientOption { return func(client *Client) error { if callback == nil { diff --git a/device.go b/device.go index 9c8ab5c..d7a8a1a 100644 --- a/device.go +++ b/device.go @@ -619,7 +619,7 @@ func NewDevice(ctx context.Context, client *Client, selector DeviceSelector, opt if client == nil { return nil, errors.New("client must not be nil") } - config := deviceConfig{httpClient: client.session(), cacheDir: filepath.Dir(client.authPath), delay: 500 * time.Millisecond} + config := deviceConfig{httpClient: client.session(), cacheDir: client.deviceCacheDir, delay: 500 * time.Millisecond} for _, option := range options { if option != nil { if err := option(&config); err != nil { diff --git a/device_test.go b/device_test.go index a6fcd99..bdfe792 100644 --- a/device_test.go +++ b/device_test.go @@ -602,6 +602,40 @@ func TestNewDeviceDefaults(t *testing.T) { } } +func TestNewDeviceMemoryClientDefaultsToNoCache(t *testing.T) { + testServer := newDeviceTestServer(t, loadSpecFixture(t), []string{`{"homelist":[{"id":"10","uid":1}]}`, `{"device_info":[{"did":"a","model":"test.light.v1"}],"has_more":false}`}) + client, err := NewClientWithAuthData(testAuthData(), WithHTTPClient(testServer.server.Client())) + if err != nil { + t.Fatal(err) + } + client.baseURL = testServer.server.URL + client.availability = true + client.availabilityValid = true + client.availabilityAt = time.Now() + + workingDirectory := t.TempDir() + previousWorkingDirectory, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(workingDirectory); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.Chdir(previousWorkingDirectory); err != nil { + t.Errorf("restore working directory: %v", err) + } + }) + + if _, err := NewDevice(context.Background(), client, DeviceSelector{DID: "a"}, WithDeviceDelay(0)); err != nil { + t.Fatal(err) + } + cachePath := filepath.Join(workingDirectory, "test.light.v1.json") + if _, err := os.Stat(cachePath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("memory client default cache stat error = %v, want not exist", err) + } +} + func TestDeviceGetSetAndAction(t *testing.T) { testServer := newDeviceTestServer(t, loadSpecFixture(t), []string{ `{"homelist":[{"id":"10","uid":1}]}`, `{"device_info":[{"did":"a","name":"Lamp","model":"test.light.v1"}],"has_more":false}`, diff --git a/docs/compose/reports/mijia-go-api.md b/docs/compose/reports/mijia-go-api.md index fbf9d43..cb042fd 100644 --- a/docs/compose/reports/mijia-go-api.md +++ b/docs/compose/reports/mijia-go-api.md @@ -30,6 +30,8 @@ commits: none - 单次请求使用同一认证快照,因为签名、Cookie 和响应解密不能混用不同代 token。 - 设备 spec 缓存同时兼容 Go 顶层标识和 Python `method` 格式,因为两个实现默认共享认证目录和缓存文件名。 - 整数规格校验使用精确十进制/有理数比较,因为 `float64` 无法安全表达 `2^53` 以上整数。 +- 内存认证的同步持久化回调保留在登录串行区内,以确保并发登录/刷新严格有序且回调失败时不安装候选认证。把回调移到所有内部锁外会允许旧回调在新认证提交后覆盖外部状态;票据或 worker 若同步等待,调用者持有回调所需锁时仍会形成 ABBA。因此公共契约明确要求回调和 Client 调用遵守锁顺序,而不是承诺任意应用锁安全。 +- 文件客户端默认把设备 spec 缓存在认证文件目录;内存客户端默认不缓存,只有显式 `WithDeviceCacheDir` 才写入 spec 文件。 ## Usage @@ -56,6 +58,8 @@ auth, err := client.Login(ctx) - `gofmt -w *.go` - `go test -count=1 ./...`:通过 - `go vet ./...`:通过 +- `go test ./... -count=20`:通过 +- `go test -race ./...`:通过 - 独立规格审查和逐阶段代码质量审查均通过。 - 测试覆盖固定加密向量、gzip/大小限制、QR 与静默刷新、本地加密 HTTP 端点、分页异常、精确大整数、MIoT HTML 变体、Python 缓存迁移和 context 取消。 diff --git a/memory_auth_test.go b/memory_auth_test.go index 3978178..9014253 100644 --- a/memory_auth_test.go +++ b/memory_auth_test.go @@ -10,7 +10,6 @@ import ( "path/filepath" "reflect" "strings" - "sync" "testing" ) @@ -136,18 +135,31 @@ func TestMemoryRefreshCallbackCanReadCurrentAuth(t *testing.T) { } } -func TestMemoryRefreshCallbackCanAcquireApplicationLock(t *testing.T) { - var persistenceMu sync.Mutex - client, _, server := newMemoryRefreshClient(t, func(AuthData) error { - persistenceMu.Lock() - defer persistenceMu.Unlock() - return nil +func TestMemoryRefreshCallbackCanPersistIndependently(t *testing.T) { + persistencePath := filepath.Join(t.TempDir(), "persisted-auth.json") + client, _, server := newMemoryRefreshClient(t, func(authData AuthData) error { + payload, err := authData.MarshalJSON() + if err != nil { + return err + } + return os.WriteFile(persistencePath, payload, 0o600) }) defer server.Close() if err := client.refreshToken(context.Background()); err != nil { t.Fatal(err) } + payload, err := os.ReadFile(persistencePath) + if err != nil { + t.Fatal(err) + } + var persisted AuthData + if err := persisted.UnmarshalJSON(payload); err != nil { + t.Fatal(err) + } + if persisted.ServiceToken != "new-token" || persisted.CUserID != "new-c-user" { + t.Fatalf("persisted auth = %#v", persisted) + } } func TestMemoryRefreshPersistsGeneratedIdentity(t *testing.T) {