fix(ingestion): bound the tripwire's alerts; test removal guards; deflake race tests
CI / Lint / Test / Vet (pull_request) Successful in 47s
CI / Mirror to GitHub (pull_request) Skipped

PR #160 review:
- The planter chooses the paths, and one alert listed them all, while the
  queue retried oldest-first forever: a message ntfy refuses (too large)
  blocked every later alert (2000 links: 0 alerts delivered), and an
  outage queued one message per removal, unbounded. Alerts now name at
  most 10 paths and count the rest; the queue holds at most 5, further
  alerts become one suppression summary; a message failing 5 times is
  dropped so it cannot block the rest.
- removeSymlinks' Lstat re-check had no test: a direct test passes a
  regular file, a path through an in-brain dir link, a ../ escape and a
  missing path, and asserts all fail with nothing deleted. A Watch test
  covers dir and in-brain targets. root.Remove (not RemoveAll) is
  commented: it refuses a dir swapped in after the Lstat.
- Every queued alert is delivered after an outage (truncating the queue
  after the first send is now red).
- hyperguild#162: race tests did not wait for their swapper goroutines
  before t.TempDir cleanup ("directory not empty" in CI run 8912). Each
  swapper is now stopped and awaited in a t.Cleanup.

Fixes #162
Refs #157

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 18:24:13 +02:00
co-authored by Claude Opus 5.5
parent 504feb372f
commit 01de163452
4 changed files with 277 additions and 31 deletions
+17 -11
View File
@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"syscall"
"testing"
@@ -25,6 +26,18 @@ import (
const raceSentinel = "SENTINEL-153-RACE"
// startSwapper runs fn until the test ends, and waits for it to exit before
// t.TempDir's cleanup removes the directory it writes into (hyperguild#162:
// an unawaited swapper raced the cleanup, "directory not empty").
func startSwapper(t *testing.T, fn func(stop *atomic.Bool)) {
t.Helper()
var stop atomic.Bool
var wg sync.WaitGroup
wg.Add(1)
go func() { defer wg.Done(); fn(&stop) }()
t.Cleanup(func() { stop.Store(true); wg.Wait() })
}
func swap(raw, outside string, fifo bool, stop *atomic.Bool) {
tgt := filepath.Join(raw, "r.md")
for i := 0; !stop.Load(); i++ {
@@ -76,9 +89,7 @@ func readers(brain, raw string) string {
func TestRawReadersLeakNothingUnderASymlinkSwapRace(t *testing.T) {
brain, raw, outside := raceBrain(t)
var stop atomic.Bool
go swap(raw, outside, false, &stop)
defer stop.Store(true)
startSwapper(t, func(stop *atomic.Bool) { swap(raw, outside, false, stop) })
leaks, iters := 0, 0
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); iters++ {
leaks += strings.Count(readers(brain, raw), raceSentinel)
@@ -91,8 +102,7 @@ func TestRawReadersLeakNothingUnderASymlinkSwapRace(t *testing.T) {
func TestRawReadersDoNotHangUnderAFIFOSwapRace(t *testing.T) {
brain, raw, outside := raceBrain(t)
var stop atomic.Bool
go swap(raw, outside, true, &stop)
startSwapper(t, func(stop *atomic.Bool) { swap(raw, outside, true, stop) })
var iters atomic.Int64
done := make(chan struct{})
go func() {
@@ -104,9 +114,7 @@ func TestRawReadersDoNotHangUnderAFIFOSwapRace(t *testing.T) {
}()
select {
case <-done:
stop.Store(true)
case <-time.After(6 * time.Second):
stop.Store(true)
// Unblock the stuck open so the test process can exit.
if f, err := os.OpenFile(filepath.Join(raw, "r.md"), os.O_WRONLY|syscall.O_NONBLOCK, 0); err == nil {
_ = f.Close()
@@ -147,8 +155,7 @@ func TestDocmarkExtractionLeaksNothingUnderASymlinkSwapRace(t *testing.T) {
if err := os.WriteFile(tgt, []byte("inside docx bytes"), 0o644); err != nil {
t.Fatal(err)
}
var stop atomic.Bool
go func() {
startSwapper(t, func(stop *atomic.Bool) {
for i := 0; !stop.Load(); i++ {
tmp := filepath.Join(raw, ".swapdocx")
_ = os.Remove(tmp)
@@ -159,8 +166,7 @@ func TestDocmarkExtractionLeaksNothingUnderASymlinkSwapRace(t *testing.T) {
}
_ = os.Rename(tmp, tgt)
}
}()
defer stop.Store(true)
})
iters := 0
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); iters++ {
_, _ = api.ReadIngestFile(brain, tgt)