From 7fbc2a03d3b90d5b0f6182e2c9805d4c3bde6746 Mon Sep 17 00:00:00 2001 From: liangxu Date: Fri, 1 May 2026 21:55:49 +0800 Subject: [PATCH] fix(config): debounce fsnotify reload events to avoid mid-write reads os.WriteFile truncates then writes, so inotify can deliver back-to-back MODIFY events while the file is momentarily empty. Reading mid-sequence made TestStoreReloadsWhenLocalOverrideIsCreated and TestStoreReloadsConfigAndKeepsPreviousOnInvalidYAML flake on Linux CI by firing onReload with a blank config before the write settled. Coalesce updates over a 50ms quiet window before reloading, and skip onReload when the diff is empty so subscribers only see real changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/internal/shared/config/store.go | 32 ++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/server/internal/shared/config/store.go b/server/internal/shared/config/store.go index d4e61fb..edae8f6 100644 --- a/server/internal/shared/config/store.go +++ b/server/internal/shared/config/store.go @@ -103,11 +103,40 @@ func (s *Store) Watch(ctx context.Context, logger *zap.Logger, onReload func(Rel case <-s.ctx.Done(): return nil case <-s.updates: + if !s.coalesceUpdates(ctx, reloadDebounce) { + return nil + } s.reload(logger, onReload) } } } +// reloadDebounce coalesces fsnotify events from a single write. os.WriteFile +// opens with O_CREATE|O_TRUNC then writes, so inotify can deliver back-to-back +// MODIFY events with the file momentarily empty. Reading mid-sequence yields a +// blank config; waiting for a brief quiet period lets the write settle. +const reloadDebounce = 50 * time.Millisecond + +func (s *Store) coalesceUpdates(ctx context.Context, window time.Duration) bool { + timer := time.NewTimer(window) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return false + case <-s.ctx.Done(): + return false + case <-s.updates: + if !timer.Stop() { + <-timer.C + } + timer.Reset(window) + case <-timer.C: + return true + } + } +} + func (s *Store) Close() { if s == nil { return @@ -138,6 +167,9 @@ func (s *Store) reload(logger *zap.Logger, onReload func(ReloadEvent)) { s.mu.Unlock() changes := Diff(previous, next) + if len(changes) == 0 { + return + } if logger != nil { logger.Info("config reloaded", zap.Strings("files", files),