diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index eef3010d..8c1a7da2 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -28,7 +28,6 @@ import ( geminiAuth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/gemini" "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/kimi" xaiauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/xai" - "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" @@ -1267,13 +1266,11 @@ func (h *Handler) PatchAuthFileStatus(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "config api key entry not found"}) return } - if errSave := config.SaveConfigPreserveComments(h.configFilePath, h.cfg); errSave != nil { - h.mu.Unlock() - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to save config: %v", errSave)}) + cfgSnapshot, okSnapshot := h.saveConfigAndSnapshotLocked(c) + h.mu.Unlock() + if !okSnapshot { return } - cfgSnapshot := h.cfg - h.mu.Unlock() h.reloadConfigAfterManagementSave(ctx, cfgSnapshot) if h.tokenStore != nil { _ = h.tokenStore.Delete(ctx, targetAuth.ID) diff --git a/internal/api/handlers/management/handler.go b/internal/api/handlers/management/handler.go index dc07ee00..3e83faf5 100644 --- a/internal/api/handlers/management/handler.go +++ b/internal/api/handlers/management/handler.go @@ -152,8 +152,29 @@ func (h *Handler) SetConfigReloadHook(hook func(context.Context, *config.Config) h.mu.Unlock() } -func (h *Handler) reloadConfigAfterManagementSave(ctx context.Context, cfg *config.Config) { - if h == nil || cfg == nil { +// snapshotConfigLocked clones the full runtime config while h.mu is held. +// Callers must hold h.mu. +func (h *Handler) snapshotConfigLocked() *config.Config { + if h == nil || h.cfg == nil { + return nil + } + return h.cfg.CloneForRuntime() +} + +// saveConfigAndSnapshotLocked saves h.cfg and returns a full runtime config snapshot. +// Callers must hold h.mu. +func (h *Handler) saveConfigAndSnapshotLocked(c *gin.Context) (*config.Config, bool) { + if errSave := config.SaveConfigPreserveComments(h.configFilePath, h.cfg); errSave != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to save config: %v", errSave)}) + return nil, false + } + return h.snapshotConfigLocked(), true +} + +// reloadConfigAfterManagementSave reloads from an independent config snapshot. +// Callers must pass a full Config clone captured immediately after a successful save. +func (h *Handler) reloadConfigAfterManagementSave(ctx context.Context, cfgSnapshot *config.Config) { + if h == nil || cfgSnapshot == nil { return } h.mu.Lock() @@ -161,16 +182,18 @@ func (h *Handler) reloadConfigAfterManagementSave(ctx context.Context, cfg *conf host := h.pluginHost h.mu.Unlock() if hook != nil { - hook(ctx, cfg) + hook(ctx, cfgSnapshot) return } if host != nil { - host.ApplyConfig(ctx, cfg) + host.ApplyConfig(ctx, cfgSnapshot) } } -func (h *Handler) reloadConfigAfterManagementSaveAsync(ctx context.Context, cfg *config.Config) { - if h == nil || cfg == nil { +// reloadConfigAfterManagementSaveAsync reloads from an independent config snapshot. +// Callers must pass a full Config clone captured immediately after a successful save. +func (h *Handler) reloadConfigAfterManagementSaveAsync(ctx context.Context, cfgSnapshot *config.Config) { + if h == nil || cfgSnapshot == nil { return } reloadCtx := context.Background() @@ -183,7 +206,7 @@ func (h *Handler) reloadConfigAfterManagementSaveAsync(ctx context.Context, cfg log.WithField("panic", recovered).Error("management: async config reload panicked") } }() - h.reloadConfigAfterManagementSave(reloadCtx, cfg) + h.reloadConfigAfterManagementSave(reloadCtx, cfgSnapshot) }() } diff --git a/internal/api/handlers/management/plugin_store.go b/internal/api/handlers/management/plugin_store.go index 161f8986..fc13cdfe 100644 --- a/internal/api/handlers/management/plugin_store.go +++ b/internal/api/handlers/management/plugin_store.go @@ -226,9 +226,9 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) { if errInstall != nil { if unloadedBeforeWrite { h.mu.Lock() - reloadCfg := h.cfg + cfgSnapshot := h.snapshotConfigLocked() h.mu.Unlock() - h.reloadConfigAfterManagementSave(c.Request.Context(), reloadCfg) + h.reloadConfigAfterManagementSave(c.Request.Context(), cfgSnapshot) } if errors.Is(errInstall, pluginstore.ErrLoadedPluginLocked) { c.JSON(http.StatusConflict, gin.H{ @@ -271,10 +271,10 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) { }) return } - reloadCfg := h.cfg + cfgSnapshot := h.snapshotConfigLocked() h.mu.Unlock() - h.reloadConfigAfterManagementSaveAsync(c.Request.Context(), reloadCfg) + h.reloadConfigAfterManagementSaveAsync(c.Request.Context(), cfgSnapshot) log.WithFields(log.Fields{ "plugin_id": result.ID, "source_id": source.ID, diff --git a/internal/api/handlers/management/plugins.go b/internal/api/handlers/management/plugins.go index dcf71630..b1afb822 100644 --- a/internal/api/handlers/management/plugins.go +++ b/internal/api/handlers/management/plugins.go @@ -226,15 +226,13 @@ func (h *Handler) PatchPluginEnabled(c *gin.Context) { return } h.cfg.Plugins.Configs[id] = updated - if errSave := config.SaveConfigPreserveComments(h.configFilePath, h.cfg); errSave != nil { - h.mu.Unlock() - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to save config: %v", errSave)}) + cfgSnapshot, okSnapshot := h.saveConfigAndSnapshotLocked(c) + h.mu.Unlock() + if !okSnapshot { return } - reloadCfg := h.cfg - h.mu.Unlock() - h.reloadConfigAfterManagementSaveAsync(c.Request.Context(), reloadCfg) + h.reloadConfigAfterManagementSaveAsync(c.Request.Context(), cfgSnapshot) c.JSON(http.StatusOK, gin.H{"status": "ok"}) } @@ -375,10 +373,10 @@ func (h *Handler) DeletePlugin(c *gin.Context) { return } } - reloadCfg := h.cfg + cfgSnapshot := h.snapshotConfigLocked() h.mu.Unlock() - h.reloadConfigAfterManagementSaveAsync(c.Request.Context(), reloadCfg) + h.reloadConfigAfterManagementSaveAsync(c.Request.Context(), cfgSnapshot) c.JSON(http.StatusOK, gin.H{ "status": "deleted", "id": htmlsanitize.String(id),