Skip to content

Commit 47ace27

Browse files
author
merge-queue-bot
committed
Merge PR #764: perf: fix top 5 high-performance-go.md violations found by codebase audit
2 parents a08e800 + daae490 commit 47ace27

13 files changed

Lines changed: 467 additions & 47 deletions

File tree

internal/corpus/collect.go

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99
"path/filepath"
1010
"strings"
1111
"unicode/utf8"
12+
13+
"github.com/jeduden/mdsmith/internal/bytelimit"
1214
)
1315

1416
// Collect gathers markdown records from configured sources.
@@ -174,9 +176,35 @@ func collectFile(
174176
return Record{}, false, nil
175177
}
176178

177-
content, err := os.ReadFile(fullPath)
179+
// Skip a file this function cannot read — oversized, vanished, or
180+
// permission-denied — rather than failing the whole source:
181+
// collectFromRoot's caller aborts the entire walk (and every record
182+
// already collected from every source before it, since Collect
183+
// returns on the first error) on any error from this function. A
184+
// cloned third-party repository can legitimately contain one
185+
// oversized or racy file (a large CHANGELOG, a vendored spec, a file
186+
// removed mid-walk) without that file being reason to discard the
187+
// rest of the corpus build.
188+
info, err := os.Stat(fullPath)
189+
if err != nil {
190+
reportProgress(cfg, fmt.Sprintf("skipping %s: %v", relPath, err))
191+
return Record{}, false, nil
192+
}
193+
if info.Size() > bytelimit.DefaultMaxInputBytes {
194+
reportProgress(cfg, fmt.Sprintf(
195+
"skipping %s: %d bytes exceeds the %d byte limit",
196+
relPath, info.Size(), bytelimit.DefaultMaxInputBytes))
197+
return Record{}, false, nil
198+
}
199+
200+
// bytelimit.ReadFileLimited re-checks the size on the actual read,
201+
// so a file that grows past the cap in the window between the Stat
202+
// above and this call (or otherwise becomes unreadable) is caught
203+
// here too — skipped the same way, not treated as fatal.
204+
content, err := bytelimit.ReadFileLimited(fullPath, bytelimit.DefaultMaxInputBytes)
178205
if err != nil {
179-
return Record{}, false, fmt.Errorf("read file %s: %w", fullPath, err)
206+
reportProgress(cfg, fmt.Sprintf("skipping %s: %v", relPath, err))
207+
return Record{}, false, nil
180208
}
181209
raw := normalizeContent(string(content))
182210
words := countWords(raw)

internal/corpus/collect_test.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package corpus
22

33
import (
4+
"bytes"
45
"os"
56
"path/filepath"
67
"testing"
8+
9+
"github.com/jeduden/mdsmith/internal/bytelimit"
710
)
811

912
func TestCollect_HappyPath(t *testing.T) {
@@ -207,6 +210,144 @@ func TestCollect_ErrorPath(t *testing.T) {
207210
}
208211
}
209212

