1 Commits

Author SHA1 Message Date
520ce79709 build: isolate golangci-lint cache and lock per checkout (closes #121)
All checks were successful
check / check (push) Successful in 54s
script/lint used golangci-lint's per-user global state, which breaks
when several checkouts on one host lint concurrently. Two independent
failure modes, two causes:

- Cross-contamination. The analysis cache (GOLANGCI_LINT_CACHE,
  default ~/.cache/golangci-lint) is keyed by content, not by
  checkout, so a hit written by another checkout is replayed with
  that checkout's file paths. A run reports findings for files it
  never linted.

- Lock collision. golangci-lint locks os.TempDir()/golangci-lint.lock
  (pkg/commands/run.go, acquireFileLock), which is NOT in the cache
  directory, with a 5s timeout. Peers that hold it longer make the
  run abort with "parallel golangci-lint is running" - a non-result
  that reads as a lint failure. Isolating the cache does not move it.

Point GOLANGCI_LINT_CACHE and TMPDIR at .lint-cache/ under the
checkout root, using the existing $ROOT idiom. TMPDIR is what makes
the lock per-checkout, so the lock keeps serialising the runs that
actually share a cache instead of being disabled. .lint-cache/ is
git-ignored and Docker-ignored, and the cache persists across runs in
a checkout, so caching is not lost.

Reproduced both modes on the unfixed script across 12 copies of this
tree: 10 of 12 concurrent runs void with the lock error, and 11 of 12
sequential runs reported findings at ../w1/... after an identical
lint-failing file was added to every copy. After the fix, 20-way
concurrency gives 0 void and 0 foreign paths, and each copy reports
only its own relative path.
2026-08-09 14:35:42 +00:00
7 changed files with 50 additions and 42 deletions

View File

@@ -1,5 +1,6 @@
.git/ .git/
bin/ bin/
.lint-cache/
*.md *.md
LICENSE LICENSE
.editorconfig .editorconfig

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
bin/ bin/
vendor/ vendor/
data/ data/
.lint-cache/
.env .env
*.exe *.exe
/dnswatcher /dnswatcher

View File

@@ -15,25 +15,8 @@ RUN go mod download
COPY . . COPY . .
# Run all checks - build fails if any check fails. # Run all checks - build fails if any check fails
# RUN make check
# CHECK_EPOCH is a cache-busting build argument. Without it, an
# unchanged tree leaves this layer's cache key identical and Docker
# serves the previous verdict instead of re-running the suite, so the
# build reports a green it did not earn. A build argument's value
# participates in the cache key of later instructions in the stage even
# when they do not reference it, so a fresh value busts this layer
# either way. It is expanded into the command deliberately: that makes
# the invalidation a property of the command string itself rather than
# of how a given builder treats unreferenced args, and it surfaces the
# epoch in the build log as a diagnostic.
#
# Placing the ARG here and nowhere earlier keeps everything above it
# (toolchain install, go mod download) cached, so only the check and the
# steps after it re-run. script/cibuild passes a fresh value per run; a
# plain `docker build` without it caches as before.
ARG CHECK_EPOCH
RUN echo "check epoch: ${CHECK_EPOCH}" && make check
# Build the binary # Build the binary
RUN make build RUN make build

View File

@@ -387,16 +387,16 @@ them. We provide:
- `script/projectname` — print the project name (used for the Docker - `script/projectname` — print the project name (used for the Docker
image tag) image tag)
- `script/test` — run the test suite (race detector, coverage) - `script/test` — run the test suite (race detector, coverage)
- `script/lint` — run golangci-lint - `script/lint` — run golangci-lint, with its cache and its lock file
isolated to this checkout (under the git-ignored `.lint-cache/`) so
concurrent checkouts on one host cannot share cache entries or
contend on a single lock
- `script/fmt` — format all code (gofmt -s, goimports) - `script/fmt` — format all code (gofmt -s, goimports)
- `script/fmt-check` — check formatting (read-only) - `script/fmt-check` — check formatting (read-only)
- `script/check` — run test, lint, and fmt-check - `script/check` — run test, lint, and fmt-check
- `script/docker` — build the Docker image tagged via - `script/docker` — build the Docker image tagged via
`script/projectname` `script/projectname`
- `script/cibuild` — CI entrypoint: `docker build .` with a fresh - `script/cibuild` — CI entrypoint: plain `docker build .`
`CHECK_EPOCH` build argument, so the Dockerfile's `make check` layer
is never served from the cache and a green build always means the
checks ran on this invocation
- `script/precommit` — run by the git pre-commit hook; `go mod tidy` - `script/precommit` — run by the git pre-commit hook; `go mod tidy`
guard, then `script/check` guard, then `script/check`
- `script/install-precommit` — install the git pre-commit hook - `script/install-precommit` — install the git pre-commit hook

19
TODO.md
View File

@@ -25,16 +25,15 @@ confirm make check still passes.
# Completed Steps # Completed Steps
- 2026-08-09: `script/cibuild` can no longer report a green it did not - 2026-08-09: `script/lint` now isolates golangci-lint's per-user global
earn. The Dockerfile declares `ARG CHECK_EPOCH` immediately above the state to the checkout (#121): `GOLANGCI_LINT_CACHE` and `TMPDIR` are
check step and expands it into the `RUN` command, and `script/cibuild` both pointed at the git-ignored, Docker-ignored `.lint-cache/`. The
passes a fresh `$(date +%s%N)` per invocation, so the `make check` cache fixes cross-contamination; `TMPDIR` is what moves the lock,
layer is always re-executed while the pinned toolchain install and which lives at `$TMPDIR/golangci-lint.lock` and not in the cache
`go mod download` stay cached. Verified by experiment: before the fix directory. Reproduced both failure modes on the unfixed script (10 of
a second run on an unchanged tree returned in 283 ms with the check 12 concurrent runs void with `parallel golangci-lint is running`; 11
layer `CACHED`; after it the check runs every time, and a deliberately of 12 reporting another checkout's paths) and both are gone at 20-way
planted always-failing test made the build fail with exactly that concurrency after the fix
test's message
- 2026-08-07: golangci-lint bumped to v2.12.2 (commit-pinned installs - 2026-08-07: golangci-lint bumped to v2.12.2 (commit-pinned installs
in `Dockerfile` and `script/bootstrap`); `.golangci.yml` set to the in `Dockerfile` and `script/bootstrap`); `.golangci.yml` set to the
org-standard v2-schema config used across the org's repos org-standard v2-schema config used across the org's repos

View File

@@ -1,18 +1,13 @@
#!/bin/sh #!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs make check, and # script/cibuild: run the CI build. The Dockerfile runs make check, so
# the CHECK_EPOCH build argument below is fresh on every invocation, so # a successful build implies all checks pass.
# the check layer is never served from the Docker layer cache: a
# successful build means the checks were executed and passed on this
# run, not on some earlier one. Only the check step and the steps after
# it are invalidated; the toolchain install and go mod download stay
# cached.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
docker build --build-arg CHECK_EPOCH="$(date +%s%N)" . docker build .
} }
main "$@" main "$@"

View File

@@ -1,11 +1,40 @@
#!/bin/sh #!/bin/sh
# script/lint: run the linter. # script/lint: run the linter.
#
# golangci-lint keeps two pieces of per-user global state, and both of
# them break when several checkouts on one host lint concurrently:
#
# 1. Its analysis cache (GOLANGCI_LINT_CACHE, default
# ~/.cache/golangci-lint). Entries are keyed by content, not by
# checkout, so a hit written by another checkout is replayed
# verbatim - including that checkout's file paths. The run then
# reports findings for files it never linted.
#
# 2. Its "one runner at a time" lock, which does NOT live in the
# cache directory: golangci-lint locks
# $(os.TempDir())/golangci-lint.lock, i.e.
# "$TMPDIR"/golangci-lint.lock (pkg/commands/run.go,
# acquireFileLock). It waits 5s, then aborts with "parallel
# golangci-lint is running" - a non-result that looks like a lint
# failure. Setting GOLANGCI_LINT_CACHE alone does not move it.
#
# So both are pinned under the checkout root. The cache is never shared,
# and TMPDIR makes the lock file per-checkout, which keeps the lock
# doing its actual job (serialising runs that share one cache) at the
# right scope. .lint-cache/ is git-ignored and Docker-ignored, and
# caching still works: it persists across runs in this checkout.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
GOLANGCI_LINT_CACHE="$ROOT/.lint-cache/cache"
TMPDIR="$ROOT/.lint-cache/tmp"
export GOLANGCI_LINT_CACHE TMPDIR
mkdir -p "$GOLANGCI_LINT_CACHE" "$TMPDIR"
golangci-lint run --config .golangci.yml ./... golangci-lint run --config .golangci.yml ./...
} }