Skip to content

Commit a3c87ce

Browse files
Fix management reload snapshot ordering
1 parent 7b16321 commit a3c87ce

4 files changed

Lines changed: 94 additions & 38 deletions

File tree

internal/api/handlers/management/handler.go

Lines changed: 58 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -38,25 +38,33 @@ const attemptMaxIdleTime = 2 * time.Hour
3838

3939
// Handler aggregates config reference, persistence path and helpers.
4040
type Handler struct {
41-
cfg *config.Config
42-
configFilePath string
43-
mu sync.Mutex
44-
attemptsMu sync.Mutex
45-
failedAttempts map[string]*attemptInfo // keyed by client IP
46-
authManager *coreauth.Manager
47-
tokenStore coreauth.Store
48-
localPassword string
49-
allowRemoteOverride bool
50-
envSecret string
51-
logDir string
52-
postAuthHook coreauth.PostAuthHook
53-
postAuthPersistHook coreauth.PostAuthHook
54-
pluginHost *pluginhost.Host
55-
configReloadHook func(context.Context, *config.Config)
56-
pluginStoreRegistryURL string
57-
pluginStoreHTTPClient pluginstore.HTTPDoer
58-
pluginReleaseCacheMu sync.Mutex
59-
pluginReleaseCache map[string]pluginReleaseCacheEntry
41+
cfg *config.Config
42+
configFilePath string
43+
mu sync.Mutex
44+
reloadMu sync.Mutex
45+
reloadGeneration uint64
46+
appliedReloadGeneration uint64
47+
attemptsMu sync.Mutex
48+
failedAttempts map[string]*attemptInfo // keyed by client IP
49+
authManager *coreauth.Manager
50+
tokenStore coreauth.Store
51+
localPassword string
52+
allowRemoteOverride bool
53+
envSecret string
54+
logDir string
55+
postAuthHook coreauth.PostAuthHook
56+
postAuthPersistHook coreauth.PostAuthHook
57+
pluginHost *pluginhost.Host
58+
configReloadHook func(context.Context, *config.Config)
59+
pluginStoreRegistryURL string
60+
pluginStoreHTTPClient pluginstore.HTTPDoer
61+
pluginReleaseCacheMu sync.Mutex
62+
pluginReleaseCache map[string]pluginReleaseCacheEntry
63+
}
64+
65+
type configReloadSnapshot struct {
66+
cfg *config.Config
67+
generation uint64
6068
}
6169

6270
// NewHandler creates a new management handler instance.
@@ -152,48 +160,63 @@ func (h *Handler) SetConfigReloadHook(hook func(context.Context, *config.Config)
152160
h.mu.Unlock()
153161
}
154162

