fix(gateway): detect same-size config edits during hot reload

This commit is contained in:
duomi 2026-03-13 23:24:49 +08:00
parent c68b4f3903
commit 0542acd5d8
2 changed files with 77 additions and 20 deletions

View file

@ -2,6 +2,7 @@ package gateway
import (
"context"
"crypto/sha256"
"fmt"
"os"
"os/signal"
@ -522,9 +523,7 @@ func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Conf
go func() {
defer wg.Done()
// Get initial file info
lastModTime := getFileModTime(configPath)
lastSize := getFileSize(configPath)
lastFingerprint := getFileFingerprint(configPath)
ticker := time.NewTicker(2 * time.Second) // Check every 2 seconds
defer ticker.Stop()
@ -532,11 +531,12 @@ func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Conf
for {
select {
case <-ticker.C:
currentModTime := getFileModTime(configPath)
currentSize := getFileSize(configPath)
currentFingerprint := getFileFingerprint(configPath)
// Check if file changed (modification time or size changed)
if currentModTime.After(lastModTime) || currentSize != lastSize {
// Detect changes by file fingerprint instead of mtime/size alone.
// This avoids missing edits that land within the same filesystem
// timestamp granularity window and keep the same file size.
if currentFingerprint != lastFingerprint {
if debug {
logger.Debugf("🔍 Config file change detected")
}
@ -562,8 +562,7 @@ func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Conf
logger.Info("✓ Config file validated and loaded")
// Update last known state
lastModTime = currentModTime
lastSize = currentSize
lastFingerprint = currentFingerprint
// Send new config to main loop (non-blocking)
select {
@ -588,22 +587,34 @@ func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Conf
return configChan, stopFunc
}
// getFileModTime returns the modification time of a file, or zero time if file doesn't exist
func getFileModTime(path string) time.Time {
info, err := os.Stat(path)
if err != nil {
return time.Time{}
}
return info.ModTime()
type fileFingerprint struct {
ModTime time.Time
Size int64
Hash [32]byte
}
// getFileSize returns the size of a file, or 0 if file doesn't exist
func getFileSize(path string) int64 {
// getFileFingerprint returns a stable fingerprint for config change detection.
// Hashing the content closes the gap where mtime granularity and unchanged size
// would otherwise hide rapid successive edits from the polling watcher.
func getFileFingerprint(path string) fileFingerprint {
info, err := os.Stat(path)
if err != nil {
return 0
return fileFingerprint{}
}
data, err := os.ReadFile(path)
if err != nil {
return fileFingerprint{
ModTime: info.ModTime(),
Size: info.Size(),
}
}
return fileFingerprint{
ModTime: info.ModTime(),
Size: info.Size(),
Hash: sha256.Sum256(data),
}
return info.Size()
}
func setupCronTool(

View file

@ -0,0 +1,46 @@
package gateway
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetFileFingerprintDetectsSameSizeSameTimestampChanges(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
initialContent := []byte(`{"model":"aaaa"}`)
require.NoError(t, os.WriteFile(configPath, initialContent, 0o600))
original := getFileFingerprint(configPath)
require.NotZero(t, original.ModTime)
require.NotZero(t, original.Size)
updatedContent := []byte(`{"model":"bbbb"}`)
require.Len(t, updatedContent, len(initialContent))
require.NoError(t, os.WriteFile(configPath, updatedContent, 0o600))
// Simulate a coarse-grained filesystem timestamp where two writes inside the
// same second end up with the same reported modification time.
require.NoError(t, os.Chtimes(configPath, original.ModTime, original.ModTime))
updated := getFileFingerprint(configPath)
assert.Equal(t, original.ModTime, updated.ModTime)
assert.Equal(t, original.Size, updated.Size)
assert.NotEqual(t, original.Hash, updated.Hash)
assert.NotEqual(t, original, updated)
}
func TestGetFileFingerprintMissingFile(t *testing.T) {
fp := getFileFingerprint(filepath.Join(t.TempDir(), "missing.json"))
assert.Equal(t, fileFingerprint{}, fp)
assert.True(t, fp.ModTime.IsZero())
assert.Equal(t, int64(0), fp.Size)
assert.Equal(t, [32]byte{}, fp.Hash)
}