From 3fbee51e9fe2ff6983b1f477bd6f9573ab9b280c Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Mon, 9 Feb 2026 08:32:58 +0800 Subject: [PATCH] fix(management): ensure management.html is available synchronously and improve asset sync handling --- go.mod | 2 +- internal/api/server.go | 15 +-- internal/managementasset/updater.go | 156 +++++++++++++++------------- 3 files changed, 92 insertions(+), 81 deletions(-) diff --git a/go.mod b/go.mod index 32080fd7..38a499be 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( golang.org/x/crypto v0.45.0 golang.org/x/net v0.47.0 golang.org/x/oauth2 v0.30.0 + golang.org/x/sync v0.18.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 ) @@ -69,7 +70,6 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect google.golang.org/protobuf v1.34.1 // indirect diff --git a/internal/api/server.go b/internal/api/server.go index e1e7a14d..3eb09366 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -655,14 +655,17 @@ func (s *Server) serveManagementControlPanel(c *gin.Context) { if _, err := os.Stat(filePath); err != nil { if os.IsNotExist(err) { - go managementasset.EnsureLatestManagementHTML(context.Background(), managementasset.StaticDir(s.configFilePath), cfg.ProxyURL, cfg.RemoteManagement.PanelGitHubRepository) - c.AbortWithStatus(http.StatusNotFound) + // Synchronously ensure management.html is available with a detached context. + // Control panel bootstrap should not be canceled by client disconnects. + if !managementasset.EnsureLatestManagementHTML(context.Background(), managementasset.StaticDir(s.configFilePath), cfg.ProxyURL, cfg.RemoteManagement.PanelGitHubRepository) { + c.AbortWithStatus(http.StatusNotFound) + return + } + } else { + log.WithError(err).Error("failed to stat management control panel asset") + c.AbortWithStatus(http.StatusInternalServerError) return } - - log.WithError(err).Error("failed to stat management control panel asset") - c.AbortWithStatus(http.StatusInternalServerError) - return } c.File(filePath) diff --git a/internal/managementasset/updater.go b/internal/managementasset/updater.go index 2fbaab12..7284b729 100644 --- a/internal/managementasset/updater.go +++ b/internal/managementasset/updater.go @@ -21,6 +21,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/util" sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" log "github.com/sirupsen/logrus" + "golang.org/x/sync/singleflight" ) const ( @@ -41,6 +42,7 @@ var ( currentConfigPtr atomic.Pointer[config.Config] schedulerOnce sync.Once schedulerConfigPath atomic.Value + sfGroup singleflight.Group ) // SetCurrentConfig stores the latest configuration snapshot for management asset decisions. @@ -171,8 +173,8 @@ func FilePath(configFilePath string) string { } // EnsureLatestManagementHTML checks the latest management.html asset and updates the local copy when needed. -// The function is designed to run in a background goroutine and will never panic. -func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL string, panelRepository string) { +// It coalesces concurrent sync attempts and returns whether the asset exists after the sync attempt. +func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL string, panelRepository string) bool { if ctx == nil { ctx = context.Background() } @@ -180,91 +182,97 @@ func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL staticDir = strings.TrimSpace(staticDir) if staticDir == "" { log.Debug("management asset sync skipped: empty static directory") - return + return false } - - lastUpdateCheckMu.Lock() - now := time.Now() - timeSinceLastAttempt := now.Sub(lastUpdateCheckTime) - if !lastUpdateCheckTime.IsZero() && timeSinceLastAttempt < managementSyncMinInterval { - lastUpdateCheckMu.Unlock() - log.Debugf( - "management asset sync skipped by throttle: last attempt %v ago (interval %v)", - timeSinceLastAttempt.Round(time.Second), - managementSyncMinInterval, - ) - return - } - lastUpdateCheckTime = now - lastUpdateCheckMu.Unlock() - localPath := filepath.Join(staticDir, managementAssetName) - localFileMissing := false - if _, errStat := os.Stat(localPath); errStat != nil { - if errors.Is(errStat, os.ErrNotExist) { - localFileMissing = true - } else { - log.WithError(errStat).Debug("failed to stat local management asset") + + _, _, _ = sfGroup.Do(localPath, func() (interface{}, error) { + lastUpdateCheckMu.Lock() + now := time.Now() + timeSinceLastAttempt := now.Sub(lastUpdateCheckTime) + if !lastUpdateCheckTime.IsZero() && timeSinceLastAttempt < managementSyncMinInterval { + lastUpdateCheckMu.Unlock() + log.Debugf( + "management asset sync skipped by throttle: last attempt %v ago (interval %v)", + timeSinceLastAttempt.Round(time.Second), + managementSyncMinInterval, + ) + return nil, nil } - } + lastUpdateCheckTime = now + lastUpdateCheckMu.Unlock() - if errMkdirAll := os.MkdirAll(staticDir, 0o755); errMkdirAll != nil { - log.WithError(errMkdirAll).Warn("failed to prepare static directory for management asset") - return - } - - releaseURL := resolveReleaseURL(panelRepository) - client := newHTTPClient(proxyURL) - - localHash, err := fileSHA256(localPath) - if err != nil { - if !errors.Is(err, os.ErrNotExist) { - log.WithError(err).Debug("failed to read local management asset hash") - } - localHash = "" - } - - asset, remoteHash, err := fetchLatestAsset(ctx, client, releaseURL) - if err != nil { - if localFileMissing { - log.WithError(err).Warn("failed to fetch latest management release information, trying fallback page") - if ensureFallbackManagementHTML(ctx, client, localPath) { - return + localFileMissing := false + if _, errStat := os.Stat(localPath); errStat != nil { + if errors.Is(errStat, os.ErrNotExist) { + localFileMissing = true + } else { + log.WithError(errStat).Debug("failed to stat local management asset") } - return } - log.WithError(err).Warn("failed to fetch latest management release information") - return - } - if remoteHash != "" && localHash != "" && strings.EqualFold(remoteHash, localHash) { - log.Debug("management asset is already up to date") - return - } + if errMkdirAll := os.MkdirAll(staticDir, 0o755); errMkdirAll != nil { + log.WithError(errMkdirAll).Warn("failed to prepare static directory for management asset") + return nil, nil + } - data, downloadedHash, err := downloadAsset(ctx, client, asset.BrowserDownloadURL) - if err != nil { - if localFileMissing { - log.WithError(err).Warn("failed to download management asset, trying fallback page") - if ensureFallbackManagementHTML(ctx, client, localPath) { - return + releaseURL := resolveReleaseURL(panelRepository) + client := newHTTPClient(proxyURL) + + localHash, err := fileSHA256(localPath) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + log.WithError(err).Debug("failed to read local management asset hash") } - return + localHash = "" } - log.WithError(err).Warn("failed to download management asset") - return - } - if remoteHash != "" && !strings.EqualFold(remoteHash, downloadedHash) { - log.Warnf("remote digest mismatch for management asset: expected %s got %s", remoteHash, downloadedHash) - } + asset, remoteHash, err := fetchLatestAsset(ctx, client, releaseURL) + if err != nil { + if localFileMissing { + log.WithError(err).Warn("failed to fetch latest management release information, trying fallback page") + if ensureFallbackManagementHTML(ctx, client, localPath) { + return nil, nil + } + return nil, nil + } + log.WithError(err).Warn("failed to fetch latest management release information") + return nil, nil + } - if err = atomicWriteFile(localPath, data); err != nil { - log.WithError(err).Warn("failed to update management asset on disk") - return - } + if remoteHash != "" && localHash != "" && strings.EqualFold(remoteHash, localHash) { + log.Debug("management asset is already up to date") + return nil, nil + } - log.Infof("management asset updated successfully (hash=%s)", downloadedHash) + data, downloadedHash, err := downloadAsset(ctx, client, asset.BrowserDownloadURL) + if err != nil { + if localFileMissing { + log.WithError(err).Warn("failed to download management asset, trying fallback page") + if ensureFallbackManagementHTML(ctx, client, localPath) { + return nil, nil + } + return nil, nil + } + log.WithError(err).Warn("failed to download management asset") + return nil, nil + } + + if remoteHash != "" && !strings.EqualFold(remoteHash, downloadedHash) { + log.Warnf("remote digest mismatch for management asset: expected %s got %s", remoteHash, downloadedHash) + } + + if err = atomicWriteFile(localPath, data); err != nil { + log.WithError(err).Warn("failed to update management asset on disk") + return nil, nil + } + + log.Infof("management asset updated successfully (hash=%s)", downloadedHash) + return nil, nil + }) + + _, err := os.Stat(localPath) + return err == nil } func ensureFallbackManagementHTML(ctx context.Context, client *http.Client, localPath string) bool {