Skip to content

Commit 9889618

Browse files
fix: sampler interval accuracy, strict quality gate, CI pinning, benchmarks
- sampler now reports real sliding-window interval so daily totals do not drift when ticks are delayed - peak pulse now fires after reset (removed zero-peak swallow) - prime timer stopped on cancellation - make check is strict (lint failure if tool missing), dev-check for fast local loops; bench/cross/smoke targets added - CI synced to go.mod, pinned linter, runs make check plus CLI smoke - added tests for intervals, delta accuracy, peak pulse after reset - benchmarks for sampler throughput and hero/compact views - bump VERSION to 0.3.4 with changelog entry
1 parent c755c10 commit 9889618

13 files changed

Lines changed: 274 additions & 46 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,28 @@ concurrency:
1515
cancel-in-progress: true
1616

1717
jobs:
18-
lint:
18+
# The one gate: the same `make check` a developer runs locally. The linter
19+
# is installed at the pinned version so a missing tool fails the job
20+
# instead of silently skipping, and a new linter release cannot turn a
21+
# green tree red on its own.
22+
check:
1923
runs-on: ubuntu-latest
2024
steps:
2125
- uses: actions/checkout@v7
2226

2327
- uses: actions/setup-go@v7
2428
with:
25-
go-version: "1.24"
29+
go-version-file: go.mod
2630
cache: true
2731

28-
- name: golangci-lint
29-
uses: golangci/golangci-lint-action@v9
30-
with:
31-
version: latest
32-
args: --timeout=5m
32+
- name: Install golangci-lint (pinned)
33+
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.14.0
34+
35+
- name: make check
36+
run: make check
37+
38+
- name: CLI smoke test
39+
run: make smoke
3340

3441
test:
3542
strategy:
@@ -44,7 +51,7 @@ jobs:
4451

4552
- uses: actions/setup-go@v7
4653
with:
47-
go-version: "1.24"
54+
go-version-file: go.mod
4855
cache: true
4956

5057
- name: go vet
@@ -55,21 +62,16 @@ jobs:
5562

5663
# Cross-compilation is checked explicitly: the README promises no cgo in the
5764
# core path and release artifacts are built CGO-free for six targets.
65+
# This is compile evidence only; runtime behaviour is covered per-OS above.
5866
cross-build:
5967
runs-on: ubuntu-latest
6068
steps:
6169
- uses: actions/checkout@v7
6270

6371
- uses: actions/setup-go@v7
6472
with:
65-
go-version: "1.24"
73+
go-version-file: go.mod
6674
cache: true
6775

6876
- name: cross-compile release targets
69-
run: |
70-
set -e
71-
for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do
72-
os="${target%/*}"; arch="${target#*/}"
73-
echo "building $target"
74-
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -o /dev/null ./cmd/flow
75-
done
77+
run: make cross

‎CHANGELOG.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,33 @@
1+
## [0.3.4] - 2026-10-09
2+
3+
Reliability, hardening, and verification patch.
4+
5+
### Fixed
6+
- **Daily totals drift when ticks are delayed.** The sampler now attaches
7+
the exact sliding-window duration (`Interval`) to each sample. The UI
8+
and CLI now accumulate totals using this real duration rather than the
9+
nominal refresh cadence, preventing total byte drift under load.
10+
- **Initial peak pulse ignored after reset.** When reset with `r`, the peak
11+
pulse check previously guarded against zero peaks (`> 0`), swallowing the
12+
first genuine peak pulse following the reset.
13+
- **Leaked startup timer on cancellation.** The sampler's initial 10ms priming
14+
timer is now explicitly stopped on context cancellation.
15+
16+
### Changed
17+
- **`make check` is a strict, deterministic quality gate.** Missing
18+
`golangci-lint` now fails with an actionable error message rather than
19+
silently skipping. A separate `make dev-check` target is provided for
20+
fast local cycles without the linter.
21+
- **CI pinned and synchronised.** CI workflows now synchronise on `go.mod`
22+
via `go-version-file`, enforce the pinned `golangci-lint` v2.14.0 release,
23+
run `make check`, and exercise a dedicated CLI smoke test.
24+
25+
### Added
26+
- **Performance benchmarks:** Added benchmarks for sampler throughput and
27+
hero/compact UI views (`make bench`).
28+
- **Regression tests:** Added tests for sampler interval reporting,
29+
sample delta accuracy, and peak pulse triggering following reset.
30+
131
## [0.3.3] - 2026-10-04
232

333
Quality and reliability release. No new user-facing features.

