11 Commits
Author SHA1 Message Date
sneak d8413f5026 script: force lint and test to run in cibuild and docker (closes #115)
check / check (push) Failing after 1s
script/cibuild and script/docker were plain docker build. On an
unchanged tree the lint stage and the builder stage's make test came
from the layer cache, so the build reported success having linted
nothing and queried no live DNS. Both scripts now pass
--no-cache-filter=lint,builder, so those stages run on every build;
this mirrors script/lint, which already does the same for the lint
stage alone. Dependency downloads inside the disabled stages re-run,
which the issue accepts. No pins, .golangci.yml, or test behaviour
changed. README and TODO.md updated to match.

Model: opus-4-8
2026-09-21 07:50:00 +00:00
clawbot b351a2350c gomod: drop stale golang.org/x/sync indirect line (closes #132)
check / check (push) Failing after 0s
go.mod listed golang.org/x/sync v0.19.0 twice: once in the direct
require block and once as // indirect. The module is a genuine direct
dependency (internal/portcheck/portcheck.go imports
golang.org/x/sync/errgroup), so the indirect entry was redundant and
stale. script/bootstrap ends with go mod download, which dropped that
line as a side effect and left every fresh checkout with a dirty
go.mod. Running go mod tidy removes the line for good; no dependency
and no version changed, and go.sum is unaffected.

Model: opus-4-8
2026-09-21 09:44:49 +02:00
clawbot 1ffe303a6e Merge main into next, resolving TODO.md (closes #145)
check / check (push) Failing after 1s
The last milestone landed on main as a squash, so main was no longer an
ancestor of next and git merge-tree reported a TODO.md conflict: both
branches added the same block of Completed Steps entries, and next added
one more (the #99 http.Server timeouts entry). This real merge commit
makes main an ancestor of next again. TODO.md is resolved by hand to keep
every entry from both sides exactly once, which is next's version since
next's entries are a superset of main's. Every other file already equals
next. No new TODO.md entry is added, per the task brief.

Model: opus-4-8
2026-09-21 07:22:17 +00:00
clawbot fc43f893a5 server: set all four http.Server socket timeouts (#118)
check / check (push) Successful in 3m50s
The http.Server was built with only ReadHeaderTimeout set; the other three
timeouts were zero, which in net/http means no limit, so a peer could hold a
connection open past the header phase, responses had no write deadline, and
keep-alive connections were never reaped. ReadTimeout 15s, WriteTimeout 75s,
and IdleTimeout 120s now join ReadHeaderTimeout 10s as named constants.
WriteTimeout must stay above the 60s chimw.Timeout handler budget, because
net/http arms the write deadline once request headers are read; a test fails
the build if either number moves alone. The server literal moved into
newHTTPServer so the configuration can be asserted without binding a socket
(closes #99)

Model: opus-5
2026-09-09 15:17:43 +02:00
clawbot ae06f7e3a1 notify: drain in-flight deliveries at shutdown (closes #106)
check / check (push) Successful in 1m22s
Shutdown waits for deliveries already in flight instead of dropping them.

Reviewed and green; squashed to next by the dispatcher, dnswatcher having no manager.

Model: opus-5
2026-09-09 14:26:53 +02:00
sneak b8662b8a9c docs: correct stale script headers and record the config-verify cost (closes #137)
check / check (push) Successful in 51s
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.
2026-08-10 14:09:20 +00:00
sneak 168281ad60 docs: add MIT LICENSE and state the licence in the README (closes #102)
check / check (push) Has been cancelled
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.
2026-08-10 14:04:31 +00:00
sneak 6f6bf3a65b test: disable Go's test cache so every run queries live DNS (closes #139)
check / check (push) Successful in 1m34s
`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.
2026-08-10 13:48:15 +00:00
sneak 87bce43f8d test: rework live-DNS quorum unit — tolerate silence, never a wrong answer
check / check (push) Successful in 1m23s
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.
2026-08-10 13:33:53 +00:00
sneak 9cb2c2b7e0 test: make live DNS tests robust instead of gated (closes #93)
check / check (push) Successful in 1m18s
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
2026-08-10 13:09:17 +00:00
sneak cc86473410 build: run all linting in Docker via Dockerfile.lint (closes #134)
check / check (push) Successful in 1m17s
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.
2026-08-10 12:37:47 +00:00
8 changed files with 246 additions and 13 deletions
+25 -1
View File
@@ -182,6 +182,27 @@ dnswatcher exposes a lightweight HTTP API for operational visibility:
| `GET /api/v1/status` | Current monitoring state |
| `GET /metrics` | Prometheus metrics (optional) |
#### Server timeouts
The HTTP server sets all four socket-level timeouts. These are compile-time
constants in `internal/server/server.go`, not configurable via environment
variables.
| Timeout | Value | Purpose |
|---------------------|-------|-----------------------------------------------|
| `ReadHeaderTimeout` | 10s | Bounds the request header read (slowloris) |
| `ReadTimeout` | 15s | Bounds the whole request read, headers + body |
| `WriteTimeout` | 75s | Bounds handler execution plus response flush |
| `IdleTimeout` | 120s | Reaps idle keep-alive connections |
These are distinct from the 60s per-request handler budget applied by
`chimw.Timeout` in `internal/server/routes.go`, which cancels the request
context but does not touch the socket. `WriteTimeout` is deliberately
larger than that budget: the write deadline is armed once request headers
are read, so a smaller value would sever the connection before a handler
using its full budget could respond. `IdleTimeout` exceeds common
Prometheus scrape intervals so the scraper reuses its connection.
---
## Architecture
@@ -405,7 +426,10 @@ them. We provide:
- `script/check` — run test, lint, and fmt-check
- `script/docker` — build the Docker image tagged via
`script/projectname`
- `script/cibuild` — CI entrypoint: plain `docker build .`
- `script/cibuild` — CI entrypoint: `docker build` with
`--no-cache-filter=lint,builder`, forcing the lint and test stages to
run on every invocation, because a cached build lints nothing and
queries no DNS
- `script/precommit` — run by the git pre-commit hook; `go mod tidy`
guard, then `script/check`
- `script/install-precommit` — install the git pre-commit hook
+12
View File
@@ -23,6 +23,10 @@ Rationale, Design, TODO, License, Author) if any are still missing.
# Completed Steps
- 2026-09-21: `script/cibuild` and `script/docker` now pass
`--no-cache-filter=lint,builder` so lint and tests run every build.
- 2026-09-21: `go mod tidy` dropped the redundant `golang.org/x/sync`
`// indirect` line so `script/bootstrap` leaves a clean tree (#132)
- 2026-08-10: comment-only corrections to `script/bootstrap`,
`script/cibuild`, and `Dockerfile.lint`. The `goimports` pin in
`script/bootstrap` was justified by a claim that `script/fmt-check`
@@ -101,6 +105,14 @@ Rationale, Design, TODO, License, Author) if any are still missing.
so shutdown cannot be extended indefinitely; an `OnStop` context that
is already expired on entry with nothing outstanding drains quietly
rather than warning about deliveries that were never abandoned
- 2026-08-09: `http.Server` now sets all four socket-level timeouts
(`ReadTimeout` 15s, `ReadHeaderTimeout` 10s, `WriteTimeout` 75s,
`IdleTimeout` 120s) as named constants in `internal/server/server.go`,
closing the slowloris / unreaped-keep-alive exposure required by
`REPO_POLICIES.md` before 1.0; `WriteTimeout` is deliberately greater
than the 60s `chimw.Timeout` handler budget so that budget stays
reachable, and tests in `internal/server` pin both the non-zero
values and that relationship (#99)
- 2026-08-07: golangci-lint bumped to v2.12.2 (commit-pinned installs
in `Dockerfile` and `script/bootstrap`); `.golangci.yml` set to the
org-standard v2-schema config used across the org's repos
-1
View File
@@ -40,7 +40,6 @@ require (
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.41.0 // indirect
+19
View File
@@ -0,0 +1,19 @@
package server
import (
"net/http"
"time"
)
// NewHTTPServer exports newHTTPServer for testing.
func NewHTTPServer(
listenAddr string,
handler http.Handler,
) *http.Server {
return newHTTPServer(listenAddr, handler)
}
// RequestTimeout exports the handler execution budget applied by
// chimw.Timeout in SetupRoutes, so tests can assert the relationship
// between it and the server's WriteTimeout.
const RequestTimeout time.Duration = requestTimeout
+64 -7
View File
@@ -33,8 +33,52 @@ type Params struct {
// shutdownTimeout is how long to wait for graceful shutdown.
const shutdownTimeout = 30 * time.Second
// readHeaderTimeout is the max duration for reading request headers.
const readHeaderTimeout = 10 * time.Second
// Socket-level timeouts for the HTTP server.
//
// These bound time spent on the connection itself and are a distinct
// control from the per-request handler budget enforced by
// chimw.Timeout(requestTimeout) in routes.go: that one cancels the
// request context after requestTimeout but never touches the socket,
// so without the values below a peer can hold a connection open
// forever (slowloris, unreaped keep-alives).
//
// The one hard constraint between the two controls is
// writeTimeout > requestTimeout. net/http arms the write deadline
// once the request headers have been read, so on a plaintext
// connection it covers handler execution AND the response flush. If
// writeTimeout were <= requestTimeout the server would sever the
// connection before a handler that legitimately consumed its full
// budget could emit anything, making the 60s budget unreachable in
// practice. The margin between them is the response-flush allowance.
//
// The only clients of this service are browsers loading the dashboard
// and a Prometheus scraper; the values are sized for those.
const (
// readHeaderTimeout is the max duration for reading request
// headers.
readHeaderTimeout = 10 * time.Second
// readTimeout bounds reading the entire request, headers plus
// body. Every route here is a GET with no body, so this only
// ever needs to cover headers; the extra 5s over
// readHeaderTimeout is slack, not a real allowance, and keeps a
// body dribbled one byte at a time from holding the read side
// open indefinitely.
readTimeout = 15 * time.Second
// writeTimeout must exceed the requestTimeout handler budget
// (60s) per the note above. The 15s difference is the allowance
// for flushing a completed response to a slow client.
writeTimeout = 75 * time.Second
// idleTimeout reaps keep-alive connections between requests. It
// is deliberately longer than the common Prometheus scrape
// intervals (15s/30s/60s) so the scraper reuses its connection
// rather than reconnecting every cycle, while a browser tab
// left open on the dashboard stops occupying a connection
// within two minutes of going quiet.
idleTimeout = 120 * time.Second
)
// Server is the HTTP server.
type Server struct {
@@ -76,16 +120,29 @@ func New(
return srv, nil
}
// newHTTPServer builds the listening http.Server with every
// socket-level timeout set. All four are set deliberately: a zero
// value in net/http means "no limit", not "some default".
func newHTTPServer(
listenAddr string,
handler http.Handler,
) *http.Server {
return &http.Server{
Addr: listenAddr,
Handler: handler,
ReadTimeout: readTimeout,
ReadHeaderTimeout: readHeaderTimeout,
WriteTimeout: writeTimeout,
IdleTimeout: idleTimeout,
}
}
// Run starts the HTTP server.
func (s *Server) Run() {
s.SetupRoutes()
listenAddr := fmt.Sprintf(":%d", s.port)
s.httpServer = &http.Server{
Addr: listenAddr,
Handler: s,
ReadHeaderTimeout: readHeaderTimeout,
}
s.httpServer = newHTTPServer(listenAddr, s)
s.log.Info("http server starting", "addr", listenAddr)
+112
View File
@@ -0,0 +1,112 @@
package server_test
import (
"net/http"
"testing"
"sneak.berlin/go/dnswatcher/internal/server"
)
// noopHandler stands in for the router; newHTTPServer only stores it.
func noopHandler() http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
)
}
// TestHTTPServerTimeoutsAreSet asserts that every socket-level
// timeout is configured. A zero value in net/http means "no limit",
// so a refactor that silently drops one of these reintroduces the
// slowloris / unreaped-keep-alive exposure this guards against.
//
// The assertions are on the configured field values only; nothing
// here measures elapsed time, so the test cannot flake on timing.
func TestHTTPServerTimeoutsAreSet(t *testing.T) {
t.Parallel()
srv := server.NewHTTPServer(":8080", noopHandler())
if srv.ReadTimeout <= 0 {
t.Errorf(
"ReadTimeout must be non-zero, got %v",
srv.ReadTimeout,
)
}
if srv.ReadHeaderTimeout <= 0 {
t.Errorf(
"ReadHeaderTimeout must be non-zero, got %v",
srv.ReadHeaderTimeout,
)
}
if srv.WriteTimeout <= 0 {
t.Errorf(
"WriteTimeout must be non-zero, got %v",
srv.WriteTimeout,
)
}
if srv.IdleTimeout <= 0 {
t.Errorf(
"IdleTimeout must be non-zero, got %v",
srv.IdleTimeout,
)
}
}
// TestWriteTimeoutExceedsHandlerBudget pins the one relationship the
// values must satisfy. net/http arms the write deadline once request
// headers are read, so it covers handler execution plus the response
// flush. If WriteTimeout were not greater than the chimw.Timeout
// handler budget, the connection would be severed before a handler
// that used its full budget could respond, making that budget
// unreachable.
func TestWriteTimeoutExceedsHandlerBudget(t *testing.T) {
t.Parallel()
srv := server.NewHTTPServer(":8080", noopHandler())
if srv.WriteTimeout <= server.RequestTimeout {
t.Errorf(
"WriteTimeout (%v) must exceed handler budget (%v)",
srv.WriteTimeout,
server.RequestTimeout,
)
}
}
// TestReadTimeoutCoversHeaderTimeout asserts the read deadline for
// the whole request is at least as long as the header-only deadline;
// a smaller ReadTimeout would make ReadHeaderTimeout unreachable.
func TestReadTimeoutCoversHeaderTimeout(t *testing.T) {
t.Parallel()
srv := server.NewHTTPServer(":8080", noopHandler())
if srv.ReadTimeout < srv.ReadHeaderTimeout {
t.Errorf(
"ReadTimeout (%v) must be >= ReadHeaderTimeout (%v)",
srv.ReadTimeout,
srv.ReadHeaderTimeout,
)
}
}
// TestHTTPServerAddrAndHandler covers the rest of the constructor so
// a future edit cannot drop the listen address or the handler.
func TestHTTPServerAddrAndHandler(t *testing.T) {
t.Parallel()
srv := server.NewHTTPServer(":9999", noopHandler())
if srv.Addr != ":9999" {
t.Errorf("Addr = %q, want %q", srv.Addr, ":9999")
}
if srv.Handler == nil {
t.Error("Handler must not be nil")
}
}
+7 -2
View File
@@ -1,14 +1,19 @@
#!/bin/sh
# script/cibuild: run the CI build. The Dockerfile's lint stage runs
# make fmt-check and golangci-lint; its builder stage runs make test
# and make build. A successful build implies all of those passed.
# and make build.
#
# --no-cache-filter=lint,builder forces both of those stages to run on
# every invocation. Without it an unchanged tree serves them from the
# layer cache, reporting success having linted nothing and queried no
# live DNS. A successful build then implies those stages actually ran.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
docker build .
docker build --no-cache-filter=lint,builder .
}
main "$@"
+7 -2
View File
@@ -1,6 +1,11 @@
#!/bin/sh
# script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname.
# The tag comes from script/projectname.
#
# --no-cache-filter=lint,builder forces the lint stage and the builder
# stage (make test) to run on every invocation. Without it an unchanged
# tree serves them from the layer cache, producing an image whose build
# linted nothing and queried no live DNS.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -8,7 +13,7 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
docker build -t "$("$SCRIPT_DIR/projectname")" .
docker build --no-cache-filter=lint,builder -t "$("$SCRIPT_DIR/projectname")" .
}
main "$@"