test(ingestion): behavioural race tests for raw/ reads; tighten the lint
CI / Lint / Test / Vet (pull_request) Successful in 36s
CI / Mirror to GitHub (pull_request) Skipped

PR #156 round 3: a readRawNote rewrite to os.ReadFile or Root.ReadFile
passed the lint and brought back the escape (19k leaks in 4s) and the
FIFO hang. Listing forbidden APIs is whack-a-mole, so the control now
rests on behaviour:

- TestRawReadersLeakNothingUnderASymlinkSwapRace and
  TestRawReadersDoNotHangUnderAFIFOSwapRace swap a regular note, a
  symlink to an outside file, or a FIFO into raw/r.md while ListPending
  and ReadIngestFile loop for 1.5s; any leaked byte or a hang fails.
- TestProcessDir_DoesNotHangUnderAFIFOSwapRace does the same for the
  unattended watcher (read and archive copy).

Flipped: os.ReadFile -> leak + hang tests red; Root.ReadFile -> hang
test red; copyFromRoot back to Root.Open -> watcher test red.

The lint stays as a second layer: it also forbids ReadFile, matches the
selector (so a stored method value is caught), refuses a dot-import of
extract, and says it only covers the three files it parses.

Refs #153

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 17:07:58 +02:00
co-authored by Claude Opus 5.5
parent 809420070b
commit d5b2be0ecf
3 changed files with 179 additions and 6 deletions
@@ -8,6 +8,8 @@ import (
"os"
"path/filepath"
"strings"
"sync/atomic"
"syscall"
"testing"
"time"
@@ -334,3 +336,51 @@ func TestProcessDir_RefusesASymlinkedRawDir(t *testing.T) {
assert.NotEmpty(t, errs, "a symlinked raw/ must be reported, not silently ingested")
assert.Zero(t, calls)
}
// PR #156: a FIFO swapped into raw/ between the watcher's checks and its
// read or archive copy must not hang the watcher, which runs unattended.
func TestProcessDir_DoesNotHangUnderAFIFOSwapRace(t *testing.T) {
brainDir := setupBrainDir(t)
raw := filepath.Join(brainDir, "raw")
var stop atomic.Bool
go func() {
for i := 0; !stop.Load(); i++ {
tmp := filepath.Join(raw, ".swap")
_ = os.Remove(tmp)
if i%2 == 0 {
_ = os.WriteFile(tmp, []byte("x"), 0o644)
} else {
_ = syscall.Mkfifo(tmp, 0o644)
}
_ = os.Rename(tmp, filepath.Join(raw, "r.md"))
}
}()
cfg := Config{BrainDir: brainDir, Interval: time.Hour, Pipeline: pipeline.Config{
Complete: func(context.Context, string, string) (string, error) {
b, _ := json.Marshal([]pipeline.RawPage{{Title: "R", Type: "source", Subtype: "article", Content: "## S\n\nx\n"}})
return string(b), nil
},
Schema: "# Schema",
}}
var iters atomic.Int64
done := make(chan struct{})
go func() {
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); {
_ = os.Remove(filepath.Join(raw, "r.md.processed"))
_ = os.Remove(filepath.Join(raw, "r.md.failed"))
_ = processDir(context.Background(), cfg, "2026-10-03")
iters.Add(1)
}
close(done)
}()
select {
case <-done:
stop.Store(true)
case <-time.After(8 * time.Second):
stop.Store(true)
if f, err := os.OpenFile(filepath.Join(raw, "r.md"), os.O_WRONLY|syscall.O_NONBLOCK, 0); err == nil {
_ = f.Close()
}
t.Fatalf("the watcher hung on a swapped-in FIFO after %d iterations", iters.Load())
}
}