‎CONTRIBUTING.md‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ reintroduces dashboard clutter.
1515

1616
## Setup
1717

18-
Requires Go 1.22+ and git.
18+
Requires Go 1.24+ and git.
1919

2020
```
2121
git clone https://github.com/programmersd21/flow
@@ -28,7 +28,7 @@ Install golangci-lint (https://golangci-lint.run/):
2828

2929
```
3030
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh \
31-
| sh -s -- -b $(go env GOPATH)/bin v1.59.1
31+
| sh -s -- -b $(go env GOPATH)/bin v2.14.0
3232
```
3333

3434
## Check suite
@@ -39,18 +39,22 @@ Run before every PR:
3939
make check
4040
```
4141

42-
Runs fmt-check, vet, lint, test in sequence. CI runs the same command.
42+
Runs fmt-check, vet, lint, test, race, and build in sequence. CI runs `make check`.
4343

4444
| Command | What it does |
4545
|------------------|----------------------------------------|
4646
| make build | go build with ldflags from VERSION |
47-
| make fmt | gofmt -l -w . |
47+
| make fmt | go fmt ./... |
4848
| make vet | go vet ./... |
49-
| make lint | golangci-lint run ./... |
50-
| make test | go test ./... -race -cover |
51-
| make check | fmt-check, vet, lint, test in sequence |
49+
| make lint | golangci-lint run ./... (pinned v2.14.0)|
50+
| make test | go test ./... |
51+
| make race | go test -race ./... |
52+
| make check | strict quality gate (fmt, vet, lint, test, race, build) |
53+
| make dev-check | fast local check skipping lint |
54+
| make bench | run cpu/alloc benchmarks |
55+
| make cross | CGO-free cross builds |
56+
| make smoke | run binary smoke tests |
5257
| make clean | removes bin/ and dist/ |
53-
| make release-dry | goreleaser snapshot, no publish |
5458

5559
## Commits
5660

@@ -66,9 +70,9 @@ refactor: extract formatBytes helper
6670

6771
## Branch protection
6872

69-
main requires the build-and-test status check before merge. Enable in
73+
main requires the CI status check before merge. Enable in
7074
Settings > Branches > Branch protection rules and add the check named
71-
build-and-test (the job name in ci.yml).
75+
`check` (the quality gate job in ci.yml).
7276

7377
## Versioning and releases
7478

‎Makefile‎

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,13 @@ endif
1818

1919
BUILDDIR := bin
2020

21-
.PHONY: all build install run test vet lint tidy clean demo help
21+
# The linter must be on PATH for the strict quality gate. The version is
22+
# pinned so a new golangci-lint release cannot turn a green tree red
23+
# unexpectedly; override with `make lint GOLANGCI_LINT=...` if needed.
24+
LINT_VERSION := v2.14.0
25+
GOLANGCI_LINT ?= $(shell command -v golangci-lint 2>/dev/null || echo $(shell go env GOPATH)/bin/golangci-lint)
26+
27+
.PHONY: all build install run test vet lint lint-optional tidy clean demo check dev-check bench cross fuzz smoke help
2228

2329
all: build
2430

@@ -71,11 +77,16 @@ demo: build
7177
echo "install gifsicle for further optimization: brew install gifsicle"; \
7278
fi
7379

74-
## check: format, vet, lint, and test
75-
## check: the one command that says whether a change is safe.
76-
## Runs formatting, vet, lint, tests, and the race detector.
80+
## check: the strict quality gate. fails on the first problem, and fails
81+
## loudly rather than skipping when a required tool is missing.
82+
## runs fmt-check, vet, lint, test, race, build.
83+
## CI runs exactly this command.
7784
check: fmt-check vet lint test race build
7885

86+
## dev-check: convenience for a fast local loop without the linter.
87+
## Not the quality gate: use `make check` before pushing.
88+
dev-check: fmt-check vet test build
89+
7990
## fmt: rewrite sources with gofmt
8091
fmt:
8192
go fmt ./...
@@ -91,12 +102,22 @@ fmt-check:
91102
vet:
92103
go vet ./...
93104