213+
// TestCollect_OversizedFile_SkippedNotFatal guards against an unbounded
214+
// os.ReadFile on a corpus source: collectFile ingests markdown from
215+
// cloned third-party repositories, which are untrusted input
216+
// (docs/development/high-performance-go.md — "os.ReadFile on huge
217+
// inputs: one giant alloc, all resident"). A file over the shared
218+
// bytelimit.DefaultMaxInputBytes cap must be skipped rather than read
219+
// into memory in full — and, since a real source repository can contain
220+
// one large file among many good ones (a big CHANGELOG, a vendored
221+
// spec), skipping it must not abort collection of the rest of that
222+
// source, or of sources collected *earlier* in the same run (Collect's
223+
// loop over cfg.Sources returns on the first error, discarding every
224+
// record gathered so far — so this uses two sources, not one, to prove
225+
// the first source's record survives a later source's oversized file).
226+
func TestCollect_OversizedFile_SkippedNotFatal(t *testing.T) {
227+
t.Parallel()
228+
229+
const prose = "# Title\n\nword word word word word word\n"
230+
231+
goodRoot := filepath.Join(t.TempDir(), "good")
232+
mustMkdirAll(t, goodRoot)
233+
mustWriteFile(t, filepath.Join(goodRoot, "early.md"), []byte(prose))
234+
235+
mixedRoot := filepath.Join(t.TempDir(), "mixed")
236+
mustMkdirAll(t, mixedRoot)
237+
oversized := bytes.Repeat([]byte("a "), int(bytelimit.DefaultMaxInputBytes)/2+1)
238+
mustWriteFile(t, filepath.Join(mixedRoot, "huge.md"), oversized)
239+
mustWriteFile(t, filepath.Join(mixedRoot, "normal.md"), []byte(prose))
240+
241+
cfg := &Config{
242+
CollectedAt: "2026-02-16",
243+
MinWords: 1,
244+
MinChars: 1,
245+
LicenseAllowlist: []string{"MIT"},
246+
Sources: []SourceConfig{
247+
{
248+
Name: "early",
249+
Repository: "github.com/acme/early",
250+
Root: goodRoot,
251+
CommitSHA: "abc123",
252+
License: "MIT",
253+
},
254+
{
255+
Name: "mixed",
256+
Repository: "github.com/acme/mixed",
257+
Root: mixedRoot,
258+
CommitSHA: "def456",
259+
License: "MIT",
260+
},
261+
},
262+
}
263+
264+
records, err := Collect(cfg, t.TempDir())
265+
if err != nil {
266+
t.Fatalf("Collect: unexpected error, oversized file should be skipped: %v", err)
267+
}
268+
if len(records) != 2 {
269+
t.Fatalf("record count = %d, want 2 (early.md from the first source, "+
270+
"normal.md from the second; huge.md must be skipped)", len(records))
271+
}
272+
paths := make([]string, len(records))
273+
for i, r := range records {
274+
paths[i] = r.Source + "/" + r.Path
275+
}
276+
if paths[0] != "early/early.md" || paths[1] != "mixed/normal.md" {
277+
t.Fatalf("records = %v, want [early/early.md mixed/normal.md]", paths)
278+
}
279+
}
280+
281+
// mustMkdirAll creates dir and all parents, failing the test on error.
282+
func mustMkdirAll(t *testing.T, dir string) {
283+
t.Helper()
284+
if err := os.MkdirAll(dir, 0o755); err != nil {
285+
t.Fatalf("mkdir %s: %v", dir, err)
286+
}
287+
}
288+
289+
// mustWriteFile writes content to path, failing the test on error.
290+
func mustWriteFile(t *testing.T, path string, content []byte) {
291+
t.Helper()
292+
if err := os.WriteFile(path, content, 0o644); err != nil {
293+
t.Fatalf("write %s: %v", path, err)
294+
}
295+
}
296+
297+
// TestCollectFile_StatError_SkippedNotFatal covers collectFile's os.Stat
298+
// error branch directly: a file that vanishes between WalkDir listing it
299+
// and the Stat call inside collectFile (or any other stat failure) must
300+
// be skipped, not treated as fatal — the same reasoning as the oversized-
301+
// file case above.
302+
func TestCollectFile_StatError_SkippedNotFatal(t *testing.T) {
303+
t.Parallel()
304+
305+
root := t.TempDir()
306+
missing := filepath.Join(root, "gone.md")
307+
308+
cfg := &Config{MinWords: 1, MinChars: 1}
309+
record, keep, err := collectFile(cfg, SourceConfig{Name: "seed"}, missing, "gone.md", root)
310+
if err != nil {
311+
t.Fatalf("collectFile: unexpected error for a stat failure: %v", err)
312+
}
313+
if keep {
314+
t.Fatal("collectFile: keep = true, want false for a stat failure")
315+
}
316+
if record != (Record{}) {
317+
t.Fatalf("collectFile: record = %+v, want zero value", record)
318+
}
319+
}
320+
321+
// TestCollectFile_ReadError_SkippedNotFatal covers collectFile's fallback
322+
// bytelimit.ReadFileLimited error branch directly: a path that passes the
323+
// Stat-based size pre-check but then fails to read must be skipped, not
324+
// treated as fatal — the same reasoning as the stat-failure and
325+
// oversized-file cases above. A directory Stats successfully (size 0,
326+
// under the cap) but fails to Read as a file ("is a directory"),
327+
// deterministically reaching this branch regardless of the running
328+
// user's privileges (unlike a permission-bit test, which root ignores).
329+
func TestCollectFile_ReadError_SkippedNotFatal(t *testing.T) {
330+
t.Parallel()
331+
332+
root := t.TempDir()
333+
notAFile := filepath.Join(root, "not-a-file.md")
334+
if err := os.Mkdir(notAFile, 0o755); err != nil {
335+
t.Fatalf("mkdir: %v", err)
336+
}
337+
338+
cfg := &Config{MinWords: 1, MinChars: 1}
339+
record, keep, err := collectFile(cfg, SourceConfig{Name: "seed"}, notAFile, "not-a-file.md", root)
340+
if err != nil {
341+
t.Fatalf("collectFile: unexpected error reading a directory as a file: %v", err)
342+
}
343+
if keep {
344+
t.Fatal("collectFile: keep = true, want false when the read fails")
345+
}
346+
if record != (Record{}) {
347+
t.Fatalf("collectFile: record = %+v, want zero value", record)
348+
}
349+
}
350+
210351
// --- reportProgress ---
211352

