build: isolate golangci-lint cache and lock per checkout (closes #121) #128
Reference in New Issue
Block a user
Delete Branch "fix/121-lint-cache-isolation"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Where the lock lives
golangci-lint v2.12.2 (
pkg/commands/run.go,acquireFileLock):$TMPDIR/golangci-lint.lock, i.e./tmp/golangci-lint.lockwhenTMPDIRis unset. Not in the cache directory, which is why an isolatedGOLANGCI_LINT_CACHEdid not stop the collision PR #122 hit. It is aflockwith a 1s retry and a 5s total timeout, so any run whose peer holds it longer than 5s aborts.Fix
script/lintsets both, derived from the existing$ROOTidiom:GOLANGCI_LINT_CACHE="$ROOT/.lint-cache/cache"— cache is never shared, so no run can be served another checkout's findings (1a).TMPDIR="$ROOT/.lint-cache/tmp"— moves the lock file per checkout (1b). This scopes the lock rather than removing it, so it still serialises runs that genuinely share one cache.--allow-parallel-runnerswas the alternative and was rejected: it deletes the guard instead of scoping it..lint-cache/added to.gitignoreand.dockerignore..golangci.ymluntouched (sha256 still021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb), linter pin untouched, no check suppressed,script/lintstill POSIXshandsh -nclean.Verification
Reproduction used 12 (later 20) copies of this tree under
/tmp, each a full checkout, runningmake lintconcurrently.Unfixed script:
parallel golangci-lint is running.../w1/internal/lintprobe/probe.go— another checkout's path, for a file they did not lint. Onlyw1, which populated the shared cache, reported its own path.Fixed script: 20 concurrent runs, cold caches — 0 void, 0 foreign paths. Repeating with the lint-failing file present, all 12 copies reported exactly their own
internal/lintprobe/probe.goand no../path.Docker: real build,
docker build --no-cache-filter=builder ., chosen overscript/cibuildbecause that takes no arguments and can report a false green on a byte-identical tree (#115). The builder stage executed rather than being reused (go install golangci-lint53.8s,RUN make check51.8s, notCACHED); lint reported0 issuesinside the container with no lock error.HOMEandTMPDIRdiffer there and the fix does not depend on either — the path comes from the checkout root.Timing: warm run ~1.7–2.2s, against ~0.8s before; the gap is host scheduling noise, not the change — user CPU time went down (2.0s vs 2.6s). The real cost is that the first run in a fresh checkout is now cold, ~17s, because the cache is no longer shared across checkouts. That is inherent to the fix. Cache is ~80MB per checkout.
make checkgreen (GOFLAGS=-count=1), tree unmodified afterwards,TODO.mdupdated in the same commit.Anomalies
gomodguarddeprecation warning still appears on every lint run. Pre-existing and already recorded as accepted inTODO.md.watchertests logfailed to save state error="writing temp state file: open /state.json.tmp: permission denied"repeatedly while passing. Pre-existing, unrelated to this change, and out of scope here — flagging it rather than filing, since I cannot tell whether it is deliberate error-path coverage or tests falling back to the default data directory.Built:
script/lintnow exportsGOLANGCI_LINT_CACHEandTMPDIRunder a git-ignored, Docker-ignored.lint-cache/in the checkout root. The cache var closes the cross-contamination half;TMPDIRis the half that actually matters for the lock, because golangci-lint locksos.TempDir()/golangci-lint.lock, not anything under the cache directory.Verified: reproduced both failure modes on the unfixed script (10 of 12 concurrent runs void with
parallel golangci-lint is running; 11 of 12 runs reporting../w1/...paths for a file they never linted), then 0 of 20 void and 0 foreign paths after. Real Docker build with the builder stage forced to execute, lint0 issuesinside the container.make checkgreen withGOFLAGS=-count=1, tree unmodified after. Full detail and the timing numbers are in the PR description.Two pre-existing anomalies are called out at the bottom of the PR description; neither is caused by this change.
Review: PASS
Independent review of
520ce79. No blocking findings.Lock claim verified against the pinned source
The pin
c0d3ddc9cf3faa61a4e378e879ece580256d76e5resolves tov2.12.2(@v/v2.12.2.info,Origin.Hash), and that source says exactly what the PR says:pkg/commands/run.go:492lockFile := filepath.Join(os.TempDir(), "golangci-lint.lock"),flock,retryDelay = time.Second(:495),totalTimeout = 5 * time.Second(:499). Path, mechanism and both timings are correct, and the lock is demonstrably not underGOLANGCI_LINT_CACHE.Concurrency test (my own, 12 fresh copies of this tree)
parallel golangci-lint is runninglllviolation in each copy../u4/internal/lintprobe/probe.go— another copy's pathinternal/lintprobe/probe.go; 0 lock errors, 0 foreign pathsrc=0,0 issues., no lock error, no foreign path, while 9/12 unfixed peers went voidThe reproduction lands before the fix and disappears after it, including with the shared
/tmplock actively thrashed by peers. Both halves of the amended definition of done are met.Timings here: cold 8.1s, warm 1.3s, cache 80MB — consistent with the PR's numbers.
Everything else checked
make checkrc=0andgit status --porcelainempty afterwards;.lint-cache/git-ignored and Docker-ignored;docker build --progress=plain --no-cache-filter=builder --target builder .(direct docker, stated per policy) ran the builder for real —RUN make checkDONE 38.6s, notCACHED, lint0 issuesinside the container withTMPDIR=/src/.lint-cache/tmp;.golangci.ymlsha256 matches; pin unchanged inDockerfileandscript/bootstrap; no linter disabled or narrowed; no test file touched; POSIXsh,sh -nclean, no bashisms,$ROOTidiom kept; CI green on head (54s, real); base is currentorigin/main; commit title ends(closes #121)withTODO.mdin the same commit; no Claude/Anthropic references or attribution trailers anywhere in9347a28..520ce79.Non-blocking findings
1. The lock still aborts rather than serialises, within a single checkout — and the script comment says otherwise. Two concurrent
make lintin the same checkout with a cold cache: one exitsrc=0, the otherrc=2withparallel golangci-lint is running(measured). Realistic trigger:script/precommitfiring while amake checkis already linting the same worktree — a cold run is 8-17s, well past the 5s timeout. This is much narrower than the pre-fix behaviour and is not a regression, butscript/lint's comment ("keeps the lock doing its actual job (serialising runs that share one cache)") overstates it: after 5s the loser is aborted, not queued.The lever that would make that sentence true is
--allow-serial-runners, not--allow-parallel-runners:pkg/commands/flagsets.go:59bindsrun.allow-serial-runners, andrun.go:498uses it to skip the 5scontext.WithTimeout, so the second run blocks on the flock until the first releases instead of aborting. It keeps the guard, unlike--allow-parallel-runners. The PR description and issue comment 49933 both state that--allow-parallel-runners"is the only other escape" — that is incorrect and worth correcting in the record even if the flag is not adopted.2.
make cleandoes not remove.lint-cache/.Makefile:43isrm -rf bin/. 80MB per checkout, no automatic trimming, on a host that spawns many throwaway worktrees.rm -rf bin/ .lint-cache/would close it.3. Naming nit:
.lint-cache/cachestutters, and the directory holdstmpas well as a cache. Something like.lint-state/{cache,tmp}reads better. Not worth a respin on its own.Anomaly that passes anyway
TMPDIRnow points inside the tree being linted. That is safe only because the Go tool skips directories whose names begin with.when expanding./...— confirmed: no run reported anything under.lint-cache/. It is a load-bearing property of the leading dot, so renaming the directory tolint-cache/later would silently start feeding temp files to the linter. Worth a word in the comment if anyone touches the name.The two pre-existing anomalies the author flagged (the
gomodguarddeprecation warning, and thewatchertests loggingpermission deniedon/state.json.tmpwhile passing) both reproduce for me on unmodifiedmainand inside the container; neither is caused by this change.[manager] Independent review PASS, no blocking findings.
merge-ready, assigned to @sneak.Lock path independently confirmed against the pinned source (
run.go:492, flock, 1s retry, 5s timeout). Reviewer's own 24-way probe — 12 fixed and 12 unfixed runs launched together — had all 12 fixed runs clean while 9/12 unfixed peers went void, so the fix is immune to peers thrashing the shared/tmplock. Constraints all verified clean.One correction to record, because I propagated the error upstream. The PR body and my comment on prompts #30 both say
--allow-parallel-runnerswas "the alternative". That is wrong:--allow-serial-runnersalso exists (flagsets.go:59, consumed atrun.go:498to skip the timeout), and it is the better lever for the residual case — it keeps the guard and makes overlapping runs queue rather than abort. I have corrected the upstream comment, since that repo is actively writing the canonical fix and would otherwise inherit the mistake.Follow-ups filed as #130, neither blocking: two concurrent
make lintwithin one checkout still abort after 5s rather than queueing (trigger:script/precommitoverlapping amake check), andmake cleandoes not remove.lint-cache/, which accumulates ~80MB per throwaway worktree unbounded.Anomaly worth carrying forward:
TMPDIRnow lives inside the linted tree, and that is safe only because the Go tool skips dot-prefixed directories when expanding./.... The leading dot is load-bearing — renaming tolint-cache/would silently start feeding temp files to the linter.build: isolate golangci-lint cache and lock per checkout (closes #121)to WIP: build: isolate golangci-lint cache and lock per checkout (closes #121)WIP: build: isolate golangci-lint cache and lock per checkout (closes #121)to build: isolate golangci-lint cache and lock per checkout (closes #121)View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.