diff --git a/accounts/keystore/account_cache.go b/accounts/keystore/account_cache.go index 2c21f57f55f5..11dbf23d0500 100644 --- a/accounts/keystore/account_cache.go +++ b/accounts/keystore/account_cache.go @@ -27,10 +27,10 @@ import ( "sync" "time" - mapset "github.com/deckarep/golang-set/v2" "github.com/ava-labs/libevm/accounts" "github.com/ava-labs/libevm/common" "github.com/ava-labs/libevm/log" + mapset "github.com/deckarep/golang-set/v2" "golang.org/x/exp/slices" ) @@ -79,7 +79,10 @@ func newAccountCache(keydir string) (*accountCache, chan struct{}) { keydir: keydir, byAddr: make(map[common.Address][]accounts.Account), notify: make(chan struct{}, 1), - fileC: fileCache{all: mapset.NewThreadUnsafeSet[string]()}, + fileC: fileCache{ + all: mapset.NewThreadUnsafeSet[string](), + fileStat: make(map[string]fileStat), + }, } ac.watcher = newWatcher(ac) return ac, ac.notify diff --git a/accounts/keystore/file_cache.go b/accounts/keystore/file_cache.go index 4e99fa08916a..d548c606f6fd 100644 --- a/accounts/keystore/file_cache.go +++ b/accounts/keystore/file_cache.go @@ -23,15 +23,22 @@ import ( "sync" "time" - mapset "github.com/deckarep/golang-set/v2" "github.com/ava-labs/libevm/log" + mapset "github.com/deckarep/golang-set/v2" ) +// fileStat is metadata from the last scan used to detect in-place updates. +type fileStat struct { + mod time.Time + size int64 +} + // fileCache is a cache of files seen during scan of keystore. type fileCache struct { - all mapset.Set[string] // Set of all files from the keystore folder - lastMod time.Time // Last time instance when a file was modified - mu sync.Mutex + all mapset.Set[string] // Set of all files from the keystore folder + lastMod time.Time // Latest ModTime among key files in the last scan + fileStat map[string]fileStat // path -> size and mod time at last scan + mu sync.Mutex } // scan performs a new scan on the given directory, compares against the already @@ -54,6 +61,7 @@ func (fc *fileCache) scan(keyDir string) (mapset.Set[string], mapset.Set[string] mods := mapset.NewThreadUnsafeSet[string]() var newLastMod time.Time + newFileStat := make(map[string]fileStat) for _, fi := range files { path := filepath.Join(keyDir, fi.Name()) // Skip any non-key files from the folder @@ -69,8 +77,20 @@ func (fc *fileCache) scan(keyDir string) (mapset.Set[string], mapset.Set[string] return nil, nil, nil, err } modified := info.ModTime() - if modified.After(fc.lastMod) { + size := info.Size() + newFileStat[path] = fileStat{mod: modified, size: size} + + switch { + case modified.After(fc.lastMod): + // Legacy coarse signal (new files and clock moved forward). mods.Add(path) + case fc.all.Contains(path): + // In-place edits can keep the same coarse ModTime as fc.lastMod (e.g. same + // wall-clock second) while still changing content; compare per-file metadata. + prev := fc.fileStat[path] + if prev.size != size || !prev.mod.Equal(modified) { + mods.Add(path) + } } if modified.After(newLastMod) { newLastMod = modified @@ -83,7 +103,7 @@ func (fc *fileCache) scan(keyDir string) (mapset.Set[string], mapset.Set[string] creates := all.Difference(fc.all) // Creates = current - previous updates := mods.Difference(creates) // Updates = modified - creates - fc.all, fc.lastMod = all, newLastMod + fc.all, fc.lastMod, fc.fileStat = all, newLastMod, newFileStat t3 := time.Now() // Report on the scanning stats and return diff --git a/accounts/keystore/file_cache_test.go b/accounts/keystore/file_cache_test.go new file mode 100644 index 000000000000..cec420a65f6e --- /dev/null +++ b/accounts/keystore/file_cache_test.go @@ -0,0 +1,90 @@ +// Copyright 2026 the libevm authors. +// +// The libevm additions to go-ethereum are free software: you can redistribute +// them and/or modify them under the terms of the GNU Lesser General Public License +// as published by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// The libevm additions are distributed in the hope that they will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser +// General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see +// . + +package keystore + +import ( + "os" + "path/filepath" + "testing" + "time" + + mapset "github.com/deckarep/golang-set/v2" +) + +// TestFileCacheScan_inPlaceSizeChangeWithoutAfterLastMod ensures a key file is +// treated as updated when its size changes even if ModTime is not strictly after +// the previous scan's global lastMod (e.g. same wall-clock second, or tests that +// bump lastMod artificially). +func TestFileCacheScan_inPlaceSizeChangeWithoutAfterLastMod(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "aaa") + if err := os.WriteFile(path, []byte("short"), 0600); err != nil { + t.Fatal(err) + } + + fc := &fileCache{ + all: mapset.NewThreadUnsafeSet[string](), + fileStat: make(map[string]fileStat), + } + if _, _, _, err := fc.scan(dir); err != nil { + t.Fatal(err) + } + + // Make the global lastMod lie in the future so ModTime.After(lastMod) is false, + // while the path is still known (in-place edit scenario). + fc.lastMod = time.Now().Add(1 * time.Hour) + + if err := os.WriteFile(path, []byte("muuuuuuuuch longer content"), 0600); err != nil { + t.Fatal(err) + } + + creates, deletes, updates, err := fc.scan(dir) + if err != nil { + t.Fatal(err) + } + if creates.Cardinality() != 0 || deletes.Cardinality() != 0 { + t.Fatalf("creates=%d deletes=%d; want 0,0", creates.Cardinality(), deletes.Cardinality()) + } + if updates.Cardinality() != 1 || !updates.Contains(path) { + t.Fatalf("updates=%v; want single path %q", updates, path) + } +} + +func TestFileCacheScan_noSpuriousUpdateWhenUnchanged(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "aaa") + content := []byte("fixed content") + if err := os.WriteFile(path, content, 0600); err != nil { + t.Fatal(err) + } + + fc := &fileCache{ + all: mapset.NewThreadUnsafeSet[string](), + fileStat: make(map[string]fileStat), + } + if _, _, _, err := fc.scan(dir); err != nil { + t.Fatal(err) + } + creates, deletes, updates, err := fc.scan(dir) + if err != nil { + t.Fatal(err) + } + if creates.Cardinality() != 0 || deletes.Cardinality() != 0 || updates.Cardinality() != 0 { + t.Fatalf("creates=%d deletes=%d updates=%d; want all 0", + creates.Cardinality(), deletes.Cardinality(), updates.Cardinality()) + } +} diff --git a/cmd/geth/logging_test.go b/cmd/geth/logging_test.go index 527dbcbd9d5c..a1addc17ed0f 100644 --- a/cmd/geth/logging_test.go +++ b/cmd/geth/logging_test.go @@ -90,7 +90,7 @@ func testConsoleLogging(t *testing.T, format string, tStart, tEnd int) { have = censor(have, tStart, tEnd) want = censor(want, tStart, tEnd) if have != want { - t.Logf(nicediff([]byte(have), []byte(want))) + t.Log(nicediff([]byte(have), []byte(want))) t.Fatalf("format %v, line %d\nhave %v\nwant %v", format, i, have, want) } } @@ -140,7 +140,7 @@ func TestJsonLogging(t *testing.T) { } if !bytes.Equal(have, want) { // show an intelligent diff - t.Logf(nicediff(have, want)) + t.Log(nicediff(have, want)) t.Errorf("file content wrong") } } @@ -210,7 +210,7 @@ func TestFileOut(t *testing.T) { } if !bytes.Equal(have, want) { // show an intelligent diff - t.Logf(nicediff(have, want)) + t.Log(nicediff(have, want)) t.Errorf("file content wrong") } } @@ -231,7 +231,7 @@ func TestRotatingFileOut(t *testing.T) { } if !bytes.Equal(have, want) { // show an intelligent diff - t.Logf(nicediff(have, want)) + t.Log(nicediff(have, want)) t.Errorf("file content wrong") } } diff --git a/metrics/influxdb/influxdb_test.go b/metrics/influxdb/influxdb_test.go index 2d2862abc7c2..b1aa034b2cf1 100644 --- a/metrics/influxdb/influxdb_test.go +++ b/metrics/influxdb/influxdb_test.go @@ -23,6 +23,8 @@ import ( "net/http/httptest" "net/url" "os" + "regexp" + "strconv" "strings" "testing" @@ -31,6 +33,21 @@ import ( influxdb2 "github.com/influxdata/influxdb-client-go/v2" ) +// stddev and variance serialization can differ by one ULP across GOARCH / Go versions. +var influxStddevVarRE = regexp.MustCompile(`\b(stddev|variance)=([0-9.]+(?:[eE][+-]?[0-9]+)?)`) + +func normalizeInfluxStddevVariance(s string) string { + return influxStddevVarRE.ReplaceAllStringFunc(s, func(m string) string { + eq := strings.Index(m, "=") + key, val := m[:eq], m[eq+1:] + f, err := strconv.ParseFloat(val, 64) + if err != nil { + return m + } + return fmt.Sprintf("%s=%.12e", key, f) + }) +} + func TestMain(m *testing.M) { metrics.Enabled = true os.Exit(m.Run()) @@ -62,7 +79,7 @@ func TestExampleV1(t *testing.T) { } else { want = string(wantB) } - if have != want { + if normalizeInfluxStddevVariance(have) != normalizeInfluxStddevVariance(want) { t.Errorf("\nhave:\n%v\nwant:\n%v\n", have, want) t.Logf("have vs want:\n%v", findFirstDiffPos(have, want)) } @@ -94,7 +111,7 @@ func TestExampleV2(t *testing.T) { } else { want = string(wantB) } - if have != want { + if normalizeInfluxStddevVariance(have) != normalizeInfluxStddevVariance(want) { t.Errorf("\nhave:\n%v\nwant:\n%v\n", have, want) t.Logf("have vs want:\n%v", findFirstDiffPos(have, want)) }