212353
// TestReportProgress pins all three branches: nil cfg is a no-op,

internal/linkgraph/linkgraph.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -251,10 +251,10 @@ func linkPosition(f *lint.File, n ast.Node) (int, int) {
251251
if offset < 0 {
252252
return 1, 1
253253
}
254-
// f.ColumnOfOffset scans backward from offset to the previous
255-
// newline, so it's O(column) per call instead of the O(offset)
256-
// forward scan a hand-rolled version would do — meaningful for
257-
// `mdsmith list backlinks` which can call this many times per file.
254+
// f.ColumnOfOffset binary-searches the cached newline index, so
255+
// it's O(log lines) per call instead of the O(column) backward scan
256+
// a hand-rolled version would do — meaningful for `mdsmith list
257+
// backlinks` which can call this many times per file.
258258
return f.LineOfOffset(offset), f.ColumnOfOffset(offset)
259259
}
260260

internal/lint/column_bench_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package lint
2+
3+
import (
4+
"bytes"
5+
"testing"
6+
)
7+
8+
// BenchmarkColumnOfOffset_LongLine exercises the former worst case for
9+
// ColumnOfOffset: an offset near the end of a very long line, which used
10+
// to force a full backward byte-at-a-time scan to find the line start.
11+
// ColumnOfOffset now reuses LineOfOffset's cached newline index via a
12+
// binary search instead (docs/development/high-performance-go.md).
13+
func BenchmarkColumnOfOffset_LongLine(b *testing.B) {
14+
line := bytes.Repeat([]byte("x"), 8192)
15+
src := append(append([]byte("prefix\n"), line...), '\n')
16+
f := &File{Source: src}
17+
offset := len(src) - 2 // last byte of the long line
18+
19+
b.ReportAllocs()
20+
b.ResetTimer()
21+
for i := 0; i < b.N; i++ {
22+
_ = f.ColumnOfOffset(offset)
23+
}
24+
}

internal/lint/file.go

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -562,23 +562,9 @@ var lineIndexNewline = []byte{'\n'}
562562
// strictly before offset (a newline exactly at offset starts the
563563
// next line, so it does not count) — identical to a linear scan,
564564
// but O(log n) via binary search over the cached newline index.
565-
// The search is inlined (sort.Search would force the comparison
566-
// callback to capture `nl` and `offset` and escape to the heap;
567-
// engine-bench profiling attributed ~64 k allocations per
568-
// 10-iteration run to that closure box before plan 195 inlined
569-
// the binary search here).
570565
func (f *File) LineOfOffset(offset int) int {
571566
nl := f.lineIndex()
572-
lo, hi := 0, len(nl)
573-
for lo < hi {
574-
mid := int(uint(lo+hi) >> 1)
575-
if nl[mid] >= offset {
576-
hi = mid
577-
} else {
578-
lo = mid + 1
579-
}
580-
}
581-
return 1 + lo
567+
return 1 + newlineSearch(nl, offset)
582568
}
583569

