next #136

Open
clawbot wants to merge 6 commits from next into main
Collaborator

Long-lived integration branch. One commit per work unit lands here; this PR accumulates them until it is merged to main.

Landed units

  • Run all linting in Docker via Dockerfile.lint + script/lint#134

    golangci-lint is no longer installed or run on the host. New root Dockerfile.lint COPYs the repo into the digest-pinned golangci/golangci-lint:v2.12.2 image and lints as a build step, so a successful build IS a clean lint; script/lint is reduced to a thin wrapper that builds it. This also works where the docker daemon is remote and bind mounts are impossible.

    Pinned digest and how it was verified. golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240, exactly as quoted in the issue. It resolves, and it is genuinely v2.12.2:

    $ docker buildx imagetools inspect golangci/golangci-lint:v2.12.2
    Name:      docker.io/golangci/golangci-lint:v2.12.2
    MediaType: application/vnd.oci.image.index.v1+json
    Digest:    sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240
    
    $ docker run --rm golangci/golangci-lint@sha256:5cceeef04e...ad5240 golangci-lint --version
    golangci-lint has version 2.12.2 built with go1.26.2 from c0d3ddc9 on 2026-05-06T11:07:58Z
    

    The tag's index digest is the quoted digest, and the binary inside reports commit c0d3ddc9, matching the org's canonical pin c0d3ddc9cf3faa61a4e378e879ece580256d76e5.

    Forcing the linter to actually run. Dockerfile.lint is split into a deps stage (base image + go mod download) and a lint stage (source copy + linter run). script/lint runs:

    docker build --progress=plain --no-cache-filter=lint --target lint -f Dockerfile.lint .
    

    Caching is explicitly waived for linting, and a cached build lints nothing, so the lint stage is invalidated on every invocation. The invalidation is scoped: the deps stage stays cached and no global cache wipe is performed. --progress=plain keeps the linter's own output visible.

    golangci-lint config verify: deliberately NOT included. It fetches its JSON schema over a live, unpinned HTTPS call, which would make linting network-dependent and defeat hash-pinning. Omitted for that reason, and the reason is recorded in a comment at the top of Dockerfile.lint.

    script/bootstrap no longer installs golangci-lint (and its pinned ref is gone); it warns non-fatally when docker is absent instead. The goimports install stays, because script/fmt and script/fmt-check still run on the host. Header comment updated accordingly.

    Root Dockerfile — required consequence, not scope creep. Its builder stage ran make check, which now calls script/lint, which shells out to docker build; there is no docker daemon inside a docker build, so script/cibuild and script/docker would have broken. It gains its own lint stage on the same pinned image (linter invoked directly, with a comment explaining why not make lint), with the builder stage depending on it via COPY --from=lint /src/go.sum /dev/null and running make fmt-check, make test, make build. The now-unneeded golangci-lint install is gone from the builder stage.

    README Entrypoints and Building sections now describe linting as a docker-only operation. TODO.md updated in the same commit.

    Verification

    All runs via make / script/ entrypoints only.

    Two consecutive make lint runs on an unchanged tree, both executing the linter:

    # run 1
    #10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./...
    #10 10.98 0 issues.
    #10 DONE 12.0s
    
    # run 2, tree untouched
    #10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./...
    #10 14.63 0 issues.
    

    A third run shows the cache scoping is working as intended — deps served from cache, lint re-executed:

    #6 [deps 2/4] WORKDIR /src
    #6 CACHED
    #7 [deps 3/4] COPY go.mod go.sum ./
    #7 CACHED
    #8 [deps 4/4] RUN go mod download
    #8 CACHED
    #9 [lint 1/2] COPY . .
    #10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./...
    

    Negative control. A deliberate violation (an unused function containing an ineffectual assignment) was added to internal/config/config.go:

    #10 11.26 internal/config/config.go:29:2: ineffectual assignment to x (ineffassign)
    #10 11.26 internal/config/config.go:28:6: func negativeControlUnused is unused (unused)
    #10 11.26 2 issues:
    #10 ERROR: process "/bin/sh -c golangci-lint run --config .golangci.yml ./..." did not complete successfully: exit code: 1
    ERROR: failed to build: failed to solve: process "/bin/sh -c golangci-lint run --config .golangci.yml ./..." did not complete successfully: exit code: 1
    make: *** [Makefile:26: lint] Error 1
    

    make lint exited non-zero naming both findings and their exact lines. After reverting the file, make lint was clean again (0 issues.).

    make check green end to end (test, lint, fmt-check), exit 0.

    script/cibuild green, confirming the Dockerfile restructure does not recurse: the lint stage ran (#16 12.56 0 issues.), then #22 [builder 8/9] RUN make test with PASS lines, then #23 [builder 9/9] RUN make build.

    Notes for the owner

    This supersedes two PRs you still have queued for merge, both of which tune host linting that no longer exists after this change: #128 (isolates the host golangci-lint cache and lock) and #131 (always installs the pinned lint tools in script/bootstrap). Neither was merged or incorporated here.

    Made moot by this change: #121 and #130.

    Review outcome

    Reviewed at #136 (comment)PASS. Three non-blocking comment-accuracy findings were recorded there for the next touch of those files; the reviewer independently confirmed the lint gate is live by negative control from a warm cache.

  • Live DNS tests made robust instead of gated; test caps moved to the org-wide 60s/20s/90s values#93

    The resolver's live-DNS tests failed nondeterministically, a different subset each run. Fixed by engineering the nondeterminism out, not by routing around the network. Nothing is mocked, faked, stubbed, recorded or replayed; there is no -short flag, no build tag, no skip, and no environment-tolerance for restricted egress. Production resolver behaviour is unchanged.

    Root causes, all test-side

    1. Burst fan-out at one root server. Every test in internal/resolver calls t.Parallel() and the build hosts have many cores (48 here), so all ~35 iterative resolutions started within milliseconds of each other, and because queryServers walks rootServerList() in fixed order they all aimed their first query at 198.41.0.4. Root servers rate-limit that, which fits the reported symptom of a different arbitrary subset failing each run.
    2. No retry anywhere. One dropped UDP packet in a delegation chain failed a test outright.
    3. Unanimity assertions. TestQueryAllNameservers_AllReturnOK and _NXDomainFromAllNS required every nameserver of a domain to answer — four independent chances to fail per run, with no tolerance for one being slow.

    What was built

    New internal/resolver/livedns_test.go holds all the live-DNS machinery, so resolver_test.go itself takes only call-site edits:

    • Bounded live concurrency. A package-wide semaphore (liveConcurrency = 6) caps how many live resolutions are in flight at once. Tests keep t.Parallel(); only their network work is throttled. This is the direct fix for cause 1, and the 60s budget is what makes it affordable.
    • Retry with exponential backoff. Three attempts per live operation, 8s deadline each, 500ms base backoff doubling. The retry predicate is deliberately transport-level — "did a nameserver answer at all" — and never the assertion the test is making, so a resolver that answers incorrectly still fails on the first attempt rather than being retried into a false green.
    • Quorum instead of unanimity, tolerating SILENCE ONLY. A strict majority of the discovered nameservers must answer as expected, and every individual result must additionally fall inside a closed allowlist of statuses the test explicitly sanctions: ok/timeout/error for the all-OK test, nxdomain/timeout/error for the NXDOMAIN test. A nameserver that stays silent is tolerated; one that answers wrongly is not, at any count. The allowlist is the load-bearing part — see the rework note below for why a blocklist was not enough.

    New internal/resolver/livedns_harness_test.go tests that machinery directly — quorum arithmetic, status counting, the allowlist, the gate's concurrency bound, per-attempt deadlines, and recovery from a transient failure. It performs no DNS resolution of any kind, so it neither mocks DNS nor depends on it.

    Rework after review — the quorum could not fail on a wrong answer

    The review at #136 (comment) returned FAIL on 9cb2c2b, correctly. Fixed in 87bce43.

    The defect. The claim above was, as first written, false for resolver.StatusNoData. Each test banned exactly one wrong status — _AllReturnOK banned only nxdomain, _NXDomainFromAllNS banned only ok — and nodata is neither. It is a wrong answer, not silence: answeredCount counted it as answered, so it did not even trigger a retry, and with a quorum of 3-of-4 a single wrong nameserver slid through undetected. The pre-change unanimity assertions would have caught it. That is robustness work quietly becoming assertion-loosening, which is exactly what this repo cannot afford.

    The fix. Tolerance is now an allowlist, not a blocklist of one status. New unsanctionedStatuses() returns every per-nameserver result whose status the caller did not explicitly sanction, and each test asserts that list is empty in addition to its quorum. A blocklist bans the one wrong answer its author thought of and silently admits everything else, including any status added to the resolver later; an allowlist fails on anything nobody sanctioned. answeredCount was reframed the same way — it now counts the closed set ok/nxdomain/nodata, so an unfamiliar status is treated as silence and can only ever cause a retry and then a loud failure, never a quiet pass.

    Evidence — the reviewer's exact probe, re-run. queryEachNS in internal/resolver/iterative.go was patched to force one of google.com's four nameservers to return StatusNoData with empty records. make test now goes red, naming the offending nameserver and status:

    exit=2
    --- FAIL: TestQueryAllNameservers_AllReturnOK (1.12s)
        Error:    Should be empty, but was [ns1.google.com.=nodata]
        Messages: every nameserver must answer OK or not answer at all:
                  ns1.google.com.=nodata ns2.google.com.=ok
                  ns3.google.com.=ok ns4.google.com.=ok
    --- FAIL: TestQueryAllNameservers_NXDomainFromAllNS (1.34s)
        Error:    Should be empty, but was [ns1.google.com.=nodata]
        Messages: every nameserver must report NXDOMAIN or not answer at all:
                  ns1.google.com.=nodata ns2.google.com.=nxdomain
                  ns3.google.com.=nxdomain ns4.google.com.=nxdomain
    FAIL  sneak.berlin/go/dnswatcher/internal/resolver  1.704s
    

    That is the same input that returned exit=0 with both tests passing under the old assertions. Probe reverted, tree clean, suite green again:

    exit=0
    ok  sneak.berlin/go/dnswatcher/internal/resolver  4.185s  coverage: 77.1% of statements
    (zero `(cached)` lines)
    

    Two harness tests lock the regression in without any probe: three OK plus one nodata (quorum satisfied, no NXDOMAIN present — the exact input that used to pass) is reported as unsanctioned, and an unknown status is neither counted as answered nor tolerated.

    Also fixed from the review: the per-attempt deadline assertion in livedns_harness_test.go had no lower bound, so it passed for a deadline far shorter than intended. It now asserts the remaining time exceeds liveAttemptTimeout/2 as well.

    Deliberately not done in this rework, per the review and the owner: no -count=1 in script/test (the test-cache issue is real but pre-existing and repo-wide, filed separately); the remaining non-DNS mocks stay for #97; queryServers root-ordering stays untouched under #138.

    The -timeout backstop value: 90s

    Per the ruling at sneak/prompts#41 (comment) the cap is org-wide with two tiers: 60s hard cap for CI green, 20s target, and anything between the two must be filed as an improvement bug. The backstop is 90s, matching sneak/prompts#42 and preserving the 1.5x backstop-to-cap ratio the old 20s/30s pair had. It must strictly exceed the 60s cap or the cap is unreachable — the old -timeout 30s would have killed a 60s-capped suite at half its allowance. Applied to script/test; nothing else in the repo carried the old 30s.

    Worst case for one live operation is 3 attempts x 8s plus ~1.5s of backoff, about 26s — comfortably inside the 90s backstop even if several operations exhaust their attempts at once.

    REPO_POLICIES.md is re-vendored, not hand-edited

    The file is org-canonical, so it was copied byte-for-byte from prompts/REPO_POLICIES.md on sneak/prompts branch org-wide-60s-test-cap (commit 52b5192) rather than reworded to approximately the same thing. Verified:

    $ cmp prompts/REPO_POLICIES.md dnswatcher/REPO_POLICIES.md && echo identical
    identical
    $ sha256sum REPO_POLICIES.md
    bcf11c312a1bee18a0e937eb412b51914411c1ab23308b8362409f3f88379ff7
    

    What that byte-identity does and does not certify. The source branch org-wide-60s-test-cap is an unmerged proposalsneak/prompts#42 — not prompts main. So, precisely:

    • The 60s hard cap and 20s improvement-bug tier ARE the owner's ruling (sneak/prompts#41 (comment)).
    • The 90s backstop is our own proposed number and is NOT ratified (sneak/prompts#41 (comment)).
    • The vendored text is therefore the proposed canonical text, pending sneak/prompts#42. If that PR lands with different numbers, this file must be re-vendored to match; it should not be hand-edited here either way.

    Known mismatch with this repo's actual state, recorded not papered over. Re-vendoring also picked up the paragraph at REPO_POLICIES.md:266-271 mandating that canonical golangci-lint be installed commit-pinned via go install ...@c0d3ddc9.... This repo does not comply with that mechanism: cc86473 in this same PR made linting Docker-only, and script/bootstrap now installs golangci-lint nowhere. The version and commit match (v2.12.2 / c0d3ddc9); the installation mechanism does not. The vendored file is org-canonical and must not be edited downstream, so this is being raised upstream for the org text to accommodate Docker-only linting rather than patched here.

    TESTING.md's stale "within the 30-second target" follows to 60. That edit is deliberately a single line so it merges cleanly when #97 lands.

    Verification

    All runs through make / script/ entrypoints only; lint runs in Docker.

    Ten consecutive make check runs, all green, none served from cache. Go's test cache will happily report ok pkg (cached) without executing anything, which proves nothing about nondeterminism, so every run was forced to actually execute and each log was checked for zero (cached) lines:

    check#1  exit=0 wall=32s resolver=2.895s fails=0 cached=0
    check#2  exit=0 wall=27s resolver=2.872s fails=0 cached=0
    check#3  exit=0 wall=47s resolver=2.729s fails=0 cached=0
    check#4  exit=0 wall=36s resolver=2.885s fails=0 cached=0
    check#5  exit=0 wall=32s resolver=2.899s fails=0 cached=0
    check#6  exit=0 (harness tests added)      fails=0 cached=0
    check#7  exit=0 wall=41s resolver=2.891s fails=0 cached=0
    check#8  exit=0 wall=36s resolver=2.871s fails=0 cached=0
    check#9  exit=0 wall=26s resolver=2.846s fails=0 cached=0
    check#10 exit=0 wall=46s resolver=2.833s fails=0 cached=0
    

    After the rework commit 87bce43, make check green again end to end, zero (cached) test lines, Docker lint stage demonstrably executed rather than served from cache:

    exit=0  cached=0
    #8 [deps 4/4] RUN go mod download
    #8 CACHED
    #10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./...
    #10 30.72 0 issues.
    

    The Docker lint stage was confirmed to execute rather than cache on each run:

    #8 [deps 4/4] RUN go mod download
    #8 CACHED
    #9 [lint 1/2] COPY . .
    #9 DONE 0.6s
    #10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./...
    #10 DONE 39.7s
    

    make test wall time: 3.6-4.1s across three timed uncached runs (4093ms, 3649ms, 3729ms). internal/resolver went from 2.0s to ~2.9s — the concurrency gate's cost. That is inside the 20s target, so no improvement bug is owed under the new two-tier rule.

    Honest note on what these runs do and do not prove. Live DNS was healthy throughout: no live-DNS retry fired even once, and no flake was observed either before or after the change (six pre-change baseline runs were also clean). So these runs demonstrate the change is not itself flaky and does not slow the suite; they do not demonstrate recovery from a real DNS failure, because no real DNS failure occurred. The original flakiness is not reproduced rather than shown fixed. The retry path is instead proven by TestRetryLiveRecoversFromTransientFailure, the only source of the single retrying in 500ms line in each log:

    livedns_harness_test.go:90: transient: attempt 1 of 3 failed
        (no answer from live DNS), retrying in 500ms
    

    Observation, not acted on

    The single most effective remaining lever against root-server rate limiting would be to stop queryServers always trying rootServerList() in the same order, so that load spreads across all thirteen roots instead of concentrating on a.root-servers.net. That is production code and this issue scopes the work as test-side, so it was left alone rather than changed quietly. It is now tracked for the owner's decision at #138.

    Interaction with #97

    TESTING.md and internal/resolver/resolver_test.go auto-merge — that PR touches resolver_test.go only at the import block and the final timeout-test section, while this change touches the body of the file and adds two new files, and it leaves the mock-DNSClient timeout test at the tail of resolver_test.go entirely alone since removing it is that PR's job. TODO.md does conflict; that PR is already labelled needs-rebase, so this adds nothing material to its rebase.

  • Go's test cache disabled, so every make test actually queries live DNS#139

    script/test did not pass -count=1, so on an unchanged tree Go served the whole suite from cache: exit 0 in ~0.2s, every package marked (cached), and not one DNS query made. This repo's suite exists to exercise live resolution on every run (TESTING.md), so that green asserted nothing — and it is exactly the green used as evidence that a flakiness fix works, since "run it a few times" stops being runs after the first. It had already misled two agents, each of whom forced uncached runs by hand.

    -count=1 now disables caching on every invocation.

    The conditional verbose rerun was missing and is added here. REPO_POLICIES.md mandates it; the primary run had been unconditionally -v, which is the failure mode the policy exists to prevent (unreadable CI and docker build logs on success). Tests now run quiet, and only a failure triggers the -v rerun. Two properties matter and both are covered: the rerun carries -count=1 too, so it cannot replay a cached copy of the failure it is meant to diagnose; and its exit status is discarded in favour of a forced 1, so a flake that passes the second time cannot turn the build green — the first failure already proved the suite broken.

    -timeout 90s untouched. It is a deliberate backstop that must strictly exceed the 60s hard cap.

    No special-casing for the Docker build, which reaches the same script via RUN make test: a fresh container's test cache is empty, so -count=1 is a no-op there, and carving out an exception would only create a second code path that could drift.

    Verification

    Proven from a warm cache, not a cold one. The suite was run first to populate the cache, and the pre-change state confirmed:

    ok  sneak.berlin/go/dnswatcher/internal/config    (cached)  coverage: 92.6% of statements
    ok  sneak.berlin/go/dnswatcher/internal/resolver  (cached)  coverage: 77.1% of statements
    ...8 of 8 packages (cached)...
    real 0m0.203s
    

    With the change applied to that same warm cache, three back-to-back runs on an unchanged tree, zero (cached) markers in all three:

    # run 1                                          # run 2
    ok  .../internal/config     1.045s               ok  .../internal/config     1.040s
    ok  .../internal/handlers   1.029s               ok  .../internal/handlers   1.021s
    ok  .../internal/notify     1.148s               ok  .../internal/notify     1.252s
    ok  .../internal/portcheck  1.026s               ok  .../internal/portcheck  1.025s
    ok  .../internal/resolver   3.005s               ok  .../internal/resolver   2.846s
    ok  .../internal/state      1.078s               ok  .../internal/state      1.097s
    ok  .../internal/tlscheck   1.067s               ok  .../internal/tlscheck   1.079s
    ok  .../internal/watcher    1.566s               ok  .../internal/watcher    1.544s
    real 0m4.174s                                    real 0m4.015s
    
    # run 3: grep -c '(cached)' => 0                 real 0m4.519s
    

    Measured uncached wall time: 4.0-4.5s (was ~0.2s served from cache). Inside the 20s target, so no improvement bug is owed under the two-tier rule at sneak/prompts#41 (comment).

    -count=1 composes with -race and -cover: both still present in the primary run, and the per-package coverage percentages above are identical to the pre-change values.

    The failure path was exercised, not assumed. A purpose-built flaky test that fails on its first run and passes every run after (marker file kept outside the module, so the tree stays byte-identical and a cached result would be served if caching were on) was run through the script:

    exit code: 1
    --- FAIL: TestFlaky (0.00s)
    FAIL    flakeproof  0.013s
    --- Rerunning with -v for details ---
    --- PASS: TestFlaky (0.00s)
    ok      flakeproof  1.014s
    

    Quiet failure, verbose rerun that genuinely re-executed (it passed, so it did not replay the cached FAIL), and exit 1 regardless of the rerun passing. Scratch module removed afterwards.

    make check green, exit 0, with the Docker lint stage demonstrably executed rather than served from cache:

    #8  [deps 4/4] RUN go mod download
    #8  CACHED
    #10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./...
    #10 21.36 0 issues.
    #10 DONE 26.2s
    

    README.md and TESTING.md record why the cache is waived, and TODO.md is updated in the same commit.

    Question for the owner, not filed as a defect

    The Docker lint run emits The linter 'gomodguard' is deprecated (since v2.12.0) due to: new major version. Replaced by gomodguard_v2. It is pre-existing and out of this issue's scope. It is not filed as an issue here because .golangci.yml tracks the org-canonical config, so switching to gomodguard_v2 looks like an upstream sneak/prompts decision rather than a per-repo fix. Say the word and it gets filed in whichever place you consider canonical.

  • MIT LICENSE added; README states the licence#102

    The repo had no licence file at all, so publicly readable code was all-rights-reserved by default and nobody could legally use it. LICENSE was also the last file missing from REPO_POLICIES.md's required minimum.

    MIT, by standing org policy rather than a per-repo call: any public repo lacking a licence gets MIT, and a private one with no licence is already all-rights-reserved. sneak/dnswatcher is public (private: false on the Gitea repo record).

    README.md's first line now names the licence, per the Description requirement, and the License section states MIT and points at the file instead of recording the decision as pending. TODO.md updated in the same commit.

    Verification

    LICENSE is the canonical MIT text byte-for-byte with only the copyright line filled in (Copyright (c) 2026 sneak) — no clauses added, removed, reworded, or reflowed. It was not typed from memory: the file was copied from an existing verbatim MIT template on disk and only the copyright line edited (diff against that template shows that one line and nothing else), then the result was word-diffed against SPDX MIT.txt fetched from spdx/license-list-data, ignoring only line wrapping and the placeholder — identical.

    make fmt did not touch LICENSE, and cannot: script/fmt runs gofmt -s -w . and goimports -w . only, with no prettier or markdown step in the repo, so no exclusion was needed.

    make check green, exit 0. Tests executed rather than replayed (zero (cached) lines, internal/resolver 3.098s), and the Docker lint stage ran rather than cached:

    #10 [deps 4/4] RUN go mod download
    #10 CACHED
    #12 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./...
    #12 36.44 0 issues.
    #12 DONE 36.7s
    
  • Comment-only corrections in script/bootstrap, script/cibuild, and Dockerfile.lint#137

    Follow-up to this PR's own review. Nothing executable changed: the diff touches comment lines, one warning string, and TODO.md.

    • script/bootstrap's header justified the pinned goimports install by claiming script/fmt-check runs it on the host. Verified against the script: script/fmt-check runs gofmt -l . and nothing else. The header now credits script/fmt alone. That fmt-check does not verify goimports at all is #119 and was deliberately left alone.
    • script/cibuild's header still said the Dockerfile runs make check. It now describes the current file: lint stage runs make fmt-check and golangci-lint, builder stage runs make test and make build.
    • The docker-missing warning was three fragments, each re-prefixed with bootstrap: mid-clause. Now one sentence: bootstrap: WARNING: docker not found; install it to run make lint and make docker.
    • Dockerfile.lint's comment explained why golangci-lint config verify is omitted but read as though the omission were free. It now states the residual risk: unknown top-level keys in .golangci.yml are silently ignored, so a mistyped or wrong-schema key lints clean while applying nothing. config verify was not added — the network-dependence reasoning stands.

    Verification

    make check green, exit 0. Tests executed rather than replayed (zero (cached) lines, internal/resolver 2.820s), Docker lint stage executed rather than cached:

    #8 [deps 4/4] RUN go mod download
    #8 CACHED
    #10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./...
    #10 13.30 0 issues.
    

    Comment-only confirmed by reading the whole diff: no statement, flag, or command changed anywhere.

Long-lived integration branch. One commit per work unit lands here; this PR accumulates them until it is merged to `main`. ## Landed units - **Run all linting in Docker via `Dockerfile.lint` + `script/lint`** — https://git.eeqj.de/sneak/dnswatcher/issues/134 golangci-lint is no longer installed or run on the host. New root `Dockerfile.lint` COPYs the repo into the digest-pinned `golangci/golangci-lint:v2.12.2` image and lints as a build step, so a successful build IS a clean lint; `script/lint` is reduced to a thin wrapper that builds it. This also works where the docker daemon is remote and bind mounts are impossible. **Pinned digest and how it was verified.** `golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`, exactly as quoted in the issue. It resolves, and it is genuinely v2.12.2: ``` $ docker buildx imagetools inspect golangci/golangci-lint:v2.12.2 Name: docker.io/golangci/golangci-lint:v2.12.2 MediaType: application/vnd.oci.image.index.v1+json Digest: sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 $ docker run --rm golangci/golangci-lint@sha256:5cceeef04e...ad5240 golangci-lint --version golangci-lint has version 2.12.2 built with go1.26.2 from c0d3ddc9 on 2026-05-06T11:07:58Z ``` The tag's index digest is the quoted digest, and the binary inside reports commit `c0d3ddc9`, matching the org's canonical pin `c0d3ddc9cf3faa61a4e378e879ece580256d76e5`. **Forcing the linter to actually run.** `Dockerfile.lint` is split into a `deps` stage (base image + `go mod download`) and a `lint` stage (source copy + linter run). `script/lint` runs: ``` docker build --progress=plain --no-cache-filter=lint --target lint -f Dockerfile.lint . ``` Caching is explicitly waived for linting, and a cached build lints nothing, so the `lint` stage is invalidated on every invocation. The invalidation is scoped: the `deps` stage stays cached and no global cache wipe is performed. `--progress=plain` keeps the linter's own output visible. **`golangci-lint config verify`: deliberately NOT included.** It fetches its JSON schema over a live, unpinned HTTPS call, which would make linting network-dependent and defeat hash-pinning. Omitted for that reason, and the reason is recorded in a comment at the top of `Dockerfile.lint`. **`script/bootstrap`** no longer installs golangci-lint (and its pinned ref is gone); it warns non-fatally when `docker` is absent instead. The `goimports` install stays, because `script/fmt` and `script/fmt-check` still run on the host. Header comment updated accordingly. **Root `Dockerfile`** — required consequence, not scope creep. Its builder stage ran `make check`, which now calls `script/lint`, which shells out to `docker build`; there is no docker daemon inside a docker build, so `script/cibuild` and `script/docker` would have broken. It gains its own lint stage on the same pinned image (linter invoked directly, with a comment explaining why not `make lint`), with the builder stage depending on it via `COPY --from=lint /src/go.sum /dev/null` and running `make fmt-check`, `make test`, `make build`. The now-unneeded golangci-lint install is gone from the builder stage. **README** `Entrypoints` and `Building` sections now describe linting as a docker-only operation. `TODO.md` updated in the same commit. ### Verification All runs via `make` / `script/` entrypoints only. Two consecutive `make lint` runs on an unchanged tree, both executing the linter: ``` # run 1 #10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./... #10 10.98 0 issues. #10 DONE 12.0s # run 2, tree untouched #10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./... #10 14.63 0 issues. ``` A third run shows the cache scoping is working as intended — `deps` served from cache, `lint` re-executed: ``` #6 [deps 2/4] WORKDIR /src #6 CACHED #7 [deps 3/4] COPY go.mod go.sum ./ #7 CACHED #8 [deps 4/4] RUN go mod download #8 CACHED #9 [lint 1/2] COPY . . #10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./... ``` **Negative control.** A deliberate violation (an unused function containing an ineffectual assignment) was added to `internal/config/config.go`: ``` #10 11.26 internal/config/config.go:29:2: ineffectual assignment to x (ineffassign) #10 11.26 internal/config/config.go:28:6: func negativeControlUnused is unused (unused) #10 11.26 2 issues: #10 ERROR: process "/bin/sh -c golangci-lint run --config .golangci.yml ./..." did not complete successfully: exit code: 1 ERROR: failed to build: failed to solve: process "/bin/sh -c golangci-lint run --config .golangci.yml ./..." did not complete successfully: exit code: 1 make: *** [Makefile:26: lint] Error 1 ``` `make lint` exited non-zero naming both findings and their exact lines. After reverting the file, `make lint` was clean again (`0 issues.`). **`make check`** green end to end (test, lint, fmt-check), exit 0. **`script/cibuild`** green, confirming the `Dockerfile` restructure does not recurse: the lint stage ran (`#16 12.56 0 issues.`), then `#22 [builder 8/9] RUN make test` with `PASS` lines, then `#23 [builder 9/9] RUN make build`. ### Notes for the owner This supersedes two PRs you still have queued for merge, both of which tune host linting that no longer exists after this change: https://git.eeqj.de/sneak/dnswatcher/pulls/128 (isolates the host golangci-lint cache and lock) and https://git.eeqj.de/sneak/dnswatcher/pulls/131 (always installs the pinned lint tools in `script/bootstrap`). Neither was merged or incorporated here. Made moot by this change: https://git.eeqj.de/sneak/dnswatcher/issues/121 and https://git.eeqj.de/sneak/dnswatcher/issues/130. ### Review outcome Reviewed at https://git.eeqj.de/sneak/dnswatcher/pulls/136#issuecomment-53509 — **PASS**. Three non-blocking comment-accuracy findings were recorded there for the next touch of those files; the reviewer independently confirmed the lint gate is live by negative control from a warm cache. - **Live DNS tests made robust instead of gated; test caps moved to the org-wide 60s/20s/90s values** — https://git.eeqj.de/sneak/dnswatcher/issues/93 The resolver's live-DNS tests failed nondeterministically, a different subset each run. Fixed by engineering the nondeterminism out, not by routing around the network. **Nothing is mocked, faked, stubbed, recorded or replayed; there is no `-short` flag, no build tag, no skip, and no environment-tolerance for restricted egress.** Production resolver behaviour is unchanged. ### Root causes, all test-side 1. **Burst fan-out at one root server.** Every test in `internal/resolver` calls `t.Parallel()` and the build hosts have many cores (48 here), so all ~35 iterative resolutions started within milliseconds of each other, and because `queryServers` walks `rootServerList()` in fixed order they all aimed their first query at `198.41.0.4`. Root servers rate-limit that, which fits the reported symptom of a different arbitrary subset failing each run. 2. **No retry anywhere.** One dropped UDP packet in a delegation chain failed a test outright. 3. **Unanimity assertions.** `TestQueryAllNameservers_AllReturnOK` and `_NXDomainFromAllNS` required *every* nameserver of a domain to answer — four independent chances to fail per run, with no tolerance for one being slow. ### What was built New `internal/resolver/livedns_test.go` holds all the live-DNS machinery, so `resolver_test.go` itself takes only call-site edits: - **Bounded live concurrency.** A package-wide semaphore (`liveConcurrency = 6`) caps how many live resolutions are in flight at once. Tests keep `t.Parallel()`; only their network work is throttled. This is the direct fix for cause 1, and the 60s budget is what makes it affordable. - **Retry with exponential backoff.** Three attempts per live operation, 8s deadline each, 500ms base backoff doubling. The retry predicate is deliberately **transport-level** — "did a nameserver answer at all" — and never the assertion the test is making, so a resolver that answers *incorrectly* still fails on the first attempt rather than being retried into a false green. - **Quorum instead of unanimity, tolerating SILENCE ONLY.** A strict majority of the discovered nameservers must answer as expected, and every individual result must additionally fall inside a closed **allowlist** of statuses the test explicitly sanctions: `ok`/`timeout`/`error` for the all-OK test, `nxdomain`/`timeout`/`error` for the NXDOMAIN test. A nameserver that stays silent is tolerated; one that answers **wrongly** is not, at any count. The allowlist is the load-bearing part — see the rework note below for why a blocklist was not enough. New `internal/resolver/livedns_harness_test.go` tests that machinery directly — quorum arithmetic, status counting, the allowlist, the gate's concurrency bound, per-attempt deadlines, and recovery from a transient failure. It performs no DNS resolution of any kind, so it neither mocks DNS nor depends on it. ### Rework after review — the quorum could not fail on a wrong answer The review at https://git.eeqj.de/sneak/dnswatcher/pulls/136#issuecomment-53869 returned **FAIL** on `9cb2c2b`, correctly. Fixed in `87bce43`. **The defect.** The claim above was, as first written, false for `resolver.StatusNoData`. Each test banned exactly one wrong status — `_AllReturnOK` banned only `nxdomain`, `_NXDomainFromAllNS` banned only `ok` — and `nodata` is neither. It is a **wrong answer, not silence**: `answeredCount` counted it as answered, so it did not even trigger a retry, and with a quorum of 3-of-4 a single wrong nameserver slid through undetected. The pre-change unanimity assertions would have caught it. That is robustness work quietly becoming assertion-loosening, which is exactly what this repo cannot afford. **The fix.** Tolerance is now an allowlist, not a blocklist of one status. New `unsanctionedStatuses()` returns every per-nameserver result whose status the caller did not explicitly sanction, and each test asserts that list is empty in addition to its quorum. A blocklist bans the one wrong answer its author thought of and silently admits everything else, including any status added to the resolver later; an allowlist fails on anything nobody sanctioned. `answeredCount` was reframed the same way — it now counts the closed set `ok`/`nxdomain`/`nodata`, so an unfamiliar status is treated as silence and can only ever cause a retry and then a loud failure, never a quiet pass. **Evidence — the reviewer's exact probe, re-run.** `queryEachNS` in `internal/resolver/iterative.go` was patched to force one of `google.com`'s four nameservers to return `StatusNoData` with empty records. `make test` now goes **red**, naming the offending nameserver and status: ``` exit=2 --- FAIL: TestQueryAllNameservers_AllReturnOK (1.12s) Error: Should be empty, but was [ns1.google.com.=nodata] Messages: every nameserver must answer OK or not answer at all: ns1.google.com.=nodata ns2.google.com.=ok ns3.google.com.=ok ns4.google.com.=ok --- FAIL: TestQueryAllNameservers_NXDomainFromAllNS (1.34s) Error: Should be empty, but was [ns1.google.com.=nodata] Messages: every nameserver must report NXDOMAIN or not answer at all: ns1.google.com.=nodata ns2.google.com.=nxdomain ns3.google.com.=nxdomain ns4.google.com.=nxdomain FAIL sneak.berlin/go/dnswatcher/internal/resolver 1.704s ``` That is the same input that returned `exit=0` with both tests **passing** under the old assertions. Probe reverted, tree clean, suite green again: ``` exit=0 ok sneak.berlin/go/dnswatcher/internal/resolver 4.185s coverage: 77.1% of statements (zero `(cached)` lines) ``` Two harness tests lock the regression in without any probe: three OK plus one `nodata` (quorum satisfied, no NXDOMAIN present — the exact input that used to pass) is reported as unsanctioned, and an unknown status is neither counted as answered nor tolerated. Also fixed from the review: the per-attempt deadline assertion in `livedns_harness_test.go` had no lower bound, so it passed for a deadline far shorter than intended. It now asserts the remaining time exceeds `liveAttemptTimeout/2` as well. Deliberately **not** done in this rework, per the review and the owner: no `-count=1` in `script/test` (the test-cache issue is real but pre-existing and repo-wide, filed separately); the remaining non-DNS mocks stay for https://git.eeqj.de/sneak/dnswatcher/pulls/97; `queryServers` root-ordering stays untouched under https://git.eeqj.de/sneak/dnswatcher/issues/138. ### The `-timeout` backstop value: 90s Per the ruling at https://git.eeqj.de/sneak/prompts/issues/41#issuecomment-53166 the cap is org-wide with two tiers: **60s hard cap for CI green, 20s target, and anything between the two must be filed as an improvement bug.** The backstop is **`90s`**, matching https://git.eeqj.de/sneak/prompts/pulls/42 and preserving the 1.5x backstop-to-cap ratio the old 20s/30s pair had. It must strictly exceed the 60s cap or the cap is unreachable — the old `-timeout 30s` would have killed a 60s-capped suite at half its allowance. Applied to `script/test`; nothing else in the repo carried the old `30s`. Worst case for one live operation is 3 attempts x 8s plus ~1.5s of backoff, about 26s — comfortably inside the 90s backstop even if several operations exhaust their attempts at once. ### `REPO_POLICIES.md` is re-vendored, not hand-edited The file is org-canonical, so it was **copied byte-for-byte** from `prompts/REPO_POLICIES.md` on `sneak/prompts` branch `org-wide-60s-test-cap` (commit `52b5192`) rather than reworded to approximately the same thing. Verified: ``` $ cmp prompts/REPO_POLICIES.md dnswatcher/REPO_POLICIES.md && echo identical identical $ sha256sum REPO_POLICIES.md bcf11c312a1bee18a0e937eb412b51914411c1ab23308b8362409f3f88379ff7 ``` **What that byte-identity does and does not certify.** The source branch `org-wide-60s-test-cap` is an **unmerged proposal** — https://git.eeqj.de/sneak/prompts/pulls/42 — not `prompts` `main`. So, precisely: - The **60s hard cap and 20s improvement-bug tier ARE the owner's ruling** (https://git.eeqj.de/sneak/prompts/issues/41#issuecomment-53166). - The **`90s` backstop is our own proposed number and is NOT ratified** (https://git.eeqj.de/sneak/prompts/issues/41#issuecomment-53602). - The vendored text is therefore **the proposed canonical text, pending** https://git.eeqj.de/sneak/prompts/pulls/42. If that PR lands with different numbers, this file must be re-vendored to match; it should not be hand-edited here either way. **Known mismatch with this repo's actual state, recorded not papered over.** Re-vendoring also picked up the paragraph at `REPO_POLICIES.md:266-271` mandating that canonical golangci-lint be installed commit-pinned via `go install ...@c0d3ddc9...`. This repo does **not** comply with that mechanism: `cc86473` in this same PR made linting Docker-only, and `script/bootstrap` now installs golangci-lint nowhere. The **version and commit match** (`v2.12.2` / `c0d3ddc9`); the **installation mechanism does not**. The vendored file is org-canonical and must not be edited downstream, so this is being raised upstream for the org text to accommodate Docker-only linting rather than patched here. `TESTING.md`'s stale "within the 30-second target" follows to 60. That edit is deliberately a single line so it merges cleanly when https://git.eeqj.de/sneak/dnswatcher/pulls/97 lands. ### Verification All runs through `make` / `script/` entrypoints only; lint runs in Docker. **Ten consecutive `make check` runs, all green, none served from cache.** Go's test cache will happily report `ok pkg (cached)` without executing anything, which proves nothing about nondeterminism, so every run was forced to actually execute and each log was checked for zero `(cached)` lines: ``` check#1 exit=0 wall=32s resolver=2.895s fails=0 cached=0 check#2 exit=0 wall=27s resolver=2.872s fails=0 cached=0 check#3 exit=0 wall=47s resolver=2.729s fails=0 cached=0 check#4 exit=0 wall=36s resolver=2.885s fails=0 cached=0 check#5 exit=0 wall=32s resolver=2.899s fails=0 cached=0 check#6 exit=0 (harness tests added) fails=0 cached=0 check#7 exit=0 wall=41s resolver=2.891s fails=0 cached=0 check#8 exit=0 wall=36s resolver=2.871s fails=0 cached=0 check#9 exit=0 wall=26s resolver=2.846s fails=0 cached=0 check#10 exit=0 wall=46s resolver=2.833s fails=0 cached=0 ``` After the rework commit `87bce43`, `make check` green again end to end, zero `(cached)` test lines, Docker lint stage demonstrably executed rather than served from cache: ``` exit=0 cached=0 #8 [deps 4/4] RUN go mod download #8 CACHED #10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./... #10 30.72 0 issues. ``` The Docker lint stage was confirmed to execute rather than cache on each run: ``` #8 [deps 4/4] RUN go mod download #8 CACHED #9 [lint 1/2] COPY . . #9 DONE 0.6s #10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./... #10 DONE 39.7s ``` **`make test` wall time: 3.6-4.1s** across three timed uncached runs (`4093ms`, `3649ms`, `3729ms`). `internal/resolver` went from 2.0s to ~2.9s — the concurrency gate's cost. That is inside the 20s target, so no improvement bug is owed under the new two-tier rule. **Honest note on what these runs do and do not prove.** Live DNS was healthy throughout: **no live-DNS retry fired even once**, and no flake was observed either before or after the change (six pre-change baseline runs were also clean). So these runs demonstrate the change is not itself flaky and does not slow the suite; they do **not** demonstrate recovery from a real DNS failure, because no real DNS failure occurred. The original flakiness is *not reproduced* rather than *shown fixed*. The retry path is instead proven by `TestRetryLiveRecoversFromTransientFailure`, the only source of the single `retrying in 500ms` line in each log: ``` livedns_harness_test.go:90: transient: attempt 1 of 3 failed (no answer from live DNS), retrying in 500ms ``` ### Observation, not acted on The single most effective remaining lever against root-server rate limiting would be to stop `queryServers` always trying `rootServerList()` in the same order, so that load spreads across all thirteen roots instead of concentrating on `a.root-servers.net`. That is **production** code and this issue scopes the work as test-side, so it was left alone rather than changed quietly. It is now tracked for the owner's decision at https://git.eeqj.de/sneak/dnswatcher/issues/138. ### Interaction with https://git.eeqj.de/sneak/dnswatcher/pulls/97 `TESTING.md` and `internal/resolver/resolver_test.go` auto-merge — that PR touches `resolver_test.go` only at the import block and the final timeout-test section, while this change touches the body of the file and adds two new files, and it leaves the mock-`DNSClient` timeout test at the tail of `resolver_test.go` entirely alone since removing it is that PR's job. `TODO.md` does conflict; that PR is already labelled `needs-rebase`, so this adds nothing material to its rebase. - **Go's test cache disabled, so every `make test` actually queries live DNS** — https://git.eeqj.de/sneak/dnswatcher/issues/139 `script/test` did not pass `-count=1`, so on an unchanged tree Go served the whole suite from cache: exit 0 in ~0.2s, every package marked `(cached)`, and not one DNS query made. This repo's suite exists to exercise live resolution on every run (`TESTING.md`), so that green asserted nothing — and it is exactly the green used as evidence that a flakiness fix works, since "run it a few times" stops being runs after the first. It had already misled two agents, each of whom forced uncached runs by hand. `-count=1` now disables caching on every invocation. **The conditional verbose rerun was missing and is added here.** `REPO_POLICIES.md` mandates it; the primary run had been unconditionally `-v`, which is the failure mode the policy exists to prevent (unreadable CI and `docker build` logs on success). Tests now run quiet, and only a failure triggers the `-v` rerun. Two properties matter and both are covered: the rerun carries `-count=1` too, so it cannot replay a cached copy of the failure it is meant to diagnose; and its exit status is discarded in favour of a forced `1`, so a flake that passes the second time cannot turn the build green — the first failure already proved the suite broken. **`-timeout 90s` untouched.** It is a deliberate backstop that must strictly exceed the 60s hard cap. **No special-casing for the Docker build**, which reaches the same script via `RUN make test`: a fresh container's test cache is empty, so `-count=1` is a no-op there, and carving out an exception would only create a second code path that could drift. ### Verification **Proven from a warm cache, not a cold one.** The suite was run first to populate the cache, and the pre-change state confirmed: ``` ok sneak.berlin/go/dnswatcher/internal/config (cached) coverage: 92.6% of statements ok sneak.berlin/go/dnswatcher/internal/resolver (cached) coverage: 77.1% of statements ...8 of 8 packages (cached)... real 0m0.203s ``` With the change applied to that same warm cache, three back-to-back runs on an unchanged tree, **zero `(cached)` markers** in all three: ``` # run 1 # run 2 ok .../internal/config 1.045s ok .../internal/config 1.040s ok .../internal/handlers 1.029s ok .../internal/handlers 1.021s ok .../internal/notify 1.148s ok .../internal/notify 1.252s ok .../internal/portcheck 1.026s ok .../internal/portcheck 1.025s ok .../internal/resolver 3.005s ok .../internal/resolver 2.846s ok .../internal/state 1.078s ok .../internal/state 1.097s ok .../internal/tlscheck 1.067s ok .../internal/tlscheck 1.079s ok .../internal/watcher 1.566s ok .../internal/watcher 1.544s real 0m4.174s real 0m4.015s # run 3: grep -c '(cached)' => 0 real 0m4.519s ``` **Measured uncached wall time: 4.0-4.5s** (was ~0.2s served from cache). Inside the 20s target, so no improvement bug is owed under the two-tier rule at https://git.eeqj.de/sneak/prompts/issues/41#issuecomment-53166. **`-count=1` composes with `-race` and `-cover`**: both still present in the primary run, and the per-package coverage percentages above are identical to the pre-change values. **The failure path was exercised, not assumed.** A purpose-built flaky test that fails on its first run and passes every run after (marker file kept outside the module, so the tree stays byte-identical and a cached result would be served if caching were on) was run through the script: ``` exit code: 1 --- FAIL: TestFlaky (0.00s) FAIL flakeproof 0.013s --- Rerunning with -v for details --- --- PASS: TestFlaky (0.00s) ok flakeproof 1.014s ``` Quiet failure, verbose rerun that genuinely re-executed (it passed, so it did not replay the cached `FAIL`), and exit `1` regardless of the rerun passing. Scratch module removed afterwards. **`make check` green**, exit 0, with the Docker lint stage demonstrably executed rather than served from cache: ``` #8 [deps 4/4] RUN go mod download #8 CACHED #10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./... #10 21.36 0 issues. #10 DONE 26.2s ``` `README.md` and `TESTING.md` record why the cache is waived, and `TODO.md` is updated in the same commit. ### Question for the owner, not filed as a defect The Docker lint run emits `The linter 'gomodguard' is deprecated (since v2.12.0) due to: new major version. Replaced by gomodguard_v2.` It is pre-existing and out of this issue's scope. It is not filed as an issue here because `.golangci.yml` tracks the org-canonical config, so switching to `gomodguard_v2` looks like an upstream `sneak/prompts` decision rather than a per-repo fix. Say the word and it gets filed in whichever place you consider canonical. - **MIT `LICENSE` added; README states the licence** — https://git.eeqj.de/sneak/dnswatcher/issues/102 The repo had no licence file at all, so publicly readable code was all-rights-reserved by default and nobody could legally use it. `LICENSE` was also the last file missing from `REPO_POLICIES.md`'s required minimum. MIT, by standing org policy rather than a per-repo call: any public repo lacking a licence gets MIT, and a private one with no licence is already all-rights-reserved. `sneak/dnswatcher` is public (`private: false` on the Gitea repo record). `README.md`'s first line now names the licence, per the Description requirement, and the License section states MIT and points at the file instead of recording the decision as pending. `TODO.md` updated in the same commit. ### Verification `LICENSE` is the canonical MIT text byte-for-byte with only the copyright line filled in (`Copyright (c) 2026 sneak`) — no clauses added, removed, reworded, or reflowed. It was not typed from memory: the file was copied from an existing verbatim MIT template on disk and only the copyright line edited (`diff` against that template shows that one line and nothing else), then the result was word-diffed against SPDX `MIT.txt` fetched from `spdx/license-list-data`, ignoring only line wrapping and the placeholder — identical. `make fmt` did **not** touch `LICENSE`, and cannot: `script/fmt` runs `gofmt -s -w .` and `goimports -w .` only, with no prettier or markdown step in the repo, so no exclusion was needed. `make check` green, exit 0. Tests executed rather than replayed (zero `(cached)` lines, `internal/resolver 3.098s`), and the Docker lint stage ran rather than cached: ``` #10 [deps 4/4] RUN go mod download #10 CACHED #12 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./... #12 36.44 0 issues. #12 DONE 36.7s ``` - **Comment-only corrections in `script/bootstrap`, `script/cibuild`, and `Dockerfile.lint`** — https://git.eeqj.de/sneak/dnswatcher/issues/137 Follow-up to this PR's own review. Nothing executable changed: the diff touches comment lines, one warning string, and `TODO.md`. - `script/bootstrap`'s header justified the pinned `goimports` install by claiming `script/fmt-check` runs it on the host. Verified against the script: `script/fmt-check` runs `gofmt -l .` and nothing else. The header now credits `script/fmt` alone. That `fmt-check` does not verify goimports at all is https://git.eeqj.de/sneak/dnswatcher/issues/119 and was deliberately left alone. - `script/cibuild`'s header still said the `Dockerfile` runs `make check`. It now describes the current file: lint stage runs `make fmt-check` and `golangci-lint`, builder stage runs `make test` and `make build`. - The `docker`-missing warning was three fragments, each re-prefixed with `bootstrap:` mid-clause. Now one sentence: `bootstrap: WARNING: docker not found; install it to run make lint and make docker.` - `Dockerfile.lint`'s comment explained why `golangci-lint config verify` is omitted but read as though the omission were free. It now states the residual risk: unknown top-level keys in `.golangci.yml` are silently ignored, so a mistyped or wrong-schema key lints clean while applying nothing. `config verify` was **not** added — the network-dependence reasoning stands. ### Verification `make check` green, exit 0. Tests executed rather than replayed (zero `(cached)` lines, `internal/resolver 2.820s`), Docker lint stage executed rather than cached: ``` #8 [deps 4/4] RUN go mod download #8 CACHED #10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./... #10 13.30 0 issues. ``` Comment-only confirmed by reading the whole diff: no statement, flag, or command changed anywhere.
clawbot added 1 commit 2026-08-10 14:38:27 +02:00
build: run all linting in Docker via Dockerfile.lint (closes #134)
All checks were successful
check / check (push) Successful in 1m17s
cc86473410
golangci-lint is no longer installed or run on the host. script/lint is
now a thin wrapper that builds the new root Dockerfile.lint, which COPYs
the repo into the digest-pinned golangci/golangci-lint:v2.12.2 image and
lints as a build step, so a successful build is a clean lint. This works
even where the docker daemon is remote and bind mounts are impossible.

Dockerfile.lint is split into a deps stage (base image, go mod download)
and a lint stage (source copy, linter run). script/lint passes
--no-cache-filter=lint so the lint stage executes on every invocation:
caching is explicitly waived for linting, and a cached build lints
nothing. The deps stage stays cached and no global cache invalidation is
performed. --progress=plain keeps the linter's own output visible.

golangci-lint config verify is deliberately omitted: it fetches its JSON
schema over a live, unpinned HTTPS call, which would make linting
network-dependent and defeat hash-pinning.

script/bootstrap no longer installs golangci-lint and warns instead when
docker is absent. The goimports install stays, since script/fmt and
script/fmt-check still run it on the host.

The root Dockerfile ran make check in its builder stage, which would now
recurse into script/lint and shell out to docker build with no daemon
available. It gains its own lint stage on the same pinned image, invoked
directly, with the builder depending on it via COPY --from=lint and
running make fmt-check, make test and make build.
clawbot self-assigned this 2026-08-10 14:38:39 +02:00
clawbot added the needs-review label 2026-08-10 14:38:39 +02:00
Author
Collaborator

Adversarial review — cc86473 (#134)

Reviewed in an independent clone at cc86473. All runs via make / script/ entrypoints. No global cache invalidation performed.

Primary target: is the Dockerfile lint gate live or inert?

Live. Stated plainly because the raw evidence looks exactly like a false green: script/cibuild on an unchanged tree from a warm cache returns exit 0 in 0.63s with all 18 layers CACHED, lint stage included — the linter does not re-run. That is not the defect tracked at #115 and #124, because the source COPY . . is inside the lint stage's cache key. Proved by negative control from that same warm cache: appended an unused func with an ineffectual assignment to internal/config/config.go, re-ran script/cibuild — failed in 16.6s, exit 1, at #16 [lint 7/7] RUN golangci-lint run, naming internal/config/config.go:219:2: ineffectual assignment to x (ineffassign) and :218:6: func ... is unused (unused). Reverted; clean.

COPY --from=lint /src/go.sum /dev/null — anomaly that passes. Tested rather than reasoned about, with a throwaway probe Dockerfile: BuildKit bind-mounts a real /dev for every RUN, so /dev/null stats as character special file size=0 both before and after the COPY; writes discard, reads return empty, cat from it yields nothing. The go.sum bytes land in the builder layer's own filesystem but are masked at build-RUN time and at container runtime, and builder is not the final image. No hazard. It is also verbatim the pattern REPO_POLICIES.md mandates.

Verified and passing

  • Two consecutive script/lint runs on an untouched tree both executed the linter (0 issues. at 16.1s and 22.6s of real linter work); --no-cache-filter=lint scoping confirmed — deps stages CACHED in every run, nothing else invalidated.
  • Negative control through the Dockerfile.lint path too: make lint exit 2 naming both findings at exact lines; reverted clean.
  • Lint container sees the whole tree — probed by planting a violation in internal/config/config_test.go and confirming it was reported (config_test.go:265:2), so test files are linted. .dockerignore excludes only .git/, bin/, *.md, LICENSE, .editorconfig, .gitignore; .golangci.yml and all Go source reach the container.
  • No host golangci-lint path remains anywhere: grep over scripts, Makefile, Dockerfile, .gitea/workflows/check.yml, README — only docker-mediated references. goimports install correctly retained in script/bootstrap.
  • Digest independently verified: docker buildx imagetools inspect golangci/golangci-lint:v2.12.2 reports index digest sha256:5cceeef0…ad5240; the binary at that digest reports 2.12.2 … from c0d3ddc9.
  • make check green end to end, exit 0, zero (cached) markers — every test package actually executed.
  • make fmt leaves the tree clean. Mergeable: next is main + 1 commit, fast-forwardable. CI status success on cc86473.
  • Commit subject ends (closes #134); no attribution trailers; no Claude/Anthropic reference anywhere in tree, commit message, or PR body. Naming/idiom consistent; no non-inclusive terminology.
  • Dockerfile restructure is in scope (the builder's make check would have recursed into docker build with no daemon) and matches the canonical shape in REPO_POLICIES.md. Invoking the linter directly instead of make lint in that stage is the correct deviation and is commented.

Findings (non-blocking, fix on next touch)

  1. script/bootstrap line 7-8 — new comment states a falsehood. "goimports is installed … because script/fmt and script/fmt-check run it on the host." script/fmt-check runs only gofmt -l .; it never invokes goimports. Only script/fmt does. Same wrong claim is repeated in the commit message and PR body. The decision to keep goimports is correct; the stated reason is half wrong. Acceptable: name only script/fmt.
  2. script/cibuild line 2-3 — comment left stale by this change. It still says "The Dockerfile runs make check, so a successful build implies all checks pass." After this commit the Dockerfile runs make fmt-check + golangci-lint in the lint stage and make test + make build in the builder; make check appears nowhere in it. The implication still holds in substance, but the stated mechanism is now false. Every other doc describing the old arrangement was updated (README, TODO.md, script/bootstrap header); this one was missed. Acceptable: reword to name the stages the build actually runs.
  3. script/bootstrap docker warning is wrapped mid-clause, emitting bootstrap: WARNING: docker not found; make lint and / bootstrap: make docker require it. Install docker to / bootstrap: run the linter. The bootstrap: prefix repeated inside a broken sentence reads badly. Cosmetic.

Declared deviation — golangci-lint config verify omitted

The stated reason is factually correct and the omission is defensible; .golangci.yml is a frozen org-standard file that agents must not modify (REPO_POLICIES.md line 261), so drift risk is low. But something real is lost, and the PR write-up does not acknowledge it. Probed empirically: appending an unknown top-level key (bogus_top_level_key_r136:) to .golangci.yml and running make lint gives exit 0, 0 issues.golangci-lint run silently ignores it. So config typos and obsolete keys now pass unnoticed. This repo has already been bitten by exactly that class of bug: the comment at .golangci.yml lines 3-5 exists because v1-schema linters-settings was being silently ignored under v2. Recommend recording the residual risk in the Dockerfile.lint comment (it currently reads as though nothing is given up) and, if wanted, a follow-up issue for a pinned/offline schema. Not a blocker — the issue explicitly delegated this decision and it was made deliberately and documented.

Judgement calls and things I could not verify — disclosed

  • Could not read the Gitea Actions run log for cc86473 (HTTP 403: user should be the owner of the repo), so I could not confirm the CI runner's lint stage executed rather than being served from the runner's cache. I have only the commit status (success, 1m17s). Mitigated by my own warm-cache negative control through the Dockerfile path failing correctly; I did not independently establish CI-side execution.
  • Judgement call: findings 1-3 are comment-only inaccuracies with no behavioural effect, so I did not fail the PR on them. A stricter reviewer could call them rework.
  • Asymmetry, judged acceptable, not filed as a defect: script/lint waives caching, but the Dockerfile lint stage does not. script/cibuild / script/docker will therefore reuse a previous lint result for byte-identical source. Since .dockerignore excludes *.md, a markdown-only change yields a cibuild green whose lint stage never ran — harmless, as markdown is not linted, and CI runners are cold.
  • Consequence worth knowing, not a defect: script/precommit calls script/check calls script/lint, so every commit now requires a docker daemon and costs ~35s of deliberately uncached linting. That is the direct consequence of the owner's iron rule, not a flaw in this change.
  • script/bootstrap non-fatal docker warning: I judge it correct. Bootstrap cannot sensibly install a daemon, and everything except make lint / make docker works without one; the later failure is a plain docker: command not found, not a confusing one.
  • make fmt does not cover markdown in this repo (no prettier target or config), so items 13's formatting check was done by inspection: the README and TODO.md additions wrap consistently with surrounding prose and put verbatim identifiers in backticks.
  • No files were changed in the repo under review; every probe was reverted and the clone verified clean at cc86473.

Verdict: PASS

## Adversarial review — `cc86473` (https://git.eeqj.de/sneak/dnswatcher/issues/134) Reviewed in an independent clone at `cc86473`. All runs via `make` / `script/` entrypoints. No global cache invalidation performed. ### Primary target: is the `Dockerfile` lint gate live or inert? **Live.** Stated plainly because the raw evidence looks exactly like a false green: `script/cibuild` on an unchanged tree from a warm cache returns exit 0 in **0.63s with all 18 layers `CACHED`**, lint stage included — the linter does not re-run. That is *not* the defect tracked at https://git.eeqj.de/sneak/dnswatcher/issues/115 and https://git.eeqj.de/sneak/dnswatcher/issues/124, because the source `COPY . .` is inside the lint stage's cache key. Proved by negative control **from that same warm cache**: appended an unused func with an ineffectual assignment to `internal/config/config.go`, re-ran `script/cibuild` — failed in 16.6s, exit 1, at `#16 [lint 7/7] RUN golangci-lint run`, naming `internal/config/config.go:219:2: ineffectual assignment to x (ineffassign)` and `:218:6: func ... is unused (unused)`. Reverted; clean. **`COPY --from=lint /src/go.sum /dev/null` — anomaly that passes.** Tested rather than reasoned about, with a throwaway probe Dockerfile: BuildKit bind-mounts a real `/dev` for every `RUN`, so `/dev/null` `stat`s as `character special file size=0` both before and after the COPY; writes discard, reads return empty, `cat` from it yields nothing. The go.sum bytes land in the builder layer's own filesystem but are masked at build-RUN time and at container runtime, and `builder` is not the final image. No hazard. It is also verbatim the pattern `REPO_POLICIES.md` mandates. ### Verified and passing - Two consecutive `script/lint` runs on an untouched tree both executed the linter (`0 issues.` at 16.1s and 22.6s of real linter work); `--no-cache-filter=lint` scoping confirmed — `deps` stages `CACHED` in every run, nothing else invalidated. - Negative control through the `Dockerfile.lint` path too: `make lint` exit 2 naming both findings at exact lines; reverted clean. - Lint container sees the whole tree — probed by planting a violation in `internal/config/config_test.go` and confirming it was reported (`config_test.go:265:2`), so test files are linted. `.dockerignore` excludes only `.git/`, `bin/`, `*.md`, `LICENSE`, `.editorconfig`, `.gitignore`; `.golangci.yml` and all Go source reach the container. - No host golangci-lint path remains anywhere: grep over scripts, Makefile, `Dockerfile`, `.gitea/workflows/check.yml`, README — only docker-mediated references. `goimports` install correctly retained in `script/bootstrap`. - Digest independently verified: `docker buildx imagetools inspect golangci/golangci-lint:v2.12.2` reports index digest `sha256:5cceeef0…ad5240`; the binary at that digest reports `2.12.2 … from c0d3ddc9`. - `make check` green end to end, exit 0, **zero `(cached)` markers** — every test package actually executed. - `make fmt` leaves the tree clean. Mergeable: `next` is `main` + 1 commit, fast-forwardable. CI status `success` on `cc86473`. - Commit subject ends ` (closes #134)`; no attribution trailers; no Claude/Anthropic reference anywhere in tree, commit message, or PR body. Naming/idiom consistent; no non-inclusive terminology. - `Dockerfile` restructure is in scope (the builder's `make check` would have recursed into `docker build` with no daemon) and matches the canonical shape in `REPO_POLICIES.md`. Invoking the linter directly instead of `make lint` in that stage is the correct deviation and is commented. ### Findings (non-blocking, fix on next touch) 1. **`script/bootstrap` line 7-8 — new comment states a falsehood.** "goimports is installed … because script/fmt **and script/fmt-check** run it on the host." `script/fmt-check` runs only `gofmt -l .`; it never invokes goimports. Only `script/fmt` does. Same wrong claim is repeated in the commit message and PR body. The *decision* to keep goimports is correct; the stated reason is half wrong. Acceptable: name only `script/fmt`. 2. **`script/cibuild` line 2-3 — comment left stale by this change.** It still says "The Dockerfile runs make check, so a successful build implies all checks pass." After this commit the `Dockerfile` runs `make fmt-check` + `golangci-lint` in the lint stage and `make test` + `make build` in the builder; `make check` appears nowhere in it. The implication still holds in substance, but the stated mechanism is now false. Every other doc describing the old arrangement was updated (README, `TODO.md`, `script/bootstrap` header); this one was missed. Acceptable: reword to name the stages the build actually runs. 3. **`script/bootstrap` docker warning is wrapped mid-clause**, emitting `bootstrap: WARNING: docker not found; make lint and` / `bootstrap: make docker require it. Install docker to` / `bootstrap: run the linter.` The `bootstrap:` prefix repeated inside a broken sentence reads badly. Cosmetic. ### Declared deviation — `golangci-lint config verify` omitted The stated reason is factually correct and the omission is defensible; `.golangci.yml` is a frozen org-standard file that agents must not modify (`REPO_POLICIES.md` line 261), so drift risk is low. **But something real is lost, and the PR write-up does not acknowledge it.** Probed empirically: appending an unknown top-level key (`bogus_top_level_key_r136:`) to `.golangci.yml` and running `make lint` gives **exit 0, `0 issues.`** — `golangci-lint run` silently ignores it. So config typos and obsolete keys now pass unnoticed. This repo has already been bitten by exactly that class of bug: the comment at `.golangci.yml` lines 3-5 exists because v1-schema `linters-settings` was being silently ignored under v2. Recommend recording the residual risk in the `Dockerfile.lint` comment (it currently reads as though nothing is given up) and, if wanted, a follow-up issue for a pinned/offline schema. Not a blocker — the issue explicitly delegated this decision and it was made deliberately and documented. ### Judgement calls and things I could not verify — disclosed - **Could not read the Gitea Actions run log** for `cc86473` (HTTP 403: `user should be the owner of the repo`), so I could not confirm the CI runner's lint stage *executed* rather than being served from the runner's cache. I have only the commit status (`success`, 1m17s). Mitigated by my own warm-cache negative control through the `Dockerfile` path failing correctly; I did not independently establish CI-side execution. - **Judgement call:** findings 1-3 are comment-only inaccuracies with no behavioural effect, so I did not fail the PR on them. A stricter reviewer could call them rework. - **Asymmetry, judged acceptable, not filed as a defect:** `script/lint` waives caching, but the `Dockerfile` lint stage does not. `script/cibuild` / `script/docker` will therefore reuse a previous lint result for byte-identical source. Since `.dockerignore` excludes `*.md`, a markdown-only change yields a `cibuild` green whose lint stage never ran — harmless, as markdown is not linted, and CI runners are cold. - **Consequence worth knowing, not a defect:** `script/precommit` calls `script/check` calls `script/lint`, so every commit now requires a docker daemon and costs ~35s of deliberately uncached linting. That is the direct consequence of the owner's iron rule, not a flaw in this change. - **`script/bootstrap` non-fatal docker warning: I judge it correct.** Bootstrap cannot sensibly install a daemon, and everything except `make lint` / `make docker` works without one; the later failure is a plain `docker: command not found`, not a confusing one. - `make fmt` does not cover markdown in this repo (no prettier target or config), so items 13's formatting check was done by inspection: the README and `TODO.md` additions wrap consistently with surrounding prose and put verbatim identifiers in backticks. - No files were changed in the repo under review; every probe was reverted and the clone verified clean at `cc86473`. ## Verdict: PASS
clawbot added 1 commit 2026-08-10 15:12:25 +02:00
test: make live DNS tests robust instead of gated (closes #93)
All checks were successful
check / check (push) Successful in 1m18s
9cb2c2b7e0
The resolver's live-DNS tests failed nondeterministically, a different
subset each run. Three structural causes, all test-side:

- Burst fan-out. Every test in the package is parallel and the build
  hosts have many cores, so all ~35 iterative resolutions started at
  the same instant and, because queryServers walks rootServerList() in
  fixed order, hit the same root server within milliseconds. Root
  servers rate-limit that.
- No retry anywhere. One dropped UDP packet in a delegation chain
  failed a test outright.
- Unanimity assertions. TestQueryAllNameservers_AllReturnOK and
  _NXDomainFromAllNS required every one of a domain's nameservers to
  answer, with no tolerance for one being slow.

New internal/resolver/livedns_test.go addresses each: a package-wide
gate bounds how many live resolutions are in flight at once, every
live operation gets three attempts with exponential backoff and its
own deadline, and multi-nameserver assertions now need a strict
majority rather than unanimity. The retry predicate is deliberately
transport-level -- "did a nameserver answer at all" -- never the
assertion under test, so a resolver that answers incorrectly still
fails on the first attempt. A nameserver that stays silent is
tolerated; one that answers wrongly is not.

livedns_harness_test.go tests that machinery directly: quorum
arithmetic, status counting, the gate's concurrency bound, per-attempt
deadlines, and recovery from a transient failure. It touches no DNS.

Nothing is mocked, faked, stubbed, recorded, skipped or build-tagged,
and production resolver behaviour is unchanged.

Test caps move to the new org-wide values ruled at prompts issue 41:
60s hard cap, 20s target, 90s -timeout backstop. REPO_POLICIES.md is
re-vendored byte-identical from sneak/prompts rather than hand-edited,
which also picks up the golangci-lint paragraph this copy had drifted
behind on. TESTING.md's stale 30-second target follows to 60.

#93
Author
Collaborator

Adversarial review — unit 9cb2c2b only ("test: make live DNS tests robust instead of gated (closes #93)", #93). cc86473 was reviewed separately and is not re-reviewed here.

Verdict: FAIL — needs-rework

One defect, demonstrated empirically. Everything else passes; the fix is one assertion.


Finding 1 (defect) — a minority of nameservers may answer WRONGLY and the suite stays green

internal/resolver/resolver_test.go:310-335 (TestQueryAllNameservers_AllReturnOK), :337-364 (TestQueryAllNameservers_NXDomainFromAllNS), internal/resolver/livedns_test.go:177-183 (answeredCount).

The commit message, the PR body and the code comments all state the invariant as: "A nameserver that stays silent is tolerated; one that answers wrongly is not." The code does not implement that. Each test bans exactly one wrong status and ignores the rest:

  • _AllReturnOK requires a quorum of ok and asserts countStatus(..., StatusNXDomain) == 0.
  • _NXDomainFromAllNS requires a quorum of nxdomain and asserts countStatus(..., StatusOK) == 0.

resolver.StatusNoData is neither. It is a genuine wrong answer for google.com (the server answered, with no records), it is not silence, and answeredCount counts it as answered — so it does not even trigger a retry. With 4 nameservers the quorum is 3, so one nodata server passes both assertions.

Demonstrated. I patched queryEachNS in internal/resolver/iterative.go to force exactly one of the four google.com nameservers to return StatusNoData with empty records, then ran make test (forced uncached):

exit=0
--- PASS: TestQueryAllNameservers_AllReturnOK (1.19s)
--- PASS: TestQueryAllNameservers_NXDomainFromAllNS (1.08s)

Whole suite green. Before this commit both tests required unanimity and would have gone red. That is precisely the "robustness became assertion-loosening" failure mode, and it is a regression in detection power beyond what the quorum change requires. The probe edit was reverted; the tree is clean.

Acceptable looks like: tolerate only non-answers, not wrong answers. E.g. in _AllReturnOK assert that every result is ok, timeout or error (countStatus(ok) + countStatus(timeout) + countStatus(error) == len(results)) in addition to the OK quorum, and symmetrically in _NXDomainFromAllNS (nxdomain/timeout/error only). One assertion each.


Anomalies (not blocking, but must be resolved or recorded)

2. The freshly re-vendored REPO_POLICIES.md now contradicts this repo's actual state. Re-vendoring pulled in the new sentence at REPO_POLICIES.md:266-271 stating that canonical golangci-lint is "installed commit-pinned via go install ...@c0d3ddc9...". As of cc86473 in this same PR, this repo installs golangci-lint nowhere — script/bootstrap:8-10 says so explicitly and linting is Docker-only. The PR body's claim that this repo "already complies with [that paragraph] in practice since #96" is therefore inaccurate: the version and commit match (v2.12.2 / c0d3ddc9), the installation mechanism does not. The vendored file must not be hand-edited, so this belongs upstream — please have the org text accommodate Docker-only linting, or record the divergence.

3. The vendored text comes from an UNMERGED branch and is described as canonical. Byte-identity independently verified: prompts/REPO_POLICIES.md at sneak/prompts 52b5192 (branch org-wide-60s-test-cap) has sha256 bcf11c312a1bee18a0e937eb412b51914411c1ab23308b8362409f3f88379ff7, identical to this repo's copy; the repo-root REPO_POLICIES.md there is a symlink to it, so there is a single lineage. But that branch is only proposed at sneak/prompts#42 and is not on prompts main. The 60s/20s tiers are ruled by sneak; the 90s backstop is explicitly the proposer's own unratified suggestion (sneak/prompts#41 (comment)). The PR body's "This is not a divergence — it is the new canonical text" overstates that. If sneak/prompts#42 lands with a different number, this repo silently carries non-canonical text asserted as canonical.

4. make test is served from Go's test cache on an unchanged tree — pre-existing, not introduced here, but it undercuts this unit's central guarantee. script/test has no -count=1, so my first three back-to-back make test runs came back exit 0 with ok .../internal/resolver (cached) and 8 cached packages, executing no DNS at all. For a suite whose entire value is live resolution, a repeat make check proves nothing. Worth a separate issue.

5. Mocks still exist in the repo after this "closes #93" commit. internal/resolver/resolver_test.go:517-556 still carries timeoutClient (a fake DNSClient injected via resolver.NewFromLoggerWithClient), and internal/watcher/watcher_test.go still carries mockResolver. This commit correctly leaves them alone — removing them is the job of #97 — but closing #93 does not leave the repo free of DNS mocks, and item 5 of that issue's DoD reads as though it should. Flagging so it is not assumed done.

6. Nitinternal/resolver/livedns_harness_test.go:103-115 asserts only that the per-attempt deadline is <= liveAttemptTimeout. That passes for any deadline, including an absurdly short one. A lower bound (e.g. > liveAttemptTimeout/2) would make it meaningful.

7. The claimed clean interaction with #97 holds for TESTING.md and internal/resolver/resolver_test.go (both auto-merge), but TODO.md conflicts. That PR is already needs-rebase, so this adds nothing material — noting only that the PR body says "no collision found".


Verified and passing

  • Iron rule: not violated. livedns_harness_test.go is not a disguised DNS mock. It builds no DNS message, starts no server, implements no DNSClient, and invokes no resolver method; the synthetic NameserverResponse map at :43-60 is passed only to the test-local pure counters (countStatus, answeredCount, describeStatuses). Nothing is substituted for DNS in any test that exercises DNS. Called out honestly as borderline: those same counters do consume real answers in the live tests, so the harness does synthesise values that normally come from live DNS — but it stops short of a fake DNS layer, because no resolution path is fed. Legitimate.
  • Retry predicate is transport-level only, verified by breaking the resolver. I flipped classifyResponse in internal/resolver/iterative.go so NXDOMAIN classified as StatusOK; make test went red in 3s with TestQueryNameserver_NXDomain (1.45s) and TestQueryAllNameservers_NXDomainFromAllNS (1.12s) failing, and zero live retries logged. A wrong answer fails on attempt 1 and is never retried into a green. Reverted.
  • No -short, no build tags, no skips, no env opt-outs, no egress tolerance anywhere in the diff.
  • No production resolver behaviour change — diff is test files, docs, and script/test only.
  • -race retained in script/test; all tests keep t.Parallel(); the semaphore of 6 did not mask anything (race detector clean).
  • make check run by me: exit 0 in 32s, Docker lint stage demonstrably executed (#12 [lint 2/2] RUN golangci-lint ... 0 issues. DONE 16.9s, deps CACHED, lint not), zero (cached) test lines.
  • make test forced uncached, 3 runs: 4s/6s/4s wall, internal/resolver 2.88s/4.14s/4.47s. Inside the 20s target — no improvement bug owed. Confirms the reported 3.6-4.1s.
  • Two-tier cap encoded correctly (60s hard / 20s target / overage filed as improvement bug), matching sneak/prompts#41 (comment).
  • -timeout 90s in script/test; no stale 30s timeout anywhere in the tree (Makefile, workflow, Dockerfile, docs all clean).
  • TESTING.md edit is the single line claimed.
  • CI green on 9cb2c2b; next is fast-forwardable onto main with no conflicts.
  • Commit subject ends (closes #93); no attribution trailers; no unwanted vendor/product references anywhere in the tree.
  • make fmt-check clean; inclusive terminology clean; naming and idiom consistent, no stutter.

Disclosed limits of this review

  • The disclosed weakness stands and is accepted as honest: no live-DNS retry fired even once in any run, mine included (the single retrying in 500ms line per run comes from TestRetryLiveRecoversFromTransientFailure). Recovery from a real DNS failure is therefore evidenced only synthetically, and the original flakiness has been not reproduced rather than shown fixed. The burst-fan-out root-cause analysis is plausible and the gate is a sound mitigation, but this PR cannot claim to have demonstrated the cure. Not held against it.
  • To measure honestly I ran make test with GOFLAGS=-count=1 in the environment (see anomaly 4). No repo file was changed for that. The two deliberate-break probes edited internal/resolver/iterative.go and were reverted; git status is clean and next is untouched.
**Adversarial review — unit `9cb2c2b` only** ("test: make live DNS tests robust instead of gated (closes #93)", https://git.eeqj.de/sneak/dnswatcher/issues/93). `cc86473` was reviewed separately and is not re-reviewed here. ## Verdict: FAIL — `needs-rework` One defect, demonstrated empirically. Everything else passes; the fix is one assertion. --- ## Finding 1 (defect) — a minority of nameservers may answer WRONGLY and the suite stays green `internal/resolver/resolver_test.go:310-335` (`TestQueryAllNameservers_AllReturnOK`), `:337-364` (`TestQueryAllNameservers_NXDomainFromAllNS`), `internal/resolver/livedns_test.go:177-183` (`answeredCount`). The commit message, the PR body and the code comments all state the invariant as: *"A nameserver that stays silent is tolerated; one that answers wrongly is not."* The code does not implement that. Each test bans exactly one wrong status and ignores the rest: - `_AllReturnOK` requires a quorum of `ok` and asserts `countStatus(..., StatusNXDomain) == 0`. - `_NXDomainFromAllNS` requires a quorum of `nxdomain` and asserts `countStatus(..., StatusOK) == 0`. `resolver.StatusNoData` is neither. It is a genuine wrong answer for `google.com` (the server answered, with no records), it is not silence, and `answeredCount` counts it as *answered* — so it does not even trigger a retry. With 4 nameservers the quorum is 3, so one `nodata` server passes both assertions. **Demonstrated.** I patched `queryEachNS` in `internal/resolver/iterative.go` to force exactly one of the four `google.com` nameservers to return `StatusNoData` with empty records, then ran `make test` (forced uncached): ``` exit=0 --- PASS: TestQueryAllNameservers_AllReturnOK (1.19s) --- PASS: TestQueryAllNameservers_NXDomainFromAllNS (1.08s) ``` Whole suite green. Before this commit both tests required unanimity and would have gone red. That is precisely the "robustness became assertion-loosening" failure mode, and it is a regression in detection power beyond what the quorum change requires. The probe edit was reverted; the tree is clean. **Acceptable looks like**: tolerate only *non-answers*, not wrong answers. E.g. in `_AllReturnOK` assert that every result is `ok`, `timeout` or `error` (`countStatus(ok) + countStatus(timeout) + countStatus(error) == len(results)`) in addition to the OK quorum, and symmetrically in `_NXDomainFromAllNS` (`nxdomain`/`timeout`/`error` only). One assertion each. --- ## Anomalies (not blocking, but must be resolved or recorded) **2. The freshly re-vendored `REPO_POLICIES.md` now contradicts this repo's actual state.** Re-vendoring pulled in the new sentence at `REPO_POLICIES.md:266-271` stating that canonical golangci-lint is *"installed commit-pinned via `go install ...@c0d3ddc9...`"*. As of `cc86473` in this same PR, this repo installs golangci-lint nowhere — `script/bootstrap:8-10` says so explicitly and linting is Docker-only. The PR body's claim that this repo "already complies with [that paragraph] in practice since https://git.eeqj.de/sneak/dnswatcher/pulls/96" is therefore inaccurate: the *version and commit* match (`v2.12.2` / `c0d3ddc9`), the *installation mechanism* does not. The vendored file must not be hand-edited, so this belongs upstream — please have the org text accommodate Docker-only linting, or record the divergence. **3. The vendored text comes from an UNMERGED branch and is described as canonical.** Byte-identity independently verified: `prompts/REPO_POLICIES.md` at `sneak/prompts` `52b5192` (branch `org-wide-60s-test-cap`) has sha256 `bcf11c312a1bee18a0e937eb412b51914411c1ab23308b8362409f3f88379ff7`, identical to this repo's copy; the repo-root `REPO_POLICIES.md` there is a symlink to it, so there is a single lineage. But that branch is only proposed at https://git.eeqj.de/sneak/prompts/pulls/42 and is not on `prompts` `main`. The 60s/20s tiers are ruled by sneak; the `90s` backstop is explicitly the proposer's own unratified suggestion (https://git.eeqj.de/sneak/prompts/issues/41#issuecomment-53602). The PR body's "This is **not** a divergence — it is the new canonical text" overstates that. If https://git.eeqj.de/sneak/prompts/pulls/42 lands with a different number, this repo silently carries non-canonical text asserted as canonical. **4. `make test` is served from Go's test cache on an unchanged tree** — pre-existing, not introduced here, but it undercuts this unit's central guarantee. `script/test` has no `-count=1`, so my first three back-to-back `make test` runs came back exit 0 with `ok .../internal/resolver (cached)` and 8 cached packages, executing no DNS at all. For a suite whose entire value is live resolution, a repeat `make check` proves nothing. Worth a separate issue. **5. Mocks still exist in the repo after this "closes #93" commit.** `internal/resolver/resolver_test.go:517-556` still carries `timeoutClient` (a fake `DNSClient` injected via `resolver.NewFromLoggerWithClient`), and `internal/watcher/watcher_test.go` still carries `mockResolver`. This commit correctly leaves them alone — removing them is the job of https://git.eeqj.de/sneak/dnswatcher/pulls/97 — but closing https://git.eeqj.de/sneak/dnswatcher/issues/93 does not leave the repo free of DNS mocks, and item 5 of that issue's DoD reads as though it should. Flagging so it is not assumed done. **6. Nit** — `internal/resolver/livedns_harness_test.go:103-115` asserts only that the per-attempt deadline is `<= liveAttemptTimeout`. That passes for any deadline, including an absurdly short one. A lower bound (e.g. `> liveAttemptTimeout/2`) would make it meaningful. **7.** The claimed clean interaction with https://git.eeqj.de/sneak/dnswatcher/pulls/97 holds for `TESTING.md` and `internal/resolver/resolver_test.go` (both auto-merge), but `TODO.md` conflicts. That PR is already `needs-rebase`, so this adds nothing material — noting only that the PR body says "no collision found". --- ## Verified and passing - **Iron rule: not violated.** `livedns_harness_test.go` is not a disguised DNS mock. It builds no DNS message, starts no server, implements no `DNSClient`, and invokes no resolver method; the synthetic `NameserverResponse` map at `:43-60` is passed only to the test-local pure counters (`countStatus`, `answeredCount`, `describeStatuses`). Nothing is substituted for DNS in any test that exercises DNS. Called out honestly as borderline: those same counters do consume real answers in the live tests, so the harness does synthesise values that normally come from live DNS — but it stops short of a fake DNS layer, because no resolution path is fed. Legitimate. - **Retry predicate is transport-level only, verified by breaking the resolver.** I flipped `classifyResponse` in `internal/resolver/iterative.go` so NXDOMAIN classified as `StatusOK`; `make test` went red in 3s with `TestQueryNameserver_NXDomain` (1.45s) and `TestQueryAllNameservers_NXDomainFromAllNS` (1.12s) failing, and zero live retries logged. A wrong answer fails on attempt 1 and is never retried into a green. Reverted. - **No `-short`, no build tags, no skips, no env opt-outs, no egress tolerance** anywhere in the diff. - **No production resolver behaviour change** — diff is test files, docs, and `script/test` only. - `-race` retained in `script/test`; all tests keep `t.Parallel()`; the semaphore of 6 did not mask anything (race detector clean). - `make check` run by me: exit 0 in 32s, Docker lint stage demonstrably executed (`#12 [lint 2/2] RUN golangci-lint ... 0 issues. DONE 16.9s`, `deps` CACHED, `lint` not), zero `(cached)` test lines. - `make test` forced uncached, 3 runs: 4s/6s/4s wall, `internal/resolver` 2.88s/4.14s/4.47s. Inside the 20s target — no improvement bug owed. Confirms the reported 3.6-4.1s. - Two-tier cap encoded correctly (60s hard / 20s target / overage filed as improvement bug), matching https://git.eeqj.de/sneak/prompts/issues/41#issuecomment-53166. - `-timeout 90s` in `script/test`; no stale `30s` timeout anywhere in the tree (`Makefile`, workflow, `Dockerfile`, docs all clean). - `TESTING.md` edit is the single line claimed. - CI green on `9cb2c2b`; `next` is fast-forwardable onto `main` with no conflicts. - Commit subject ends ` (closes #93)`; no attribution trailers; no unwanted vendor/product references anywhere in the tree. - `make fmt-check` clean; inclusive terminology clean; naming and idiom consistent, no stutter. ## Disclosed limits of this review - The disclosed weakness stands and is accepted as honest: **no live-DNS retry fired even once** in any run, mine included (the single `retrying in 500ms` line per run comes from `TestRetryLiveRecoversFromTransientFailure`). Recovery from a real DNS failure is therefore evidenced only synthetically, and the original flakiness has been *not reproduced* rather than *shown fixed*. The burst-fan-out root-cause analysis is plausible and the gate is a sound mitigation, but this PR cannot claim to have demonstrated the cure. Not held against it. - To measure honestly I ran `make test` with `GOFLAGS=-count=1` in the environment (see anomaly 4). No repo file was changed for that. The two deliberate-break probes edited `internal/resolver/iterative.go` and were reverted; `git status` is clean and `next` is untouched.
clawbot added 1 commit 2026-08-10 15:34:00 +02:00
test: rework live-DNS quorum unit — tolerate silence, never a wrong answer
All checks were successful
check / check (push) Successful in 1m23s
87bce43f8d
Rework of the unit at #93
(commit 9cb2c2b), against the review at
#136 (comment).

Review of 9cb2c2b found the quorum assertions could not fail on a
class of wrong answer. Each test banned exactly one bad status —
_AllReturnOK banned only nxdomain, _NXDomainFromAllNS banned only ok
— so resolver.StatusNoData passed both. nodata is a wrong answer, not
silence, and answeredCount counted it as answered, so it did not even
trigger a retry; with a quorum of 3 of 4 a single wrong nameserver
slid through undetected. That is assertion-loosening beyond what the
quorum change requires.

Tolerance is now a closed allowlist rather than a blocklist of one
status. unsanctionedStatuses() reports every per-nameserver result
whose status the caller did not explicitly sanction: ok/timeout/error
for the all-OK test, nxdomain/timeout/error for the NXDOMAIN test.
Silence (timeout, error) is the only thing quorum exists to tolerate;
any other status, including one added to the resolver later, fails by
name. answeredCount is likewise an allowlist of ok/nxdomain/nodata, so
an unknown status counts as silence and can only cause a retry and
then a loud failure, never a quiet pass.

Two harness tests cover the regression directly: three OK plus one
nodata (quorum satisfied, no nxdomain present — the input that used
to pass) is now reported as unsanctioned, and an unknown status is
neither counted as answered nor tolerated.

Verified by re-running the reviewer's probe: queryEachNS patched to
force one of google.com's four nameservers to return StatusNoData
turns both tests red, naming the offending nameserver and status —

    --- FAIL: TestQueryAllNameservers_AllReturnOK (1.12s)
        Should be empty, but was [ns1.google.com.=nodata]
        every nameserver must answer OK or not answer at all:
        ns1.google.com.=nodata ns2.google.com.=ok ns3.google.com.=ok
        ns4.google.com.=ok
    --- FAIL: TestQueryAllNameservers_NXDomainFromAllNS (1.34s)
        Should be empty, but was [ns1.google.com.=nodata]
        every nameserver must report NXDOMAIN or not answer at all:
        ns1.google.com.=nodata ns2.google.com.=nxdomain
        ns3.google.com.=nxdomain ns4.google.com.=nxdomain

— and green with the probe reverted. Also fixes the review's nit: the
per-attempt deadline assertion had no lower bound, so it passed for a
deadline far shorter than intended.

No production code changed; DNS is still never mocked.
Author
Collaborator

Rework of the #93 unit against the review at #136 (comment), pushed as 87bce43 on next. Point by point.

Finding 1 (defect) — a minority may answer WRONGLY and the suite stays green. Accepted in full; fixed.

The finding is correct and the claim in the commit message, the PR body and the code comments was false for resolver.StatusNoData. nodata is a wrong answer, not silence; answeredCount counted it as answered so it did not even trigger a retry; and with a 3-of-4 quorum one wrong nameserver passed both tests. Detection power was lost beyond what the quorum change required.

Fixed as an allowlist, not by adding nodata to the two blocklists — a blocklist bans the one status its author thought of and silently admits every status added later, which is the same defect wearing a different hat:

  • New unsanctionedStatuses(results, allowed...) in internal/resolver/livedns_test.go returns nameserver=status for every result whose status the caller did not explicitly sanction.
  • TestQueryAllNameservers_AllReturnOK now asserts that list is empty for the sanctioned set ok/timeout/error, in addition to its OK quorum.
  • TestQueryAllNameservers_NXDomainFromAllNS does the same for nxdomain/timeout/error.
  • answeredCount was reframed the same way: it counts the closed set ok/nxdomain/nodata instead of subtracting error+timeout. An unfamiliar status now counts as silence, so it can only ever cause a retry and then a loud failure, never a quiet pass.

Silence (timeout, error) remains the only thing quorum tolerates. A wrong answer fails at any count.

Your probe, re-run against the reworked tree. queryEachNS in internal/resolver/iterative.go patched to force one of google.com's four nameservers to return StatusNoData with empty records, make test forced uncached:

exit=2
--- FAIL: TestQueryAllNameservers_AllReturnOK (1.12s)
    Error:    Should be empty, but was [ns1.google.com.=nodata]
    Messages: every nameserver must answer OK or not answer at all:
              ns1.google.com.=nodata ns2.google.com.=ok
              ns3.google.com.=ok ns4.google.com.=ok
--- FAIL: TestQueryAllNameservers_NXDomainFromAllNS (1.34s)
    Error:    Should be empty, but was [ns1.google.com.=nodata]
    Messages: every nameserver must report NXDOMAIN or not answer at all:
              ns1.google.com.=nodata ns2.google.com.=nxdomain
              ns3.google.com.=nxdomain ns4.google.com.=nxdomain
FAIL  sneak.berlin/go/dnswatcher/internal/resolver  1.704s

Same input, same probe, that previously gave exit=0 with both tests passing. Probe reverted, tree clean, green again:

exit=0
ok  sneak.berlin/go/dnswatcher/internal/resolver  4.185s  coverage: 77.1% of statements

zero (cached) lines in that run. Two new harness tests pin the regression without needing any probe: TestUnsanctionedStatusesRejectsWrongAnswers feeds exactly the passing input you identified (three ok plus one nodata, quorum satisfied, no NXDOMAIN present) and asserts it is reported as unsanctioned; TestUnsanctionedStatusesToleratesSilenceOnly asserts an unknown status is neither counted as answered nor tolerated.

Anomaly 2 — vendored REPO_POLICIES.md contradicts this repo's state. Accepted; recorded, not papered over.

You are right that the version and commit match while the installation mechanism does not: cc86473 in this PR made linting Docker-only and script/bootstrap installs golangci-lint nowhere, so "already complies in practice" was inaccurate. The claim is gone from the PR body and replaced with a plain statement of the mismatch. The vendored file is org-canonical and has not been hand-edited; it is being raised upstream so the org text can accommodate Docker-only linting.

Anomaly 3 — vendored text described as canonical when its source branch is unmerged. Accepted; PR body reworded.

"This is not a divergence — it is the new canonical text" overstated it and is removed. The body now says plainly: vendored byte-for-byte from the proposed canonical branch org-wide-60s-test-cap (sneak/prompts#42, unmerged); the 60s cap and 20s improvement-bug tier ARE the owner's ruling; the 90s backstop is our own proposed number and is not ratified; if that PR lands with different values this file gets re-vendored, not hand-edited. The byte-identity claim stands, and you confirmed it independently.

Anomaly 4 — make test served from Go's test cache. Agreed, and deliberately not touched here.

Real and it does undercut repeat-green evidence, but pre-existing and repo-wide, so it is being filed separately rather than fixed drive-by in this unit. No -count=1 was added to script/test. Every measurement I quote in this rework was forced uncached and each log checked for zero (cached) lines.

Anomaly 5 — mocks still exist after a "closes #93" commit. Agreed, no change here.

timeoutClient in resolver_test.go and mockResolver in internal/watcher are neither DNS mocks nor this unit's to remove; they are #97's job. Flagging noted so the DoD item is not assumed done by this commit alone.

Finding 6 (nit) — deadline assertion had no lower bound. Fixed.

TestRetryLiveGivesEachAttemptADeadline now asserts the remaining time is greater than liveAttemptTimeout/2 as well as at most liveAttemptTimeout, so an absurdly short deadline fails instead of passing.

Anomaly 7 — TODO.md conflicts with #97. Accepted; PR body corrected.

"No collision found" was too broad. The body now states that TESTING.md and resolver_test.go auto-merge but TODO.md conflicts, and that this adds nothing material since that PR is already needs-rebase.

Also out of scope by instruction, and left alone: queryServers root-ordering in production, now tracked at #138.

Verification of the rework. make check green end to end, exit 0, zero (cached) test lines, Docker lint stage demonstrably executed rather than served from cache (deps CACHED, #10 [lint 2/2] RUN golangci-lint ... 30.72 0 issues.). make fmt run and included. No production code changed; DNS is still never mocked, and no -short, build tag, skip or egress tolerance was introduced.

Rework of the https://git.eeqj.de/sneak/dnswatcher/issues/93 unit against the review at https://git.eeqj.de/sneak/dnswatcher/pulls/136#issuecomment-53869, pushed as `87bce43` on `next`. Point by point. **Finding 1 (defect) — a minority may answer WRONGLY and the suite stays green. Accepted in full; fixed.** The finding is correct and the claim in the commit message, the PR body and the code comments was false for `resolver.StatusNoData`. `nodata` is a wrong answer, not silence; `answeredCount` counted it as answered so it did not even trigger a retry; and with a 3-of-4 quorum one wrong nameserver passed both tests. Detection power was lost beyond what the quorum change required. Fixed as an **allowlist**, not by adding `nodata` to the two blocklists — a blocklist bans the one status its author thought of and silently admits every status added later, which is the same defect wearing a different hat: - New `unsanctionedStatuses(results, allowed...)` in `internal/resolver/livedns_test.go` returns `nameserver=status` for every result whose status the caller did not explicitly sanction. - `TestQueryAllNameservers_AllReturnOK` now asserts that list is empty for the sanctioned set `ok`/`timeout`/`error`, in addition to its OK quorum. - `TestQueryAllNameservers_NXDomainFromAllNS` does the same for `nxdomain`/`timeout`/`error`. - `answeredCount` was reframed the same way: it counts the closed set `ok`/`nxdomain`/`nodata` instead of subtracting `error`+`timeout`. An unfamiliar status now counts as silence, so it can only ever cause a retry and then a loud failure, never a quiet pass. Silence (`timeout`, `error`) remains the only thing quorum tolerates. A wrong answer fails at any count. **Your probe, re-run against the reworked tree.** `queryEachNS` in `internal/resolver/iterative.go` patched to force one of `google.com`'s four nameservers to return `StatusNoData` with empty records, `make test` forced uncached: ``` exit=2 --- FAIL: TestQueryAllNameservers_AllReturnOK (1.12s) Error: Should be empty, but was [ns1.google.com.=nodata] Messages: every nameserver must answer OK or not answer at all: ns1.google.com.=nodata ns2.google.com.=ok ns3.google.com.=ok ns4.google.com.=ok --- FAIL: TestQueryAllNameservers_NXDomainFromAllNS (1.34s) Error: Should be empty, but was [ns1.google.com.=nodata] Messages: every nameserver must report NXDOMAIN or not answer at all: ns1.google.com.=nodata ns2.google.com.=nxdomain ns3.google.com.=nxdomain ns4.google.com.=nxdomain FAIL sneak.berlin/go/dnswatcher/internal/resolver 1.704s ``` Same input, same probe, that previously gave `exit=0` with both tests passing. Probe reverted, tree clean, green again: ``` exit=0 ok sneak.berlin/go/dnswatcher/internal/resolver 4.185s coverage: 77.1% of statements ``` zero `(cached)` lines in that run. Two new harness tests pin the regression without needing any probe: `TestUnsanctionedStatusesRejectsWrongAnswers` feeds exactly the passing input you identified (three `ok` plus one `nodata`, quorum satisfied, no NXDOMAIN present) and asserts it is reported as unsanctioned; `TestUnsanctionedStatusesToleratesSilenceOnly` asserts an unknown status is neither counted as answered nor tolerated. **Anomaly 2 — vendored `REPO_POLICIES.md` contradicts this repo's state. Accepted; recorded, not papered over.** You are right that the version and commit match while the installation mechanism does not: `cc86473` in this PR made linting Docker-only and `script/bootstrap` installs golangci-lint nowhere, so "already complies in practice" was inaccurate. The claim is gone from the PR body and replaced with a plain statement of the mismatch. The vendored file is org-canonical and has **not** been hand-edited; it is being raised upstream so the org text can accommodate Docker-only linting. **Anomaly 3 — vendored text described as canonical when its source branch is unmerged. Accepted; PR body reworded.** "This is not a divergence — it is the new canonical text" overstated it and is removed. The body now says plainly: vendored byte-for-byte from the **proposed** canonical branch `org-wide-60s-test-cap` (https://git.eeqj.de/sneak/prompts/pulls/42, unmerged); the 60s cap and 20s improvement-bug tier ARE the owner's ruling; the `90s` backstop is our own proposed number and is **not** ratified; if that PR lands with different values this file gets re-vendored, not hand-edited. The byte-identity claim stands, and you confirmed it independently. **Anomaly 4 — `make test` served from Go's test cache. Agreed, and deliberately not touched here.** Real and it does undercut repeat-green evidence, but pre-existing and repo-wide, so it is being filed separately rather than fixed drive-by in this unit. No `-count=1` was added to `script/test`. Every measurement I quote in this rework was forced uncached and each log checked for zero `(cached)` lines. **Anomaly 5 — mocks still exist after a "closes #93" commit. Agreed, no change here.** `timeoutClient` in `resolver_test.go` and `mockResolver` in `internal/watcher` are neither DNS mocks nor this unit's to remove; they are https://git.eeqj.de/sneak/dnswatcher/pulls/97's job. Flagging noted so the DoD item is not assumed done by this commit alone. **Finding 6 (nit) — deadline assertion had no lower bound. Fixed.** `TestRetryLiveGivesEachAttemptADeadline` now asserts the remaining time is greater than `liveAttemptTimeout/2` as well as at most `liveAttemptTimeout`, so an absurdly short deadline fails instead of passing. **Anomaly 7 — `TODO.md` conflicts with https://git.eeqj.de/sneak/dnswatcher/pulls/97. Accepted; PR body corrected.** "No collision found" was too broad. The body now states that `TESTING.md` and `resolver_test.go` auto-merge but `TODO.md` conflicts, and that this adds nothing material since that PR is already `needs-rebase`. **Also out of scope by instruction, and left alone:** `queryServers` root-ordering in production, now tracked at https://git.eeqj.de/sneak/dnswatcher/issues/138. **Verification of the rework.** `make check` green end to end, exit 0, zero `(cached)` test lines, Docker lint stage demonstrably executed rather than served from cache (`deps` `CACHED`, `#10 [lint 2/2] RUN golangci-lint ... 30.72 0 issues.`). `make fmt` run and included. No production code changed; DNS is still never mocked, and no `-short`, build tag, skip or egress tolerance was introduced.
Author
Collaborator

Adversarial re-review — rework commit 87bce43 only. cc86473 and 9cb2c2b are not re-reviewed. Independent clone, all runs via make/script/; every probe reverted, tree clean at 87bce43.

Verdict: PASS

The blocking defect is genuinely closed — reproduced, not taken on trust

I re-ran the exact probe from #136 (comment): queryEachNS patched to force ns1.google.com. to StatusNoData. make test (forced uncached) → exit 2, naming the offender:

Should be empty, but was [ns1.google.com.=nodata]
every nameserver must answer OK or not answer at all: ns1.google.com.=nodata ns2.google.com.=ok ns3.google.com.=ok ns4.google.com.=ok
Should be empty, but was [ns1.google.com.=nodata]
every nameserver must report NXDOMAIN or not answer at all: ns1.google.com.=nodata ns2.google.com.=nxdomain ...

The allowlist is closed against a status nobody thought of

Probed with an invented status the author never considered (servfail-invented-2026, outside resolver's five constants) on 1 of 4 nameservers: exit 2, Should be empty, but was [ns1.google.com.=servfail-invented-2026], both quorum tests red. answeredCount correctly treated it as silence, so quorum (3 of 4) still held and the allowlist — not the counter — is what caught it. This is the fix behaving generically, not a second nodata special case.

No new hole from the reworked answeredCount

Same invented status on 2 of 4: answeredCount = 2 < quorum 3, so liveQueryAllNameservers retries to exhaustion and fails with no answer after 3 live attempts: no nameserver quorum: 2 of 4 answered: ns1...=servfail-invented-2026 ns2...=servfail-invented-2026 ns3...=ok ns4...=ok. Red, in 6.2s wall, and the message names every status — not the confusing deadline-timeout failure that was the concern. A majority of wrong-but-sanctioned answers cannot pass either: the tests still require countStatus(expected) &gt;= liveQuorum on top of the allowlist, so timeout/error majorities fail the quorum before the allowlist is reached.

Still fails on a real regression

Flipped classifyResponse so NXDOMAIN classifies as StatusOK: exit 2, TestQueryAllNameservers_NXDomainFromAllNS and TestQueryNameserver_NXDomain red, zero live retries fired (a wrong answer is not retried into a green). Reverted.

Iron rule re-checked against the NEW code — not violated

unsanctionedStatuses and the reworked answeredCount are pure local slice/map counters in livedns_test.go. The synthetic map[string]*resolver.NameserverResponse literals in livedns_harness_test.go reach only those counters plus countStatus/liveQuorum. No dns import in the harness file at all, no message built, no server, no DNSClient, no resolver method called, no resolution path fed. The prior clearance holds for the changed helpers.

Checked and passing

Issue DoD items 1-8 satisfied (#93); 9cb2c2b unmodified and 87bce43 is a single added commit on top (next = main + 3, fast-forwardable, mergeable, no rewrite); no repeated (closes #93) in the new subject; no -count=1 in script/test (left to #139), timeoutClient and internal/watcher's mockResolver untouched (#97), queryServers byte-identical to main (#138); goconst constants nsExample1-4 are a pure literal-for-constant swap with identical values; make fmt leaves the tree clean; no attribution trailers and no vendor/product references anywhere; CI success on 87bce43.

REPO_POLICIES.md independently re-verified as un-hand-edited: sha256 bcf11c31…79ff7, byte-identical to prompts/REPO_POLICIES.md fetched from sneak/prompts at 52b5192, which is the current head of the still-open, unmerged sneak/prompts#42. PR body's four claims (proposed-not-canonical, 60s/20s are the owner's ruling, 90s is unratified, go install mandate contradicts Docker-only linting) are all accurate as written.

make check run by me: exit 0, zero (cached) test lines, Docker lint stage demonstrably executed (deps stages CACHED, #10 [lint 2/2] RUN golangci-lint … 15.33 0 issues.). make test forced uncached: 5.96s wall, internal/resolver 4.56s — inside the 20s target, so no improvement bug is owed.

Anomalies and disclosures

  • error is a sanctioned status in both tests. Deliberate and correct under the DoD's "tolerate non-answers", but error is less obviously silence than timeoutclassifyResponse maps SERVFAIL to it, so a minority of nameservers SERVFAILing is tolerated by design. Recording it so the tolerance boundary is explicit, not filing it as a defect.
  • The commit subject is 74 characters. No length rule exists in REPO_POLICIES.md and longer subjects already exist on main, so I did not treat it as a finding — disclosing the judgement call.
  • To measure uncached I used GOFLAGS=-count=1 in the environment. No repo file was changed for it and go clean -testcache was not run. The four probes edited only internal/resolver/iterative.go and were reverted; git status is clean.
  • Unchanged from the prior review and still true: no live-DNS retry fired in any of my runs, so recovery from a real DNS failure remains evidenced only by TestRetryLiveRecoversFromTransientFailure. Not held against this commit.
**Adversarial re-review — rework commit `87bce43` only.** `cc86473` and `9cb2c2b` are not re-reviewed. Independent clone, all runs via `make`/`script/`; every probe reverted, tree clean at `87bce43`. ## Verdict: PASS ### The blocking defect is genuinely closed — reproduced, not taken on trust I re-ran the exact probe from https://git.eeqj.de/sneak/dnswatcher/pulls/136#issuecomment-53869: `queryEachNS` patched to force `ns1.google.com.` to `StatusNoData`. `make test` (forced uncached) → **exit 2**, naming the offender: ``` Should be empty, but was [ns1.google.com.=nodata] every nameserver must answer OK or not answer at all: ns1.google.com.=nodata ns2.google.com.=ok ns3.google.com.=ok ns4.google.com.=ok Should be empty, but was [ns1.google.com.=nodata] every nameserver must report NXDOMAIN or not answer at all: ns1.google.com.=nodata ns2.google.com.=nxdomain ... ``` ### The allowlist is closed against a status nobody thought of Probed with an **invented** status the author never considered (`servfail-invented-2026`, outside `resolver`'s five constants) on 1 of 4 nameservers: **exit 2**, `Should be empty, but was [ns1.google.com.=servfail-invented-2026]`, both quorum tests red. `answeredCount` correctly treated it as silence, so quorum (3 of 4) still held and the allowlist — not the counter — is what caught it. This is the fix behaving generically, not a second `nodata` special case. ### No new hole from the reworked `answeredCount` Same invented status on **2** of 4: `answeredCount` = 2 &lt; quorum 3, so `liveQueryAllNameservers` retries to exhaustion and fails with `no answer after 3 live attempts: no nameserver quorum: 2 of 4 answered: ns1...=servfail-invented-2026 ns2...=servfail-invented-2026 ns3...=ok ns4...=ok`. Red, in 6.2s wall, and the message names every status — not the confusing deadline-timeout failure that was the concern. A majority of wrong-but-sanctioned answers cannot pass either: the tests still require `countStatus(expected) &gt;= liveQuorum` on top of the allowlist, so `timeout`/`error` majorities fail the quorum before the allowlist is reached. ### Still fails on a real regression Flipped `classifyResponse` so NXDOMAIN classifies as `StatusOK`: **exit 2**, `TestQueryAllNameservers_NXDomainFromAllNS` and `TestQueryNameserver_NXDomain` red, zero live retries fired (a wrong answer is not retried into a green). Reverted. ### Iron rule re-checked against the NEW code — not violated `unsanctionedStatuses` and the reworked `answeredCount` are pure local slice/map counters in `livedns_test.go`. The synthetic `map[string]*resolver.NameserverResponse` literals in `livedns_harness_test.go` reach only those counters plus `countStatus`/`liveQuorum`. No `dns` import in the harness file at all, no message built, no server, no `DNSClient`, no resolver method called, no resolution path fed. The prior clearance holds for the changed helpers. ### Checked and passing Issue DoD items 1-8 satisfied (https://git.eeqj.de/sneak/dnswatcher/issues/93); `9cb2c2b` unmodified and `87bce43` is a single added commit on top (`next` = `main` + 3, fast-forwardable, mergeable, no rewrite); no repeated ` (closes #93)` in the new subject; no `-count=1` in `script/test` (left to https://git.eeqj.de/sneak/dnswatcher/issues/139), `timeoutClient` and `internal/watcher`'s `mockResolver` untouched (https://git.eeqj.de/sneak/dnswatcher/pulls/97), `queryServers` byte-identical to `main` (https://git.eeqj.de/sneak/dnswatcher/issues/138); `goconst` constants `nsExample1`-`4` are a pure literal-for-constant swap with identical values; `make fmt` leaves the tree clean; no attribution trailers and no vendor/product references anywhere; CI `success` on `87bce43`. `REPO_POLICIES.md` independently re-verified as un-hand-edited: `sha256 bcf11c31…79ff7`, byte-identical to `prompts/REPO_POLICIES.md` fetched from `sneak/prompts` at `52b5192`, which is the current head of the still-open, unmerged https://git.eeqj.de/sneak/prompts/pulls/42. PR body's four claims (proposed-not-canonical, 60s/20s are the owner's ruling, `90s` is unratified, `go install` mandate contradicts Docker-only linting) are all accurate as written. `make check` run by me: **exit 0**, **zero `(cached)` test lines**, Docker lint stage demonstrably executed (`deps` stages `CACHED`, `#10 [lint 2/2] RUN golangci-lint … 15.33 0 issues.`). `make test` forced uncached: **5.96s** wall, `internal/resolver` 4.56s — inside the 20s target, so no improvement bug is owed. ### Anomalies and disclosures - **`error` is a sanctioned status in both tests.** Deliberate and correct under the DoD's "tolerate non-answers", but `error` is less obviously silence than `timeout` — `classifyResponse` maps SERVFAIL to it, so a minority of nameservers SERVFAILing is tolerated by design. Recording it so the tolerance boundary is explicit, not filing it as a defect. - The commit subject is 74 characters. No length rule exists in `REPO_POLICIES.md` and longer subjects already exist on `main`, so I did not treat it as a finding — disclosing the judgement call. - To measure uncached I used `GOFLAGS=-count=1` in the environment. No repo file was changed for it and `go clean -testcache` was not run. The four probes edited only `internal/resolver/iterative.go` and were reverted; `git status` is clean. - Unchanged from the prior review and still true: no live-DNS retry fired in any of my runs, so recovery from a real DNS failure remains evidenced only by `TestRetryLiveRecoversFromTransientFailure`. Not held against this commit.
clawbot added 1 commit 2026-08-10 15:48:27 +02:00
test: disable Go's test cache so every run queries live DNS (closes #139)
All checks were successful
check / check (push) Successful in 1m34s
6f6bf3a65b
`script/test` did not pass `-count=1`, so on an unchanged tree Go
served the whole suite from its test cache: exit 0 in ~0.2s with every
package marked `(cached)` and not one DNS query made. This repo's suite
exists to exercise live resolution on every run (`TESTING.md`), so that
green asserted nothing — and it is exactly the green used as evidence
that a flakiness fix works, since "run it a few times" stops being
runs after the first.

`-count=1` now disables caching on every invocation.

The conditional verbose rerun that `REPO_POLICIES.md` mandates was
missing at the same spot and is added here rather than left broken: the
primary run had been unconditionally `-v`, which is the failure mode
the policy exists to prevent (unreadable CI and `docker build` logs on
success). Tests now run quiet, and only a failure triggers the `-v`
rerun. The rerun carries `-count=1` too, so it cannot replay a cached
copy of the failure it is meant to diagnose, and its exit status is
discarded in favour of a forced 1: the first failure already proved the
suite broken, so a flake that passes the second time must not turn the
build green.

`-timeout 90s` is untouched. It is a deliberate backstop that must
strictly exceed the 60s hard cap on suite duration.

No special-casing for the Docker build, which also reaches this script
via `RUN make test`: a fresh container's test cache is empty, so
`-count=1` changes nothing there and carving out an exception would
only create a second code path that could drift.

Verified: three back-to-back `make test` runs on an unchanged tree,
zero `(cached)` markers, ~4.0-4.5s wall each (was ~0.2s cached),
comfortably inside the 20s target with `-race` and `-cover` both still
working and coverage percentages unchanged. The rerun-and-still-fail
path was exercised against a purpose-built flaky test that fails once
then passes: quiet failure, verbose rerun that genuinely re-executed,
exit 1 regardless. `make check` green.
Author
Collaborator

Review of 6f6bf3a only (#139)

PASS. All of the issue's definition of done verified independently in a fresh clone: -count=1 present on both runs, zero caching proven from a warm cache, rerun pattern correct, exit 1 forced, -timeout 90s untouched, docs accurate, make check and script/cibuild green by my own runs, CI green on 6f6bf3a, mergeable against main (9347a28, unmoved), no attribution trailers, no scope creep beyond the rerun pattern (which DoD item 4 fairly compels once the pattern is found absent rather than at risk), iron rule untouched — nothing mocked, gated, skipped or shortened to pay for the extra live runs.

Evidence that the checks executed, not cached:

  • Warm-cache negative control. In a throwaway clone I ran the identical post-change command line with -count=1 removed: run 1 3.5s/0 cached, run 2 0.204s/8 packages (cached). Restoring -count=1 against that same now-warm cache: 3.4s/0 cached. On the reviewed tree, three back-to-back make test: 5.3s, 3.7s, 6.2s, zero (cached) in all three. Uncached wall time 3.7-6.2s — inside the 20s target, no improvement bug owed. Coverage percentages identical to pre-change (resolver 77.1%, config 92.6%, etc.), -race and -cover both intact.
  • Failure path reproduced, not taken on trust. A flaky test (fails once, passes after; marker file outside the module) run through a verbatim copy of script/test under dash: quiet FAIL, banner, verbose rerun that genuinely re-executed and passed, EXITCODE=1. Confirmed a rerun's success can never green the build.
  • Exit propagation. script/test exit 1 -> script/check exit 1 with lint and fmt-check never reached -> make test / make check exit 2.
  • make check exit 0, Docker lint stage #10 ... DONE 27.9s / 0 issues. (deps stages CACHED, lint stage not), 0 (cached) test lines. script/cibuild exit 0, #22 [builder 8/9] RUN make test executed (not CACHED), 0 (cached), 0 --- PASS lines — so the no-carve-out reasoning holds and the quiet-on-success benefit is real in docker build logs.

Anomalies, all passing:

  • || true is required, not incidental, and is a justified deviation from the snippet in REPO_POLICIES.md (which is a make recipe, not a set -e script). The brace group is the last command of the AND-OR list, so errexit is live inside it: verified that without || true a failing rerun aborts the script and exit 1 is never reached, leaving the rerun's status as the exit code. As written the exit code is deterministically 1, and a rerun that crashes or times out still yields 1.
  • Banner goes to stderr where the policy snippet uses stdout. Harmless, arguably better; noting only because it is a deviation.
  • Unrelated to this commit: internal/state coverage reads 90.5% on the host and 88.4% inside the container, i.e. an environment-dependent branch somewhere. Pre-existing, not filed.

Disclosure: the negative control and the flaky-test exercise were done in throwaway clones/modules outside the reviewed tree (one scripted edit to strip -count=1 from the throwaway's script/test). The reviewed tree was never modified; git status clean throughout and after.

Note for the owner: this also fully satisfies #103 (items 1-7), which can be closed manually — the commit subject closes only #139, so it will not close itself. The only literal mismatch is that issue's -timeout 30s, deliberately superseded by the 90s backstop landed in 9cb2c2b.

## Review of `6f6bf3a` only (https://git.eeqj.de/sneak/dnswatcher/issues/139) **PASS.** All of the issue's definition of done verified independently in a fresh clone: `-count=1` present on both runs, zero caching proven from a warm cache, rerun pattern correct, exit 1 forced, `-timeout 90s` untouched, docs accurate, `make check` and `script/cibuild` green by my own runs, CI green on `6f6bf3a`, mergeable against `main` (`9347a28`, unmoved), no attribution trailers, no scope creep beyond the rerun pattern (which DoD item 4 fairly compels once the pattern is found absent rather than at risk), iron rule untouched — nothing mocked, gated, skipped or shortened to pay for the extra live runs. Evidence that the checks executed, not cached: - **Warm-cache negative control.** In a throwaway clone I ran the identical post-change command line with `-count=1` removed: run 1 `3.5s`/0 cached, run 2 `0.204s`/**8 packages `(cached)`**. Restoring `-count=1` against that same now-warm cache: `3.4s`/**0 cached**. On the reviewed tree, three back-to-back `make test`: `5.3s`, `3.7s`, `6.2s`, zero `(cached)` in all three. **Uncached wall time 3.7-6.2s** — inside the 20s target, no improvement bug owed. Coverage percentages identical to pre-change (`resolver` 77.1%, `config` 92.6%, etc.), `-race` and `-cover` both intact. - **Failure path reproduced, not taken on trust.** A flaky test (fails once, passes after; marker file outside the module) run through a verbatim copy of `script/test` under `dash`: quiet `FAIL`, banner, verbose rerun that genuinely re-executed and **passed**, `EXITCODE=1`. Confirmed a rerun's success can never green the build. - **Exit propagation.** `script/test` exit 1 -&gt; `script/check` exit 1 with `lint` and `fmt-check` never reached -&gt; `make test` / `make check` exit 2. - **`make check`** exit 0, Docker lint stage `#10 ... DONE 27.9s` / `0 issues.` (deps stages `CACHED`, lint stage not), 0 `(cached)` test lines. **`script/cibuild`** exit 0, `#22 [builder 8/9] RUN make test` executed (not `CACHED`), 0 `(cached)`, 0 `--- PASS` lines — so the no-carve-out reasoning holds and the quiet-on-success benefit is real in `docker build` logs. Anomalies, all passing: - **`|| true` is required, not incidental**, and is a justified deviation from the snippet in `REPO_POLICIES.md` (which is a `make` recipe, not a `set -e` script). The brace group is the *last* command of the AND-OR list, so errexit is live inside it: verified that without `|| true` a failing rerun aborts the script and `exit 1` is never reached, leaving the rerun's status as the exit code. As written the exit code is deterministically 1, and a rerun that crashes or times out still yields 1. - Banner goes to stderr where the policy snippet uses stdout. Harmless, arguably better; noting only because it is a deviation. - Unrelated to this commit: `internal/state` coverage reads 90.5% on the host and 88.4% inside the container, i.e. an environment-dependent branch somewhere. Pre-existing, not filed. Disclosure: the negative control and the flaky-test exercise were done in throwaway clones/modules outside the reviewed tree (one scripted edit to strip `-count=1` from the throwaway's `script/test`). The reviewed tree was never modified; `git status` clean throughout and after. **Note for the owner:** this also fully satisfies https://git.eeqj.de/sneak/dnswatcher/issues/103 (items 1-7), which can be closed manually — the commit subject closes only https://git.eeqj.de/sneak/dnswatcher/issues/139, so it will not close itself. The only literal mismatch is that issue's `-timeout 30s`, deliberately superseded by the 90s backstop landed in `9cb2c2b`.
clawbot added 1 commit 2026-08-10 16:04:40 +02:00
docs: add MIT LICENSE and state the licence in the README (closes #102)
Some checks failed
check / check (push) Has been cancelled
168281ad60
The repository had no licence file at all, which makes publicly readable
code all-rights-reserved by default: nobody may legally use it. That is a
1.0 blocker rather than a nicety, and `LICENSE` was the only file from
`REPO_POLICIES.md`'s required minimum still missing here.

The choice is standing org policy rather than a per-repo call: any public
repo lacking a licence gets MIT, while a private repo with no licence is
already all-rights-reserved and needs nothing. `sneak/dnswatcher` is
public, so MIT.

`LICENSE` carries the canonical MIT text byte-for-byte with only the
copyright line filled in; no clauses added, removed, reworded, or
reflowed. `README.md`'s first line now names the licence, which the
Description requirement in `REPO_POLICIES.md` calls for, and the License
section states MIT and points at the file instead of recording the
decision as pending.
clawbot added 1 commit 2026-08-10 16:09:29 +02:00
docs: correct stale script headers and record the config-verify cost (closes #137)
All checks were successful
check / check (push) Successful in 51s
b8662b8a9c
script/bootstrap credited the goimports pin to script/fmt-check, which
runs gofmt only; the header now credits script/fmt. script/cibuild
still described the Dockerfile as running make check, which stopped
being true once linting moved to its own stage. The docker-missing
warning in script/bootstrap is one sentence instead of three fragments
each carrying the bootstrap: prefix. Dockerfile.lint now states the
residual risk of skipping golangci-lint config verify: unknown
top-level keys in .golangci.yml are ignored silently, so a mistyped key
lints clean and applies nothing.

Comment and message text only; no behaviour changes.
All checks were successful
check / check (push) Successful in 51s
Required
Details
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin next:next
git checkout next
Sign in to join this conversation.