From f4994984d40c8f9ea30016fa91be0d89ab27e561 Mon Sep 17 00:00:00 2001 From: Markus Date: Sun, 4 Oct 2026 02:00:08 +0200 Subject: [PATCH 1/2] test: fix TestHarvestTypedRecipients flake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit harvestTypedRecipients persists the contacts cache asynchronously (safeGo → SaveIfDirty). t.TempDir's cleanup can race that goroutine and fail the test with: unlinkat … directory not empty under repeated runs (-count / -race) — it only passed at -count=1 by luck of timing. Fix: poll for the cache file to land on disk before the test ends (2 ms interval, 2 s deadline). Test-only change, pre-existing on main; surfaced while stress-testing the keyword-tags feature's identical write-through pattern. Verified: intermittent failure with -count=20 before, stable after (-count=30, and -race -count=10). --- internal/ui/compose_test.go | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/internal/ui/compose_test.go b/internal/ui/compose_test.go index 6d334dd..473121a 100644 --- a/internal/ui/compose_test.go +++ b/internal/ui/compose_test.go @@ -1,9 +1,11 @@ package ui import ( + "os" "path/filepath" "strings" "testing" + "time" "github.com/sspaeti/neomd/internal/contacts" ) @@ -86,7 +88,8 @@ func TestComposeSuggestions_MultiRecipientInsert(t *testing.T) { // Sending must persist user-typed "Name " recipients into the contacts // store so autocomplete knows them next time (no contacts file needed). func TestHarvestTypedRecipients(t *testing.T) { - s := contacts.Load(filepath.Join(t.TempDir(), "contacts")) + cachePath := filepath.Join(t.TempDir(), "contacts") + s := contacts.Load(cachePath) m := Model{contacts: s} m.harvestTypedRecipients( "Max Muster , bare@x.io", @@ -109,4 +112,18 @@ func TestHarvestTypedRecipients(t *testing.T) { // Nil store: must be a no-op, not a panic. nilModel := Model{} nilModel.harvestTypedRecipients("A ") + + // harvestTypedRecipients persists via safeGo(SaveIfDirty); without + // waiting, t.TempDir cleanup can race that goroutine and fail with + // "unlinkat … directory not empty" (flaky under -count/repeated runs). + deadline := time.Now().Add(2 * time.Second) + for { + if _, err := os.Stat(cachePath); err == nil { + break + } + if !time.Now().Before(deadline) { + t.Fatal("contacts cache never saved to disk") + } + time.Sleep(2 * time.Millisecond) + } } From d25d997bbe049d612298aba45af75980e0547d2f Mon Sep 17 00:00:00 2001 From: Markus Date: Tue, 6 Oct 2026 23:53:46 +0200 Subject: [PATCH 2/2] test: fix TestApplySenderRules_PersistsMatches flake, share waitForFile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same race class as TestHarvestTypedRecipients: applySenderRules persists merges.toml via safeGo(store.Save) and t.TempDir cleanup can race that goroutine ("unlinkat ... directory not empty"), flaky under -count and on slow CI disks. Pre-existing on main — reproduced at the branch point with -count=300. Extract the poll-until-saved loop into a shared waitForFile helper and use it from both tests. --- internal/ui/compose_test.go | 18 +++--------------- internal/ui/helpers_test.go | 25 +++++++++++++++++++++++++ internal/ui/model_test.go | 3 +++ 3 files changed, 31 insertions(+), 15 deletions(-) create mode 100644 internal/ui/helpers_test.go diff --git a/internal/ui/compose_test.go b/internal/ui/compose_test.go index 473121a..10f6961 100644 --- a/internal/ui/compose_test.go +++ b/internal/ui/compose_test.go @@ -1,11 +1,9 @@ package ui import ( - "os" "path/filepath" "strings" "testing" - "time" "github.com/sspaeti/neomd/internal/contacts" ) @@ -113,17 +111,7 @@ func TestHarvestTypedRecipients(t *testing.T) { nilModel := Model{} nilModel.harvestTypedRecipients("A ") - // harvestTypedRecipients persists via safeGo(SaveIfDirty); without - // waiting, t.TempDir cleanup can race that goroutine and fail with - // "unlinkat … directory not empty" (flaky under -count/repeated runs). - deadline := time.Now().Add(2 * time.Second) - for { - if _, err := os.Stat(cachePath); err == nil { - break - } - if !time.Now().Before(deadline) { - t.Fatal("contacts cache never saved to disk") - } - time.Sleep(2 * time.Millisecond) - } + // harvestTypedRecipients persists via safeGo(SaveIfDirty) — wait for + // the write before TempDir cleanup runs. + waitForFile(t, cachePath) } diff --git a/internal/ui/helpers_test.go b/internal/ui/helpers_test.go new file mode 100644 index 0000000..0af3dd4 --- /dev/null +++ b/internal/ui/helpers_test.go @@ -0,0 +1,25 @@ +package ui + +import ( + "os" + "testing" + "time" +) + +// waitForFile blocks until path exists on disk. Tests that trigger async +// persistence (safeGo → Save) into a t.TempDir must call this before +// returning: the test's TempDir cleanup otherwise races the goroutine and +// fails with "unlinkat … directory not empty" (flaky under -count / -race). +func waitForFile(t *testing.T, path string) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for { + if _, err := os.Stat(path); err == nil { + return + } + if !time.Now().Before(deadline) { + t.Fatalf("%s never landed on disk", path) + } + time.Sleep(2 * time.Millisecond) + } +} diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 45fc3a8..b3e9390 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -1300,6 +1300,9 @@ func TestApplySenderRules_PersistsMatches(t *testing.T) { if n := m.applySenderRules(emails); n != 1 { t.Errorf("applySenderRules = %d, want 1", n) } + // applySenderRules persists via safeGo(store.Save) — wait for the + // write before TempDir cleanup runs. + waitForFile(t, path) if title, ok := s.TitleOf(""); !ok || title != "Bounces" { t.Errorf(" should now belong to Bounces, got %q %v", title, ok) }