script/lint shares one golangci-lint cache across worktrees, so one tree's findings are served for another #99
Reference in New Issue
Block a user
Delete Branch "%!s()"
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?
Found while verifying PR #98. A new variant of #88, with a different
signature: no
parallel golangci-lint is runningmessage appears, sothe existing VOID heuristic does not catch it.
What happened
make lintfrom a worktree at/tmp/impl-93reported 231 issues.Every one of the 232 finding lines cited a path under
../impl-91/— adifferent worktree on the same host, belonging to another session. Not
one finding referred to a file in the tree being linted. Example lines:
Re-running with an isolated
XDG_CACHE_HOMEand nothing else changed:0 issues, 0 foreign paths.
Mechanism
script/lintmounts only its own$ROOTat/src, so the containercannot see the other worktree's files. The leak is the cache, not the
mount:
That path is shared by every worktree of this repo on the host. Two
worktrees of the same repo have identical Go file contents, so their
cache keys collide, and golangci-lint replays the stored analysis
results — including the paths recorded when they were produced, which is
why the other worktree's
../impl-91/paths surface verbatim.Why this matters more than it looks
The failure is symmetric, and only one direction is loud:
investigated.
from a lint run that analysed someone else's code is a sixth way for a
gate in this repo to report a green it did not earn — alongside #78,
#80, #85, #88, #93.
The absence of a
parallel golangci-lint is runningmessage means the#88 VOID rule as currently stated does not reject these runs. The
signature to add is: any finding citing a path outside the tree being
linted, and any run whose findings are entirely foreign, is void.
Not affected
The containerised lint inside
script/cibuildis structurally immune —the lint stage runs in the image with its own cache and no shared volume
— and passed on the same tree.
Possible directions
Not prescribing one;
script/lintis currently treated as off-limitsunder #78/#80/#88, so this needs a deliberate decision.
$ROOT)so worktrees cannot collide. Keeps the warm-cache speed that the
comment in
script/lintsays the shared cache exists for, at thecost of one cache per worktree.
script/lintfailloudly if any reported path lies outside
$ROOT. Cheap, and it turnsthe silent-pass direction into a hard error.
Option 3 is worth doing regardless of which of the others is chosen,
since it converts an unearned green into a failure.
Reproducing
Needs two worktrees of this repo on one host with differing lint
cleanliness, then
make lintfrom the clean one.Implementation plan (this issue is being fixed together with #80 in one
PR, branch
fix-lint-isolation, since both are the same defect inscript/lint: the native path is gated on version equality rather thanexecution context, and cache isolation is part of that context).
For this issue:
Per-worktree cache key.
cache_root()becomes${XDG_CACHE_HOME:-~/.cache}/vaultik-lint/<basename>-<hash of $ROOT>, so two worktrees can no longer collide. The cache stayspersistent per worktree, so the warm-run speed the shared cache
existed for is kept.
Foreign-path guard (the item that matters most).
script/lintstops
execing the linter and instead captures its output (stillstreamed live via
tee), then rejects the run if any finding cites apath that is not inside the tree being linted: absolute paths outside
the root, any path with a
..component, and any relative path thatdoes not exist under
$ROOT. That last one also catches a foreignpath that happens to lack
... A run that trips this exits non-zerowith a diagnostic naming the cache directory and how to clear it —
which converts the silent-pass direction into a hard error regardless
of whether (1) holds.
parallel golangci-lint is runningbecomes a retry, not averdict. Per the empirical finding in #88 a private cache does not
remove lock contention. That message is not a lint result, and
surfacing it as a non-zero exit is indistinguishable to a caller from
real findings. It will be retried a bounded number of times with a
backoff, and if the retries are exhausted the script fails with a
message that says explicitly that this is not a lint verdict.
Bounded cache growth. Per-worktree cache directories carry a
marker file recording the worktree path; on each run, sibling cache
directories whose recorded worktree no longer exists are removed.
Since worktrees under
/tmpare the ones that proliferate, thiscollects them as they disappear. The location and how to clear it by
hand get documented.
Verification will be the actual reproduction, not inspection: two
concurrent
make lintruns from two worktrees of this repo withdiffering lint cleanliness, confirming each reports only its own
findings and neither dies on the parallel-run error; plus a constructed
run whose findings cite an outside path, confirming a non-zero exit.
.golangci.ymlis not touched (sha256 verified before push), nor is thelint-stage
FROMline or theCHECK_EPOCHstructure.Implemented in PR #102 (branch
fix-lint-isolation, one commit,together with #80 since they are the same defect).
What was built
${XDG_CACHE_HOME:-~/.cache}/vaultik-lint/<slug>-<12 hex of a digest of $ROOT>. Still persistent, so the warm-run speed the shared cacheexisted for is kept; a collision between two checkouts is no longer
possible.
script/lint-audit, run on every lint, rejects output citing anyfile that is not in the tree being linted — absolute paths outside
the root,
..components, and relative paths that do not exist here.It runs on clean output too, since the unearned-pass direction is
the silent one. It deliberately never certifies that a run passed: it
does not look at whether there were findings, so it cannot become a
second gate that reports a green. It is a separate script rather than
an inline function so it can be exercised directly against a saved
capture, which is how it was verified.
parallel golangci-lint is runningis now a bounded retry (6attempts, 15s apart), not a verdict. Exhausting the retries fails
with a message stating the tree was never analysed. Confirmed the
#88 reading that this is orthogonal to cache privacy.
--allow-parallel-runnerswas rejected as the alternative: itdisables the lock that protects a cache shared by two runs of the
same worktree, trading a delay for corruption.
next run once that worktree is gone, so throwaway worktrees do not
accumulate caches. The whole tree is under
XDG_CACHE_HOMEanddisposable; the removal command appears in the script comment and in
the rejection message.
How it was verified (reproduction, not inspection)
/tmp/impl-99(clean) and/tmp/impl-99-dirty(identical tree plus one file carrying areviveand an
unusedfinding),make lintstarted in both at once. Clean:0 issues., exit 0. Dirty: exactly 2 findings, both citinginternal/blobgen/lintbait.goin its own tree, exit 2. Neither logcontained
parallel golangci-lint is running. No leakage in eitherdirection.
script/lintrun in thedirty worktree made to report outside paths reproduced this issue's
signature (
../impl-91/internal/blobgen/lintbait.go) and exited 1with the REJECTED diagnostic rather than reporting the findings. The
auditor was additionally run against the verbatim output quoted in
this issue (exit 1), a foreign path with no
..component (exit 1), areal clean capture (exit 0), and a container-absolute
/src/...path(exit 0).
the capture plus the audit scan.
make checkexit 0 with 14oklines, zero
(cached),0 issues..script/cibuildexit 0.One limitation, stated rather than papered over:
script/lintbind-mounts every tree at
/src, so two containerized runs of differentcheckouts both call themselves
/srcand contamination between them isnot distinguishable by path alone. That is why the per-worktree cache
key, not the audit, is the primary fix; the audit catches the signature
observed here and any host-side path leakage. It is documented at the
top of
script/lint-audit.