ADR-1065: Go staticcheck r10 — poll-loop timer leak and missing body guards¶
- Status: Accepted
- Date: 2026-06-06
- Deciders: Lusoris automated agent (r10 parallel-push)
- Tags:
go,security,correctness
Context¶
A round-10 staticcheck audit of the Go codebase found three classes of correctness bugs across four files:
-
SA1015-analog —
time.Afterinsidefor/selectpoll loops (pkg/storage/http_serve.go:waitForHTTP,pkg/storage/fuse_mount.go:waitForPath). Each loop iteration calledtime.After(interval), allocating a new*time.Timeron the heap. When thectx.Done()arm fires first, the timer fires later (afterinterval) with no reader — a goroutine-heap timer leak that accumulates across many concurrent readiness probes.observability.goalready documented the correct fix (time.NewTimer+defer Stop()) and cited ADR-1017; the two storage helpers had not received the same treatment. -
Missing
http.MaxBytesReaderon the controller score handler (cmd/vmafx-controller/http_server.go:handleScore). Thevmafx-serverequivalent already capped the request body at 1 MiB and mapped the overflow to HTTP 413. The controller handler omitted this guard, allowing an unauthenticated or low-privilege caller to push an arbitrarily large body and consume memory proportional to the body size while the JSON decoder reads it. -
Missing
ReadTimeouton both HTTP servers (controller and server).ReadHeaderTimeoutwas set (preventing Slowloris on the header phase), butReadTimeout— which covers the body read — was absent. Combined with the missingMaxBytesReaderon the controller, this left the body-read phase unbounded in wall time. The server already hadMaxBytesReader; both servers now also carryReadTimeout: 30 * time.Second.
Decision¶
-
Replace
time.After(interval)in both poll loops with atime.NewTicker(interval)created before the loop;defer ticker.Stop()ensures the ticker is released when the loop exits on any path. -
Add
http.MaxBytesReadertohandleScorein the controller, mirroring the 1 MiB cap already present in the server. Map overflow to HTTP 413. -
Add
ReadTimeout: 30 * time.Secondto bothhttp.Serverliterals.
Alternatives considered¶
| Option | Pros | Cons | Why not chosen |
|---|---|---|---|
Keep time.After but add // nolint | No code change | Suppresses a real timer leak | Timers accumulate during FUSE mount probing; fix is trivial |
Use time.Sleep instead of ticker | Simpler loop | Ignores ctx.Done() during sleep; not safe | Context cancellation must be honoured |
Omit ReadTimeout and rely only on MaxBytesReader | MaxBytesReader caps data volume | Wall-clock time still unbounded for slow senders | Defence in depth; 30 s is generous for 1 MiB JSON |
Consequences¶
- Positive: Poll loops no longer accumulate unreachable timers when a context cancellation races a poll interval. The controller score endpoint now rejects oversized bodies before reading them into memory. Both servers are hardened against slow-body attacks.
- Negative: none — the ticker is a standard Go idiom;
ReadTimeoutdoes not affect normal traffic (a 1 MiB JSON body transfers in milliseconds). - Neutral: The
MaxBytesErrorbranch in the controller now requireserrors.As, adding theerrorsimport.
References¶
- ADR-1017 (grpc-dial-backoff) — first instance of
time.NewTimer+defer Stop()in this codebase. - SA1015 staticcheck rule: "Using
time.Tickin a short-lived context may cause a memory leak." observability/observability.gocomment: "Use time.NewTimer so the timer is stopped … preventing a goroutine-timer leak. ADR-1017."