fix(watcher): refuse oversize notes before reading them; pin the long-name marker case (#198, #199)
CI / Lint / Test / Vet (pull_request) Successful in 1m7s
CI / Mirror to GitHub (pull_request) Skipped

MaxNoteBytes (default 64 MiB) is checked on the Lstat size before extraction;
an oversize note stays in raw/, is reported once and not retried until it
changes. The #199 case (marker name over NAME_MAX) is covered by the
remember-on-any-failure retire path and now has a test that fails without it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 20:04:02 +02:00
co-authored by Claude Sonnet 5.5
parent 2cc4f1b30d
commit d8c7defc78
2 changed files with 77 additions and 1 deletions
@@ -475,3 +475,60 @@ func TestProcessDir_KeepsANoteThatChangedWhileItWasBeingIngested(t *testing.T) {
require.NoError(t, err, "a note that changed during ingest must stay in raw/")
assert.Equal(t, "newer and longer content", string(got))
}
// hyperguild#198: an oversized note must not be read into memory. It is left in
// raw/ (visible, not lost), reported once, and not retried until it changes.
func TestProcessDir_ANoteOverTheSizeCapIsRefusedOnceAndKept(t *testing.T) {
brainDir := setupBrainDir(t)
big := filepath.Join(brainDir, "raw", "big.md")
require.NoError(t, os.WriteFile(big, []byte(strings.Repeat("x", 2048)), 0o644))
var calls atomic.Int32
cfg := Config{
BrainDir: brainDir,
MaxNoteBytes: 1024,
Pipeline: pipeline.Config{
Complete: func(ctx context.Context, s, u string) (string, error) {
calls.Add(1)
return successComplete(pipeline.RawPage{Title: "Big", Type: "source", Content: "x\n"})(ctx, s, u)
},
Schema: "# Schema",
},
}
first := processDir(context.Background(), cfg, "2026-10-20")
require.NotEmpty(t, first, "the refusal is reported")
assert.Contains(t, first[0].Error(), "too large")
second := processDir(context.Background(), cfg, "2026-10-20")
assert.Empty(t, second, "and reported once, not every poll")
assert.Zero(t, calls.Load(), "no extraction")
_, err := os.Stat(big)
assert.NoError(t, err, "the note stays in raw/")
entries, _ := os.ReadDir(filepath.Join(brainDir, "raw", "processed"))
assert.Empty(t, entries, "nothing was archived")
}
// hyperguild#199: a name near NAME_MAX cannot take a ".processed" suffix. Under
// inbox zero the marker is only the fallback when removal fails, and any failure
// there is remembered, so the note is still not handled every poll.
func TestProcessDir_ALongNameWhoseMarkerCannotBeWrittenIsNotHandledEveryPoll(t *testing.T) {
brainDir := setupBrainDir(t)
rawDir := filepath.Join(brainDir, "raw")
name := strings.Repeat("a", 250) + ".md" // 253 bytes; +".processed" exceeds 255
require.NoError(t, os.WriteFile(filepath.Join(rawDir, name), []byte("Content."), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(rawDir, "processed"), 0o755))
require.NoError(t, os.Chmod(rawDir, 0o555)) // removal fails, so the marker fallback runs
t.Cleanup(func() { _ = os.Chmod(rawDir, 0o755) })
cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{
Complete: successComplete(pipeline.RawPage{Title: "Long", Type: "source", Content: "x\n"}), Schema: "# Schema"}}
for i := 0; i < 3; i++ {
errs := processDir(context.Background(), cfg, "2026-10-20")
// Without the remember step the second poll tries to archive a second
// copy under a name that is too long and errors, every poll.
assert.Empty(t, errs, "poll %d handled the note again", i)
}
archived, err := filepath.Glob(filepath.Join(rawDir, "processed", "2026-10-20", "*"))
require.NoError(t, err)
assert.Len(t, archived, 1)
}