a9c1997fbc
gates / gates (push) Successful in 1m17s
internal/upwatch: exact per-container counters from the Docker Engine API, per-app judging (upload >= max(5 Mbit/s, 25% of the line) for 15 min; CPU >= max(150%, 50% x cores) for 30 min), explainers qBittorrent / Transmission / Plex, app_unexplained_upload / app_unexplained_cpu + _cleared (hub 0.169.0). The off-site push is sampled once a second for the line (a lower bound). qBittorrent limit = 50% of the line, the household's limit always wins. 20 red-proofs. Spike + design: felhom.eu audits/SPIKE-upload-watch-2026-10-23.md, architecture/08 section 9. Not released.
148 lines
5.9 KiB
Go
148 lines
5.9 KiB
Go
package upwatch
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// The best ≥ 10-second rate, and a push only COUNTS when that window moved ≥ 10 MB.
|
|
// RED-PROOF: compute the peak over 1-second windows → the 4 MB burst reads as 33 Mbit/s → fails.
|
|
func TestLine_SummarizePeakAndCounts(t *testing.T) {
|
|
series := func(perSecond []uint64) []Point {
|
|
t0 := time.Date(2026, 10, 24, 2, 16, 0, 0, time.UTC)
|
|
pts := []Point{{t0, 0}}
|
|
for i, b := range perSecond {
|
|
pts = append(pts, Point{t0.Add(time.Duration(i+1) * time.Second), pts[len(pts)-1].V + b})
|
|
}
|
|
return pts
|
|
}
|
|
steady := make([]uint64, 60)
|
|
for i := range steady {
|
|
steady[i] = 50e6 / 8
|
|
}
|
|
if r := Summarize(series(steady), time.Now()); r.Peak10Mbps < 49.9 || r.Peak10Mbps > 50.1 || !r.Counts || r.Seconds != 60 {
|
|
t.Fatalf("steady 50 Mbit/s push: %+v", r)
|
|
}
|
|
burst := make([]uint64, 30)
|
|
burst[3] = 4 << 20
|
|
if r := Summarize(series(burst), time.Now()); r.Counts || r.Peak10Mbps > 4 {
|
|
t.Fatalf("a 4 MB one-second push must not set the line: %+v", r)
|
|
}
|
|
}
|
|
|
|
// The sampler itself: readings from a live (fast) ticker reach the summary.
|
|
func TestLine_PushSamplerCollects(t *testing.T) {
|
|
var mu sync.Mutex
|
|
var v uint64
|
|
p := &PushSampler{Every: time.Millisecond, Read: func() (uint64, error) { mu.Lock(); defer mu.Unlock(); v += 1000; return v, nil }}
|
|
stop := p.Start()
|
|
time.Sleep(30 * time.Millisecond)
|
|
r := stop()
|
|
if r.Bytes < 2000 {
|
|
t.Fatalf("sampler collected %d bytes", r.Bytes)
|
|
}
|
|
}
|
|
|
|
func TestLine_FromRecordsUsesOnlyCountingNightsAndKeepsFourteen(t *testing.T) {
|
|
if _, ok := LineFromRecords(nil); ok {
|
|
t.Fatal("no record: the line must be unknown")
|
|
}
|
|
recs := []LineRecord{{Peak10Mbps: 90, Counts: false}, {Peak10Mbps: 40, Counts: true}, {Peak10Mbps: 55, Counts: true}}
|
|
if l, ok := LineFromRecords(recs); !ok || l != 55 {
|
|
t.Fatalf("line = %v %v, want 55 (the 90 did not count)", l, ok)
|
|
}
|
|
var keep []LineRecord
|
|
for i := 0; i < 20; i++ {
|
|
keep = AddRecord(keep, LineRecord{Peak10Mbps: float64(i), Counts: true})
|
|
}
|
|
if len(keep) != KeepNights || keep[0].Peak10Mbps != 6 {
|
|
t.Fatalf("kept %d, first %v", len(keep), keep[0].Peak10Mbps)
|
|
}
|
|
}
|
|
|
|
func TestLine_TxFromNetDevSkipsLoopback(t *testing.T) {
|
|
p := filepath.Join(t.TempDir(), "dev")
|
|
os.WriteFile(p, []byte(`Inter-| Receive | Transmit
|
|
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
|
|
lo: 9000 10 0 0 0 0 0 0 9000 10 0 0 0 0 0 0
|
|
eth0: 100 1 0 0 0 0 0 0 774708 5 0 0 0 0 0 0
|
|
eth1: 1 1 0 0 0 0 0 0 1000 1 0 0 0 0 0 0
|
|
`), 0o644)
|
|
v, err := txFromNetDev(p)
|
|
if err != nil || v != 775708 {
|
|
t.Fatalf("tx = %d %v, want 775708 (eth0+eth1, not lo)", v, err)
|
|
}
|
|
}
|
|
|
|
// ── qBittorrent's default limit ──
|
|
|
|
type fakeQB struct {
|
|
cur int64
|
|
sets []int64
|
|
}
|
|
|
|
func (f *fakeQB) UploadLimit(context.Context, map[string]string) (int64, error) { return f.cur, nil }
|
|
func (f *fakeQB) SetUploadLimit(_ context.Context, b int64) (int64, error) {
|
|
f.sets = append(f.sets, b)
|
|
f.cur = b / 1024 * 1024 // qBittorrent keeps KiB/s
|
|
return f.cur, nil
|
|
}
|
|
|
|
// RED-PROOF per row: drop the household check → the „household" rows set a limit → fail; compare against the
|
|
// requested value instead of the stored one → the box's own rounding reads as a household change → the re-set row fails.
|
|
func TestQBLimit_TheHouseholdAlwaysWins(t *testing.T) {
|
|
now := time.Date(2026, 10, 24, 3, 0, 0, 0, time.UTC)
|
|
ctx := context.Background()
|
|
// 1. Unlimited, never set, line 100 Mbit/s, share 50 % → 6 250 000 B/s requested, stored rounded.
|
|
q := &fakeQB{}
|
|
st, act, err := ApplyQBLimit(ctx, q, QBLimitState{}, 100, true, 0.5, now)
|
|
if err != nil || act != "set" || len(q.sets) != 1 || q.sets[0] != 6250000 || st.BoxSetBytes != 6249472 {
|
|
t.Fatalf("first set: %s %v %+v sets=%v", act, err, st, q.sets)
|
|
}
|
|
// 2. Same line, qBittorrent still holds the box's (rounded) value → kept, no write.
|
|
st2, act, _ := ApplyQBLimit(ctx, q, st, 105, true, 0.5, now)
|
|
if act != "kept" || len(q.sets) != 1 {
|
|
t.Fatalf("unchanged: %s sets=%v", act, q.sets)
|
|
}
|
|
// 3. The line moved > 20 % → re-set.
|
|
st3, act, _ := ApplyQBLimit(ctx, q, st2, 200, true, 0.5, now)
|
|
if act != "set" || len(q.sets) != 2 || st3.LineMbps != 200 {
|
|
t.Fatalf("line moved: %s %+v", act, st3)
|
|
}
|
|
// 4. The household changed it in qBittorrent → theirs, never touched again, even when the line moves.
|
|
q.cur = 1000000
|
|
st4, act, _ := ApplyQBLimit(ctx, q, st3, 200, true, 0.5, now)
|
|
if act != "household" || !st4.Household {
|
|
t.Fatalf("household change: %s %+v", act, st4)
|
|
}
|
|
if _, act, _ := ApplyQBLimit(ctx, q, st4, 50, true, 0.5, now); act != "household" || len(q.sets) != 2 {
|
|
t.Fatalf("after the household took it: %s sets=%v", act, q.sets)
|
|
}
|
|
// 5. The household REMOVED the box's limit (0) → theirs too.
|
|
q2 := &fakeQB{}
|
|
s, _, _ := ApplyQBLimit(ctx, q2, QBLimitState{}, 100, true, 0.5, now)
|
|
q2.cur = 0
|
|
if s2, act, _ := ApplyQBLimit(ctx, q2, s, 100, true, 0.5, now); act != "household" || !s2.Household {
|
|
t.Fatalf("household removed the limit: %s", act)
|
|
}
|
|
// 6. A limit already there before the box ever looked → the household's.
|
|
q3 := &fakeQB{cur: 500000}
|
|
if s3, act, _ := ApplyQBLimit(ctx, q3, QBLimitState{}, 100, true, 0.5, now); act != "household" || !s3.Household || len(q3.sets) != 0 {
|
|
t.Fatalf("pre-existing limit: %s", act)
|
|
}
|
|
// 6b. That household later REMOVES its own limit (unlimited again) → still theirs: the box does not step in.
|
|
q3.cur = 0
|
|
if _, act, _ := ApplyQBLimit(ctx, q3, QBLimitState{Household: true}, 100, true, 0.5, now); act != "household" || len(q3.sets) != 0 {
|
|
t.Fatalf("household set unlimited after its own limit: %s sets=%v — the box overrode their choice", act, q3.sets)
|
|
}
|
|
// 7. No line measured → nothing set.
|
|
q4 := &fakeQB{}
|
|
if _, act, _ := ApplyQBLimit(ctx, q4, QBLimitState{}, 0, false, 0.5, now); act != "no-line" || len(q4.sets) != 0 {
|
|
t.Fatalf("no line: %s", act)
|
|
}
|
|
}
|