584570
// ColumnOfOffset converts a byte offset in Source to a 1-based column
@@ -587,9 +573,38 @@ func (f *File) ColumnOfOffset(offset int) int {
587573
if offset > len(f.Source) {
588574
offset = len(f.Source)
589575
}
590-
start := offset
591-
for start > 0 && f.Source[start-1] != '\n' {
592-
start--
576+
if offset < 0 {
577+
offset = 0
578+
}
579+
// Reuses LineOfOffset's cached newline index via the same binary
580+
// search instead of scanning backward from offset byte by byte —
581+
// O(log n) in the newline count instead of O(line length). A single
582+
// very long line (a minified table, a long URL list) used to make
583+
// every diagnostic on that line pay for a full backward scan.
584+
nl := f.lineIndex()
585+
lo := newlineSearch(nl, offset)
586+
start := 0
587+
if lo > 0 {
588+
start = nl[lo-1] + 1
593589
}
594590
return offset - start + 1
595591
}
592+
593+
// newlineSearch returns the index of the first entry in nl (a sorted
594+
// list of newline byte offsets) that is >= offset, or len(nl) if none.
595+
// Inlined rather than sort.Search: sort.Search's comparison closure
596+
// would capture nl and offset and escape to the heap (engine-bench
597+
// profiling attributed ~64 k allocations per 10-iteration run to that
598+
// closure box before plan 195 inlined the binary search here).
599+
func newlineSearch(nl []int, offset int) int {
600+
lo, hi := 0, len(nl)
601+
for lo < hi {
602+
mid := int(uint(lo+hi) >> 1)
603+
if nl[mid] >= offset {
604+
hi = mid
605+
} else {
606+
lo = mid + 1
607+
}
608+
}
609+
return lo
610+
}

internal/lint/lint_coverage_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,14 @@ func TestColumnOfOffset_PastEOFClamps(t *testing.T) {
280280
assert.Equal(t, 4, f.ColumnOfOffset(999))
281281
}
282282

283+
func TestColumnOfOffset_NegativeOffsetClamps(t *testing.T) {
284+
// A negative offset clamps to the start of the file (column 1),
285+
// mirroring the upper-bound EOF clamp above.
286+
f := &File{Source: []byte("line1\nline2\n")}
287+
assert.Equal(t, 1, f.ColumnOfOffset(-1))
288+
assert.Equal(t, 1, f.ColumnOfOffset(-100))
289+
}
290+
283291
func TestColumnOfOffset_AtNewline(t *testing.T) {
284292
// The newline itself sits at the end of its line.
285293
f := &File{Source: []byte("ab\nc")}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package tablefmt
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
)
8+
9+
// wideTableLines builds a table with n data rows, used to make append
10+
// slice-growth in tryParseTable/findTables visible in an alloc count.
11+
func wideTableLines(n int) [][]byte {
12+
lines := [][]byte{
13+
[]byte("| Col A | Col B |"),
14+
[]byte("| --- | --- |"),
15+
}
16+
for i := 0; i < n; i++ {
17+
lines = append(lines, []byte("| a | b |"))
18+
}
19+
return lines
20+
}
21+
22+
// TestFindTables_PreSizedRowSlices pins the allocation count for parsing
23+
// one large table. rawLines and rows in tryParseTable used to grow via
24+
// plain append with no capacity hint, paying several slice-growth
25+
// reallocs per table (docs/development/high-performance-go.md); a
26+
// pre-sizing pass ahead of the capture loop removes them.
27+
func TestFindTables_PreSizedRowSlices(t *testing.T) {
28+
lines := wideTableLines(50)
29+
codeLines := map[int]struct{}{}
30+
31+
allocs := testing.AllocsPerRun(50, func() {
32+
tables := findTables(lines, codeLines)
33+
require.Len(t, tables, 1)
34+
})
35+
t.Logf("findTables allocs/op for a 52-row table = %.0f", allocs)
36+
// Most of the remaining allocs come from splitRowBytes's per-row
37+
// cells slice, unrelated to this fix. Before pre-sizing
38+
// rawLines/rows in tryParseTable, this fixture measured 174
39+
// allocs/op; pre-sizing removed the slice-growth reallocs on both
40+
// slices, dropping it to 162.
41+
require.LessOrEqualf(t, allocs, float64(162),
42+
"findTables allocs/op = %.0f; want <= 162 (rawLines+rows pre-sized "+
43+
"in tryParseTable)", allocs)
44+
}

0 commit comments

Comments
 (0)