Repository navigation
keep ctags tags whose reply line is 4 KiB or longer - #32
Merged
Merged
Conversation
The scanner read ctags replies through a 4,096-byte bufio buffer and silently skipped every line that did not fit (from zoekt 5851b63). Parse then lost the tag with no error and no log. Since `scope` repeats the parent's full name, the children of a long-named type were lost too. Lines that do not fit in the buffer are now copied into a reused buffer, up to maxLineSize (1 MiB). Short lines still go through ReadSlice with no copy. Lines over the cap are skipped as before, and Parse logs one Info line per file when it skipped anything. Long lines that are not tags, or that do not decode, are still skipped instead of becoming errors, so Parse and New fail on nothing they accepted before. Unmarshal errors quote at most 200 bytes of the line. Scan clears `long` when it returns false, so that Parse returns an error, as before, when ctags dies in the middle of a long line. The new tests assert names, lines and parents rather than goldens, so they hold on ctags 6.0.0 and 6.2.1. fake_test.go lets the test binary stand in for ctags to script exact replies; writeFakeScript covers the cases it cannot (startup output, a reply cut short). Memory is bounded in the next commit. Refs sourcegraph#27 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reading long lines (previous commit) removes the only bound go-ctags had on the size of its output. Every child tag repeats its parent's full name in `scope`, so a small file can produce a huge reply: a 1 MB Python file with one class whose name is 1 MB long and 200 inner classes makes ctags print 401 MB. Zoekt keeps those strings until the shard is written. Two measures, both per Parse: - Parent names of minSharedParentLen (256) bytes or more are shared through a map, so N children of one long parent keep one copy of its name. Shorter names are not worth a map entry. On a 232 KB Go file with a 3.5 KB struct name and 20,000 fields (every line under 4 KiB) this cuts the retained heap from 85 MB to 3.5 MB. - Lines that do not fit in the read buffer count against a budget of max(1 MiB, 100 × len(content)) reply bytes, a shared parent counted once. Once a tag does not fit, Parse lowers the scanner's limit so that every later long line of that call is skipped without being decoded. Lines under 4 KiB never count, so Parse still returns every tag it did before the previous commit. The limits are unexported constants; ratio and minBudget are fields only so that tests can lower them. On ordinary files the Go side of Parse shows no measurable difference in time or allocations. Fixes sourcegraph#27 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a18240-4e65-7468-b990-c9c16c76124f Co-authored-by: Keegan Smith <keegan@sourcegraph.com> # Conflicts: # ctags_test.go
Zoekt keeps every file's tags in memory until the shard is written, so 100x let a single pathological 1 MB file hold on to 100 MB. The budget only counts lines that do not fit in the read buffer, so lowering it never drops a tag that was kept before long lines were read. Amp-Thread-ID: https://ampcode.com/threads/T-01a18240-4e65-7468-b990-c9c16c76124f Co-authored-by: Keegan Smith <keegan@sourcegraph.com>
keegancsmith
approved these changes
Oct 28, 2026
keegancsmith
left a comment
Member
There was a problem hiding this comment.
LGTM. I was at first concerned a bit about returning larger symbols and the impact of pathological files. However, the impact isn't that bad + I really like the fix around sharing parent strings.
I did push a commit to lower the heuristic limit though from 100 * len to 10 * len just to be on the safe side (eg think about 1mb files).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #27.
The scanner read each ctags reply line into a 4096-byte buffer and silently skipped lines that did not fit (from zoekt 5851b63). Parse then dropped the tag with no error and no log. Because
scoperepeats the parent's full name, the children of a long-named type were dropped too. A real case is corretto-8LongName.java(141,446 B): today Parse returns only the class, not its 140,000-character field.Two commits:
ReadSliceinto the 4096-byte buffer. Only lines that do not fit are copied into a reused buffer, so short lines cost nothing extra.maxLineSize(1 MiB) are skipped, as long lines were before.Options.Info:ctags skipping N long output lines for <file>.Debuglogger now sees long lines in full (up to 1 MiB).Parentstrings of 256 bytes or more are shared through a per-Parse map, so N children of one long parent keep one copy of its name, not N. Shorter parents are not worth a map entry.max(1 MiB, 100 × len(content)), a shared parent counted once. Once a tag does not fit, the remaining long lines of that Parse are skipped without being decoded. Short lines are never counted, so Parse returns every tag that main returns, plus the long ones.No API change. The limits are unexported constants.
Why not
ReadByteswith no limit, as the issue suggested? The 4 KiB skip was also the only bound on memory. A 1 MB Python file with one class whose name is 1 MB long and 200 inner classes makes ctags print 401 MB of reply. Every scope is a different string, so sharingParentcannot help, and Zoekt would hold those strings until the whole shard is written. With the budget (about 100.6 MB here), Parse keeps 100.9 MB of heap. The bound counts reply bytes, so heap can be somewhat higher: about 1.2× on the JS file below.Retained heap after Parse on recorded ctags replies, main → this PR:
s5.go, a 232 KB file with one struct whose name has about 3.5 KB and 20,000 fields (all lines under 4 KiB): 85.5 MB → 3.5 MB, thanks to the sharedParent.enum_ceiling.c, a 63 KB C file with one enum whose name has about 3.3 KB and 20,000 members (all reply lines under 4 KiB): 72.2 MB → 3.1 MB.On ordinary files (all lines under 4 KiB) the Go side of Parse shows no measurable difference in time or allocations from main (replayed ctags replies, benchstat, n=10).
Two things are left out on purpose. Happy to change either:
Optionsfields if Zoekt wants to tune them.Test Plan:
TestScannernow sets a small line cap: lines longer than the buffer are kept, lines over the cap are skipped. NewTestScannerBufferBoundarypins 4095/4096 B, andFuzzScannerchecks that lines come back in order and intact, every line under the cap comes back, and none over it (30 s locally, no failure).Parsetests use assertions, not goldens, so they do not depend on the ctags version: the issue's Go func and C function, a long Python class and Go struct with their children (checkingParent), 32 KiB and 100 KiB names, the 1 MiB cap, a sharedParenton long and on short lines, a lowered budget that drops long lines but keeps every short one, and the stop once a long tag does not fit. Others use a fake ctags (fake_test.go) or a small script: long lines that are not tags are skipped (in Parse and in New), and Parse returns an error instead of hanging when ctags dies in the middle of a long line.Newstarts.go vet,go test -race ./...on ctags 6.2.1 andgo test ./...on 6.0.0.2783f009 (the CI build, darwin)../gen.shleaves no diff.replaceto this branch:go test ./internal/ctags/... ./index/... ./cmd/zoekt-index/...passes.zoekt-index -require_ctagsonLongName.javaand the issue'ssample.go:sym:finds both long symbols (0 before), with no increase in max RSS or wall time.🤖 Generated with Claude Code