94-
## lint: run golangci-lint if it is installed, otherwise say so and continue
105+
## lint: run the pinned linter; missing tool is a failure, not a skip
95106
lint:
107+
@if ! command -v $(GOLANGCI_LINT) >/dev/null 2>&1; then \
108+
echo "golangci-lint $(LINT_VERSION) not found on PATH."; \
109+
echo "install it with: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(LINT_VERSION)"; \
110+
echo "(or run the convenience target 'make dev-check', which skips linting)"; \
111+
exit 1; \
112+
fi
113+
$(GOLANGCI_LINT) run ./... --timeout=5m
114+
115+
## lint-optional: run the linter when installed, otherwise say so
116+
lint-optional:
96117
@if command -v golangci-lint >/dev/null 2>&1; then \
97118
golangci-lint run ./...; \
98119
else \
99-
echo "golangci-lint not installed; skipping (go install honnef.co/go/tools/cmd/staticcheck@latest)"; \
120+
echo "golangci-lint not installed; skipping"; \
100121
fi
101122

102123
## vuln: check dependencies for known vulnerabilities, when the tool is present
@@ -121,6 +142,26 @@ fuzz:
121142
go test ./internal/ui -run '^$$' -fuzz FuzzSplitHeroNumber -fuzztime 15s
122143
go test ./internal/ui -run '^$$' -fuzz FuzzTruncate -fuzztime 15s
123144

145+
## bench: run the benchmarks (compare with -count=6 and benchstat)
146+
bench:
147+
go test -run '^$$' -bench . -benchmem ./...
148+
149+
## cross: cross-compile the release targets CGO-free
150+
cross:
151+
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/flow-linux-amd64 ./cmd/flow
152+
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/flow-linux-arm64 ./cmd/flow
153+
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o bin/flow-darwin-amd64 ./cmd/flow
154+
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o bin/flow-darwin-arm64 ./cmd/flow
155+
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o bin/flow-windows-amd64.exe ./cmd/flow
156+
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -o bin/flow-windows-arm64.exe ./cmd/flow
157+
158+
## smoke: exercise the real binary the way scripts use it
159+
smoke: build
160+
$(BUILDDIR)$(SEP)$(BINARY) --version
161+
$(BUILDDIR)$(SEP)$(BINARY) --once
162+
$(BUILDDIR)$(SEP)$(BINARY) --once --json
163+
go test ./cmd/flow -run 'TestCLI' -count=1
164+
124165
## help: print this message
125166
help:
126167
@sed -n 's/^## //p' $(MAKEFILE_LIST) 2>/dev/null || findstr /B "##" Makefile

‎VERSION‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.3.3
1+
0.3.4

