package api_test import ( "os" "path/filepath" "strings" "sync/atomic" "syscall" "testing" "time" "github.com/mathiasbq/hyperguild/ingestion/internal/api" ) // hyperguild#153 / PR #156: behavioural race tests, so the control does not // rest on lints that list forbidden APIs (round 3: an os.ReadFile or // Root.ReadFile rewrite passed the lint and leaked 19k times in 4s). A // swapper keeps renaming a regular note, a symlink to an outside file, or a // FIFO into raw/r.md while the public readers loop. Any leaked byte fails; // any hang fails. const raceSentinel = "SENTINEL-153-RACE" func swap(raw, outside string, fifo bool, stop *atomic.Bool) { tgt := filepath.Join(raw, "r.md") for i := 0; !stop.Load(); i++ { tmp := filepath.Join(raw, ".swap") _ = os.Remove(tmp) switch { case i%2 == 0: _ = os.WriteFile(tmp, []byte("# r\n\ninside"), 0o644) case fifo: _ = syscall.Mkfifo(tmp, 0o644) default: _ = os.Symlink(outside, tmp) } _ = os.Rename(tmp, tgt) } } func raceBrain(t *testing.T) (brain, raw, outside string) { t.Helper() brain = t.TempDir() raw = filepath.Join(brain, "raw") if err := os.MkdirAll(raw, 0o755); err != nil { t.Fatal(err) } outside = filepath.Join(t.TempDir(), "secret.md") if err := os.WriteFile(outside, []byte("# s\n\n"+raceSentinel), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(raw, "r.md"), []byte("# r\n\ninside"), 0o644); err != nil { t.Fatal(err) } return brain, raw, outside } // readers runs every public raw/ reader once and returns all text they // produced (excerpts, extracted text). func readers(brain, raw string) string { var b strings.Builder if ns, err := api.ListPending(brain); err == nil { for _, n := range ns { b.WriteString(n.Excerpt) } } if s, err := api.ReadIngestFile(brain, filepath.Join(raw, "r.md")); err == nil { b.WriteString(s) } return b.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) leaks, iters := 0, 0 for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); iters++ { leaks += strings.Count(readers(brain, raw), raceSentinel) } if leaks > 0 { t.Fatalf("outside file leaked %d times in %d iterations", leaks, iters) } t.Logf("%d iterations, 0 leaks", iters) } func TestRawReadersDoNotHangUnderAFIFOSwapRace(t *testing.T) { brain, raw, outside := raceBrain(t) var stop atomic.Bool go swap(raw, outside, true, &stop) var iters atomic.Int64 done := make(chan struct{}) go func() { for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); { _ = readers(brain, raw) iters.Add(1) } close(done) }() 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() } t.Fatalf("a raw/ reader hung on a swapped-in FIFO after %d iterations", iters.Load()) } }