155-
// snapshotConfigLocked clones the full runtime config while h.mu is held.
163+
// reloadSnapshotConfigLocked clones the runtime config and assigns a reload generation.
156164
// Callers must hold h.mu.
157-
func (h *Handler) snapshotConfigLocked() *config.Config {
165+
func (h *Handler) reloadSnapshotConfigLocked() configReloadSnapshot {
158166
if h == nil || h.cfg == nil {
159-
return nil
167+
return configReloadSnapshot{}
168+
}
169+
h.reloadGeneration++
170+
return configReloadSnapshot{
171+
cfg: h.cfg.CloneForRuntime(),
172+
generation: h.reloadGeneration,
160173
}
161-
return h.cfg.CloneForRuntime()
162174
}
163175

164176
// saveConfigAndSnapshotLocked saves h.cfg and returns a full runtime config snapshot.
165177
// Callers must hold h.mu.
166-
func (h *Handler) saveConfigAndSnapshotLocked(c *gin.Context) (*config.Config, bool) {
178+
func (h *Handler) saveConfigAndSnapshotLocked(c *gin.Context) (configReloadSnapshot, bool) {
167179
if errSave := config.SaveConfigPreserveComments(h.configFilePath, h.cfg); errSave != nil {
168180
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to save config: %v", errSave)})
169-
return nil, false
181+
return configReloadSnapshot{}, false
170182
}
171-
return h.snapshotConfigLocked(), true
183+
return h.reloadSnapshotConfigLocked(), true
172184
}
173185

174186
// reloadConfigAfterManagementSave reloads from an independent config snapshot.
175187
// Callers must pass a full Config clone captured immediately after a successful save.
176-
func (h *Handler) reloadConfigAfterManagementSave(ctx context.Context, cfgSnapshot *config.Config) {
177-
if h == nil || cfgSnapshot == nil {
188+
func (h *Handler) reloadConfigAfterManagementSave(ctx context.Context, snapshot configReloadSnapshot) {
189+
if h == nil || snapshot.cfg == nil || snapshot.generation == 0 {
178190
return
179191
}
192+
h.reloadMu.Lock()
193+
defer h.reloadMu.Unlock()
194+
180195
h.mu.Lock()
196+
if snapshot.generation < h.appliedReloadGeneration {
197+
h.mu.Unlock()
198+
return
199+
}
181200
hook := h.configReloadHook
182201
host := h.pluginHost
183202
h.mu.Unlock()
184203
if hook != nil {
185-
hook(ctx, cfgSnapshot)
186-
return
204+
hook(ctx, snapshot.cfg)
205+
} else if host != nil {
206+
host.ApplyConfig(ctx, snapshot.cfg)
187207
}
188-
if host != nil {
189-
host.ApplyConfig(ctx, cfgSnapshot)
208+
209+
h.mu.Lock()
210+
if snapshot.generation > h.appliedReloadGeneration {
211+
h.appliedReloadGeneration = snapshot.generation
190212
}
213+
h.mu.Unlock()
191214
}
192215

193216
// reloadConfigAfterManagementSaveAsync reloads from an independent config snapshot.
194217
// Callers must pass a full Config clone captured immediately after a successful save.
195-
func (h *Handler) reloadConfigAfterManagementSaveAsync(ctx context.Context, cfgSnapshot *config.Config) {
196-
if h == nil || cfgSnapshot == nil {
218+
func (h *Handler) reloadConfigAfterManagementSaveAsync(ctx context.Context, snapshot configReloadSnapshot) {
219+
if h == nil || snapshot.cfg == nil || snapshot.generation == 0 {
197220
return
198221
}
199222
reloadCtx := context.Background()
@@ -206,7 +229,7 @@ func (h *Handler) reloadConfigAfterManagementSaveAsync(ctx context.Context, cfgS
206229
log.WithField("panic", recovered).Error("management: async config reload panicked")
207230
}
208231
}()
209-
h.reloadConfigAfterManagementSave(reloadCtx, cfgSnapshot)
232+
h.reloadConfigAfterManagementSave(reloadCtx, snapshot)
210233
}()
211234
}
212235

internal/api/handlers/management/plugin_store.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) {
226226
if errInstall != nil {
227227
if unloadedBeforeWrite {
228228
h.mu.Lock()
229-
cfgSnapshot := h.snapshotConfigLocked()
229+
cfgSnapshot := h.reloadSnapshotConfigLocked()
230230
h.mu.Unlock()
231231
h.reloadConfigAfterManagementSave(c.Request.Context(), cfgSnapshot)
232232
}
@@ -271,7 +271,7 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) {
271271
})
272272
return
273273
}
274-
cfgSnapshot := h.snapshotConfigLocked()
274+
cfgSnapshot := h.reloadSnapshotConfigLocked()
275275
h.mu.Unlock()
276276

277277
h.reloadConfigAfterManagementSaveAsync(c.Request.Context(), cfgSnapshot)

internal/api/handlers/management/plugins.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,7 @@ func (h *Handler) DeletePlugin(c *gin.Context) {
373373
return
374374
}
375375
}
376-
cfgSnapshot := h.snapshotConfigLocked()
376+
cfgSnapshot := h.reloadSnapshotConfigLocked()
377377
h.mu.Unlock()
378378

379379
h.reloadConfigAfterManagementSaveAsync(c.Request.Context(), cfgSnapshot)

internal/api/handlers/management/plugins_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,39 @@ func captureConfigReload(h *Handler) (<-chan *config.Config, <-chan struct{}) {
5151
return reloads, done
5252
}
5353

54+
func TestConfigReloadGenerationSkipsOlderSnapshot(t *testing.T) {
55+
t.Parallel()
56+
57+
h := &Handler{
58+
cfg: &config.Config{
59+
Plugins: config.PluginsConfig{
60+
Configs: map[string]config.PluginInstanceConfig{
61+
"sample": pluginConfigFromYAML(t, "enabled: true\nmode: old\n"),
62+
},
63+
},
64+
},
65+
}
66+
reloadedModes := make([]string, 0, 1)
67+
h.SetConfigReloadHook(func(_ context.Context, cfg *config.Config) {
68+
reloadedModes = append(reloadedModes, pluginRawScalarValue(t, cfg.Plugins.Configs["sample"], "mode"))
69+
})
70+
71+
h.mu.Lock()
72+
older := h.reloadSnapshotConfigLocked()
73+
item := h.cfg.Plugins.Configs["sample"]
74+
setPluginRawScalarValue(t, &item.Raw, "mode", "new")
75+
h.cfg.Plugins.Configs["sample"] = item
76+
newer := h.reloadSnapshotConfigLocked()
77+
h.mu.Unlock()
78+
79+
h.reloadConfigAfterManagementSave(context.Background(), newer)
80+
h.reloadConfigAfterManagementSave(context.Background(), older)
81+
82+
if len(reloadedModes) != 1 || reloadedModes[0] != "new" {
83+
t.Fatalf("reloaded modes = %#v, want only new snapshot", reloadedModes)
84+
}
85+
}
86+
5487
func TestListPluginsIncludesScannedAndConfiguredPlugins(t *testing.T) {
5588
t.Parallel()
5689

0 commit comments

Comments
 (0)