‎cmd/flow/main.go‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -259,8 +259,8 @@ func runOnce(smp *sampler.Sampler, asJSON bool, bits bool, refresh time.Duration
259259

260260
// Peaks are tracked over the samples this process actually observed.
261261
tr := history.NewTracker()
262-
tr.Record(s1.DownBps, s1.UpBps, refresh.Seconds())
263-
tr.Record(s.DownBps, s.UpBps, refresh.Seconds())
262+
tr.Record(s1.DownBps, s1.UpBps, sampleInterval(s1, refresh))
263+
tr.Record(s.DownBps, s.UpBps, sampleInterval(s, refresh))
264264

265265
if asJSON {
266266
down := ui.FormatBpsExt(s.DownBps, ui.UnitAuto, bits)
@@ -303,13 +303,13 @@ func runJSONStream(smp *sampler.Sampler, refresh time.Duration, bits bool) {
303303
}
304304
// Peaks are the highest rates observed since the stream started.
305305
tr := history.NewTracker()
306-
tr.Record(s1.DownBps, s1.UpBps, refresh.Seconds())
306+
tr.Record(s1.DownBps, s1.UpBps, sampleInterval(s1, refresh))
307307
for s := range smp.Out {
308308
if s.Err != nil {
309309
fmt.Fprintf(os.Stderr, "flow: %v\n", s.Err)
310310
os.Exit(1)
311311
}
312-
tr.Record(s.DownBps, s.UpBps, refresh.Seconds())
312+
tr.Record(s.DownBps, s.UpBps, sampleInterval(s, refresh))
313313
_ = enc.Encode(map[string]interface{}{
314314
"status": "ok",
315315
"timestamp": s.At.UTC().Format(time.RFC3339Nano),
@@ -334,6 +334,17 @@ func isTTY(f *os.File) bool {
334334
return fi.Mode()&os.ModeCharDevice != 0
335335
}
336336

337+
// sampleInterval returns the real seconds a sample covers, so accumulated
338+
// totals match the sampling that actually happened rather than the configured
339+
// refresh (ticks lag when the machine is loaded or suspended). A sample that
340+
// carries no interval falls back to the configured refresh.
341+
func sampleInterval(s sampler.Sample, refresh time.Duration) float64 {
342+
if s.Interval > 0 {
343+
return s.Interval
344+
}
345+
return refresh.Seconds()
346+
}
347+
337348
// todayTotals loads persisted today totals. Missing file means zeros, never an error.
338349
func todayTotals() (float64, float64) {
339350
tr := history.NewTracker()
@@ -391,14 +402,14 @@ func runFormat(smp *sampler.Sampler, tmplStr string, stream bool, width int, ref
391402
os.Exit(1)
392403
}
393404
tr := history.NewTracker()
394-
tr.Record(s1.DownBps, s1.UpBps, refresh.Seconds())
405+
tr.Record(s1.DownBps, s1.UpBps, sampleInterval(s1, refresh))
395406

396407
for s := range smp.Out {
397408
if s.Err != nil {
398409
fmt.Fprintf(os.Stderr, "flow: %v\n", s.Err)
399410
os.Exit(1)
400411
}
401-
tr.Record(s.DownBps, s.UpBps, refresh.Seconds())
412+
tr.Record(s.DownBps, s.UpBps, sampleInterval(s, refresh))
402413
todayDown, todayUp := todayTotals()
403414
data := format.Data{
404415
Iface: s.Interface,

‎internal/sampler/sampler.go‎

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,14 @@ type Sample struct {
1111
DownBps float64
1212
UpBps float64
1313
Interface string
14-
At time.Time
15-
Err error
14+
// At is the tick the sample was computed for.
15+
At time.Time
16+
// Interval is the seconds of real time this sample's rates cover: the
17+
// sliding-window duration, not the configured refresh. Consumers that
18+
// accumulate totals must use it, otherwise daily totals drift whenever
19+
// ticks lag (loaded machine, suspended laptop, coarse timer).
20+
Interval float64
21+
Err error
1622
}
1723

1824
type entry struct {
@@ -24,6 +30,16 @@ type entry struct {
2430
// windowSlots: targets ~1 s window. At 250 ms default = 4 slots.
2531
const windowSlots = 4
2632

33+
// Sampler turns OS counter readings into smoothed rates.
34+
//
35+
// Ownership: Run is the only writer, and it runs in the caller's goroutine.
36+
// Out is read by the consumer (UI or CLI). The send on Out is deliberately
37+
// non-blocking: a sample that cannot be delivered immediately is dropped.
38+
// The UI is freshness-oriented — a stale sample is worse than a missing one,
39+
// because the display would not match the moment it names — and blocking here
40+
// would let a stalled consumer stop sampling altogether. That lossiness is a
41+
// decision, not an oversight, and TestSamplerDoesNotBlockOnSlowConsumer locks
42+
// it in.
2743
type Sampler struct {
2844
col collector.Reader
2945
interval time.Duration
@@ -51,21 +67,24 @@ func (s *Sampler) Run(ctx context.Context) {
5167
prev, err := s.col.Read()
5268
if err != nil {
5369
select {
54-
case s.Out <- Sample{Err: err, At: time.Now()}:
70+
case s.Out <- Sample{Err: err, At: time.Now().UTC()}:
5571
case <-ctx.Done():
5672
}
5773
return
5874
}
5975
var prevTime time.Time
6076

77+
// Prime the window: a single read cannot produce a rate, so take a second
78+
// one shortly after the first to establish prevTime.
6179
primeTimer := time.NewTimer(10 * time.Millisecond)
80+
defer primeTimer.Stop()
6281
select {
6382
case <-ctx.Done():
6483
return
6584
case <-primeTimer.C:
6685
if snap, err2 := s.col.Read(); err2 != nil {
6786
select {
68-
case s.Out <- Sample{Err: err2, At: time.Now()}:
87+
case s.Out <- Sample{Err: err2, At: time.Now().UTC()}:
6988
case <-ctx.Done():
7089
}
7190
return
@@ -123,6 +142,8 @@ func (s *Sampler) Run(ctx context.Context) {
123142
s.full = true
124143
}
125144

145+
// Window seconds this sample's rates are averaged over. Used both as the
146+
// divisor and as the reporting interval, so the two can never disagree.
126147
windowDt := s.dtSum
127148
if windowDt <= 0 {
128149
windowDt = float64(windowSlots) * s.interval.Seconds()
@@ -133,6 +154,7 @@ func (s *Sampler) Run(ctx context.Context) {
133154
UpBps: float64(s.txSum) / windowDt,
134155
Interface: snap.Interface,
135156
At: t,
157+
Interval: windowDt,
136158
}
137159

138160
select {

0 commit comments

Comments
 (0)