From 1a4a03a20db75d75b64d99f1f49a7bc5ffd10845 Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 6 Aug 2026 13:47:48 +0800 Subject: [PATCH] fix(agent): clear sticky LastError on successful sync paths Pages reconcile could succeed while agent state retained a previous network error, so health events stayed active indefinitely. Clear LastError whenever sync completes successfully without re-applying config, and cover the paths with regression tests. --- docs/changelog/index.md | 4 + internal/apps/agent/sync/service.go | 2 + internal/apps/agent/sync/service_test.go | 160 +++++++++++++++++++++++ internal/apps/agent/sync/sync_helpers.go | 2 + 4 files changed, 168 insertions(+) diff --git a/docs/changelog/index.md b/docs/changelog/index.md index ed651016..084ab489 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -26,6 +26,10 @@ sidebar: false - 新增 Cloudflare DNS 指向管理:可复用现有 Cloudflare DNS 账号或配置独立 Token,按分组将 ZoneDomain 的单条 A 记录异步同步到边缘节点 IPv4,并支持成员橙云、同步状态与节点 IP 变更联动。 +### 修复 + +- 修复 Agent 在配置已对齐但磁盘校验和不一致时,Pages 等对账成功后仍保留 `LastError` 的问题,避免偶发网络失败被健康事件长期显示为「活动中」且无法自动恢复。 + ### 改进 - 删除、撤销与未保存离开等确认操作统一改用页面内 AlertDialog,不再使用浏览器原生 `confirm` 弹窗,交互风格与系统其余对话框保持一致。 diff --git a/internal/apps/agent/sync/service.go b/internal/apps/agent/sync/service.go index 6c684b02..8798d35e 100644 --- a/internal/apps/agent/sync/service.go +++ b/internal/apps/agent/sync/service.go @@ -166,6 +166,8 @@ func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool, return err } slog.Debug("skipping apply because state already records target version/checksum", "version", config.Version, "checksum", config.Checksum) + // Successful reconcile must clear sticky LastError so health events can resolve. + snapshot.LastError = "" return s.stateStore.Save(snapshot) } return s.applyRenderedConfig(ctx, mode, snapshot, currentChecksum, config) diff --git a/internal/apps/agent/sync/service_test.go b/internal/apps/agent/sync/service_test.go index 01b0a2fd..d8d02745 100644 --- a/internal/apps/agent/sync/service_test.go +++ b/internal/apps/agent/sync/service_test.go @@ -1804,3 +1804,163 @@ func TestSyncOnceDownloadsPagesDeploymentWithTopLevelFolder(t *testing.T) { t.Fatalf("unexpected Pages assets/app.js content: %s", string(jsData)) } } + +func TestSyncOnceClearsStickyLastErrorWhenChecksumMatches(t *testing.T) { + const sticky = "pages project 1: fetch latest hash: context deadline exceeded" + client := &fakeClient{ + config: protocol.ActiveConfigResponse{ + Version: "20260309-201", + Checksum: "checksum-201", + SourceConfigJSON: testSourceConfigJSON("auto", 90), + CreatedAt: time.Now().Format(time.RFC3339), + }, + } + stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json")) + nodeID, err := stateStore.EnsureNodeID() + if err != nil { + t.Fatalf("EnsureNodeID failed: %v", err) + } + if err = stateStore.Save(&state.Snapshot{ + NodeID: nodeID, + CurrentVersion: client.config.Version, + CurrentChecksum: client.config.Checksum, + PagesDeployments: []state.PagesDeployment{}, + LastError: sticky, + }); err != nil { + t.Fatalf("failed to seed state: %v", err) + } + + manager := &fakeManager{currentChecksum: client.config.Checksum} + service := New(client, manager, stateStore) + if err = service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{ + Version: client.config.Version, + Checksum: client.config.Checksum, + }); err != nil { + t.Fatalf("SyncOnce failed: %v", err) + } + + snapshot, err := stateStore.Load() + if err != nil { + t.Fatalf("failed to load state: %v", err) + } + if snapshot.LastError != "" { + t.Fatalf("expected sticky LastError to clear after successful up-to-date sync, got %q", snapshot.LastError) + } +} + +func TestSyncOnceClearsStickyLastErrorWhenStateMatchesButDiskChecksumDiffers(t *testing.T) { + // Reproduces the production sticky-alert bug: nginx on-disk checksum no longer + // equals the active config checksum, but agent state already records the target. + // Pages reconcile succeeds, yet older code saved state without clearing LastError. + const sticky = "pages project 1: fetch latest hash: connection reset by peer" + const version = "20260309-202" + const checksum = "checksum-202" + + packageBytes := testPagesPackage(t, map[string]string{"index.html": "ok"}) + hash := testBytesChecksum(packageBytes) + projectID := uint(1) + client := &fakeClient{ + config: protocol.ActiveConfigResponse{ + Version: version, + Checksum: checksum, + SourceConfigJSON: testPagesSourceConfigJSON(projectID, projectID, hash), + CreatedAt: time.Now().Format(time.RFC3339), + }, + pagesPackages: map[uint][]byte{projectID: packageBytes}, + pagesHashes: map[uint]string{projectID: hash}, + } + stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json")) + nodeID, err := stateStore.EnsureNodeID() + if err != nil { + t.Fatalf("EnsureNodeID failed: %v", err) + } + if err = stateStore.Save(&state.Snapshot{ + NodeID: nodeID, + CurrentVersion: version, + CurrentChecksum: checksum, + LastError: sticky, + PagesDeployments: []state.PagesDeployment{{ + ProjectID: projectID, + DeploymentID: projectID, + Hash: hash, + }}, + }); err != nil { + t.Fatalf("failed to seed state: %v", err) + } + + // Disk checksum differs from target → mismatched path; state already matches target. + manager := &fakeManager{currentChecksum: "on-disk-differs-from-target"} + service := New(client, manager, stateStore) + service.SetPagesDir(t.TempDir()) + + if err = service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{ + Version: version, + Checksum: checksum, + }); err != nil { + t.Fatalf("SyncOnce failed: %v", err) + } + + snapshot, err := stateStore.Load() + if err != nil { + t.Fatalf("failed to load state: %v", err) + } + if snapshot.LastError != "" { + t.Fatalf("expected sticky LastError to clear when pages reconcile succeeds, got %q", snapshot.LastError) + } +} + +func TestSyncOnceClearsStickyLastErrorAfterStaleHeartbeatFetchesActiveConfig(t *testing.T) { + // Heartbeat meta is stale vs active config; disk checksum mismatches heartbeat. + // After fetch, snapshot already matches active config → applyIfNeeded state-match path. + const sticky = "previous transient pages hash timeout" + const activeVersion = "20260309-203" + const activeChecksum = "checksum-203" + + client := &fakeClient{ + config: protocol.ActiveConfigResponse{ + Version: activeVersion, + Checksum: activeChecksum, + SourceConfigJSON: testSourceConfigJSON("auto", 91), + CreatedAt: time.Now().Format(time.RFC3339), + }, + } + stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json")) + nodeID, err := stateStore.EnsureNodeID() + if err != nil { + t.Fatalf("EnsureNodeID failed: %v", err) + } + if err = stateStore.Save(&state.Snapshot{ + NodeID: nodeID, + CurrentVersion: activeVersion, + CurrentChecksum: activeChecksum, + PagesDeployments: []state.PagesDeployment{}, + LastError: sticky, + }); err != nil { + t.Fatalf("failed to seed state: %v", err) + } + + // Disk differs from stale heartbeat target so SyncOnce does not take the matching path. + manager := &fakeManager{currentChecksum: "disk-checksum-differs"} + service := New(client, manager, stateStore) + + if err = service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{ + Version: "20260309-202", + Checksum: "checksum-stale-heartbeat", + }); err != nil { + t.Fatalf("SyncOnce failed: %v", err) + } + + snapshot, err := stateStore.Load() + if err != nil { + t.Fatalf("failed to load state: %v", err) + } + if snapshot.LastError != "" { + t.Fatalf("expected sticky LastError to clear on applyIfNeeded state-match success, got %q", snapshot.LastError) + } + if client.fetchCalls != 1 { + t.Fatalf("expected one config fetch for stale heartbeat target, got %d", client.fetchCalls) + } + if len(manager.applyMainContents) != 0 { + t.Fatal("expected state-match path to skip OpenResty apply") + } +} diff --git a/internal/apps/agent/sync/sync_helpers.go b/internal/apps/agent/sync/sync_helpers.go index 70a2917c..e3269d36 100644 --- a/internal/apps/agent/sync/sync_helpers.go +++ b/internal/apps/agent/sync/sync_helpers.go @@ -122,6 +122,8 @@ func (s *Service) syncMismatchedChecksum(ctx context.Context, mode string, start if !reconciled { slog.Debug("skipping config fetch because state already records target version/checksum", "version", target.Version, "checksum", target.Checksum) } + // Successful reconcile must clear sticky LastError so health events can resolve. + snapshot.LastError = "" return s.stateStore.Save(snapshot) }