Implemented and fixed many issues

This commit is contained in:
Martino Ferrari
2026-08-21 23:24:48 +02:00
parent 14d5351a81
commit e03c60db25
52 changed files with 7726 additions and 769 deletions
@@ -0,0 +1,168 @@
package wshub
import (
"encoding/binary"
"math"
"testing"
)
// decodeCaptureSpan pulls the time extent of one signal out of a v2 frame.
func decodeCaptureSpan(t *testing.T, buf []byte, key string) (first, last float64, n int) {
t.Helper()
off := 1 + 8 + 8 + 8
nSig := int(binary.LittleEndian.Uint32(buf[off:]))
off += 4
for i := 0; i < nSig; i++ {
kl := int(binary.LittleEndian.Uint16(buf[off:]))
off += 2
k := string(buf[off : off+kl])
off += kl
cnt := int(binary.LittleEndian.Uint32(buf[off:]))
off += 4
if k == key && cnt > 0 {
first = math.Float64frombits(binary.LittleEndian.Uint64(buf[off:]))
last = math.Float64frombits(binary.LittleEndian.Uint64(buf[off+(cnt-1)*8:]))
n = cnt
}
off += cnt * 16
}
return
}
// The rings only reach back over the window once they have rolled over at the
// current bucket, which takes as long as the window itself — so a window widened
// mid-run leaves the first captures asking for history the rings never stored.
// The archive kept it, and the capture must come back whole.
func TestCaptureBackfillsItsHeadFromTheArchive(t *testing.T) {
h := NewHub()
hw, key := newTestHistory(t, HistoryConfig{
WindowSec: 60, Decimation: 1, MinDiskFreeMB: -1,
}, 1000)
h.hist = hw
// 20 s of 1 kSps, archived in full…
ts, vs := ramp(1000, 0.001, 20000)
hw.write(key, ts, vs)
// …but a ring that only ever holds the last 5 s of it.
rb := newSigRing(5000)
rb.write(ts, vs)
h.rings[key] = rb
// A 15 s window, of which the ring has the newest third.
const t0, t1 = 1005.0, 1020.0
buf := h.buildTriggerCapture(1015, 10, 5)
if buf == nil {
t.Fatal("no capture frame built")
}
first, last, n := decodeCaptureSpan(t, buf, key)
if first > t0+0.05 {
t.Errorf("capture starts at %.3f, want the window's start %.3f — the archive holds it",
first, t0)
}
if last < t1-0.05 {
t.Errorf("capture ends at %.3f, want %.3f", last, t1)
}
if n < 100 {
t.Errorf("capture has %d points, too few for a 15 s window at 1 kSps", n)
}
// The join between the two sources must not break time order, or every
// binary search over the capture — client-side and in the hold — misreads it.
ct, _, ok := h.capture.slice(key, t0, t1)
if !ok {
t.Fatal("the hold declined the window it just published")
}
for i := 1; i < len(ct); i++ {
if ct[i] < ct[i-1] {
t.Fatalf("capture time goes backwards at %d: %.6f then %.6f", i, ct[i-1], ct[i])
}
}
}
// A capture that neither source could fill must not answer for the stretch it is
// missing: the client has to fall through to the archive instead of redrawing
// the same hole on every zoom.
func TestHoldDeclinesTheStretchACaptureNeverGot(t *testing.T) {
h := NewHub()
ts, vs := ramp(1000, 0.001, 20000)
rb := newSigRing(5000) // the newest 5 s only, and no archive to fill from
rb.write(ts, vs)
h.rings["src:sig"] = rb
if buf := h.buildTriggerCapture(1015, 10, 5); buf == nil {
t.Fatal("no capture frame built")
}
if _, _, ok := h.capture.slice("src:sig", 1005, 1020); ok {
t.Error("the hold answered for 15 s it only has the last 5 s of")
}
// What it does hold, it still serves.
if _, _, ok := h.capture.slice("src:sig", 1016, 1019); !ok {
t.Error("the hold declined a range well inside its data")
}
}
// TestCaptureCoverageAcrossShots walks a whole acquisition the way Run() does —
// ingest, retune, dueCapture, rearm — and reports how much of each window the
// capture actually came back with.
func TestCaptureCoverageAcrossShots(t *testing.T) {
const (
key = "s1:Ch1"
rate = 100e3 // scaled 10x down from the 1 MSps producer
budget = 400_000
window = 120.0
prePct = 20.0
batchSec = 1.0 / 30.0
simSec = 900.0
)
h := NewHub()
h.SetRingBudget(budget)
h.rings[key] = newSigRing(ringCapInitial)
h.trigger.SetConfig(trigConfig{signalKey: key, edge: "rising", threshold: 0,
windowSec: window, prePercent: prePct, mode: "normal", holdoffSec: 0.2})
rateHz := float64(rate)
nBatch := int(rateHz * batchSec)
ts := make([]float64, nBatch)
vs := make([]float64, nBatch)
armed := false
shots := 0
for now := 0.0; now < simSec; now += batchSec {
for i := range ts {
ts[i] = now + float64(i)/rateHz
// 0.05 Hz sine: one rising zero crossing every 20 s.
vs[i] = math.Sin(2 * math.Pi * 0.05 * ts[i])
}
h.ingest(key, 1, ts, vs)
h.retuneRings(now)
// Arm once the stream is going, as a user would.
if !armed && now > 5 {
h.trigger.Arm()
armed = true
}
if trigTime, pre, post, ok := h.trigger.dueCapture(now + batchSec); ok {
buf := h.buildTriggerCapture(trigTime, pre, post)
if buf == nil {
t.Fatalf("shot at t=%.1f produced no frame", trigTime)
}
first, last, n := decodeCaptureSpan(t, buf, key)
t0, t1 := trigTime-pre, trigTime+post
_, ringSpan := h.rings[key].stats()
shots++
t.Logf("shot %d fired t=%.1f window [%.1f,%.1f] got [%.1f,%.1f] "+
"= %.0f%% (%d pts, bucket %d, ring span %.1f s)",
shots, trigTime, t0, t1, first, last,
100*(last-first)/(t1-t0), n, h.rings[key].bucketSize(), ringSpan)
h.trigger.markTriggered(now + batchSec)
} else if h.trigger.dueRearm(now + batchSec) {
h.trigger.Arm()
}
}
if shots < 3 {
t.Fatalf("only %d shots in %.0f s", shots, simSec)
}
}
+83
View File
@@ -0,0 +1,83 @@
package wshub
import (
"sort"
"sync"
)
// capturedWindow is one delivered trigger capture, held at the resolution the
// rings had when it was taken. Nothing mutates it after publication, so readers
// may sub-slice it without copying.
type capturedWindow struct {
t0, t1 float64
sigs map[string]sigData
}
// captureHold is the read half of the trigger double buffer; the rings are the
// write half.
//
// The rings keep rolling while the trigger re-arms and collects the next shot,
// so within seconds of a capture they no longer hold the window the user is
// looking at — a zoom into it came back with only the newest sliver, or with
// nothing. Publishing the window here at capture time gives the viewer a
// snapshot that the re-arming acquisition cannot overwrite: the swap happens
// only when the *next* capture is complete, which is also the moment the client
// stops displaying this one.
type captureHold struct {
mu sync.RWMutex
cur *capturedWindow
}
// publish swaps in a new capture, retiring the previous one. Readers that
// already hold a pointer to the retired window keep reading it safely.
func (ch *captureHold) publish(t0, t1 float64, sigs map[string]sigData) {
if len(sigs) == 0 {
return
}
w := &capturedWindow{t0: t0, t1: t1, sigs: sigs}
ch.mu.Lock()
ch.cur = w
ch.mu.Unlock()
}
// clear drops the held capture, releasing its memory.
func (ch *captureHold) clear() {
ch.mu.Lock()
ch.cur = nil
ch.mu.Unlock()
}
// slice answers [a, b] for one signal out of the held capture, reporting
// whether it could.
//
// It declines any range reaching outside the captured window: that is a live
// zoom or a pan off the capture, and only the rings still track the stream.
// Inside the window the hold is never worse than the rings — retuning does not
// rewrite stored samples, so a ring that still covers the range holds the very
// same points — which is why no trigger-state gating is needed here.
func (ch *captureHold) slice(key string, a, b float64) ([]float64, []float64, bool) {
ch.mu.RLock()
w := ch.cur
ch.mu.RUnlock()
if w == nil || a < w.t0 || b > w.t1 {
return nil, nil, false
}
sd, ok := w.sigs[key]
if !ok || len(sd.T) == 0 {
return nil, nil, false
}
// The window is what was asked for; this signal's samples are what could be
// found. A capture whose front was never recoverable must not answer for the
// stretch it is missing — the client would redraw the same hole on every
// zoom and every "fit" instead of falling back to the archive.
tol := shortCaptureTol * (w.t1 - w.t0)
if sd.T[0] > a+tol || sd.T[len(sd.T)-1] < b-tol {
return nil, nil, false
}
lo := sort.SearchFloat64s(sd.T, a)
hi := lo + sort.Search(len(sd.T)-lo, func(i int) bool { return sd.T[lo+i] > b })
if hi <= lo {
return nil, nil, false
}
return sd.T[lo:hi], sd.V[lo:hi], true
}
+128
View File
@@ -0,0 +1,128 @@
package wshub
import "testing"
func heldRamp(t0, dt float64, n int) sigData {
sd := sigData{T: make([]float64, n), V: make([]float64, n)}
for i := range sd.T {
sd.T[i] = t0 + float64(i)*dt
sd.V[i] = float64(i)
}
return sd
}
func TestCaptureHoldServesRangesInsideTheWindow(t *testing.T) {
var ch captureHold
ch.publish(0, 10, map[string]sigData{"s1:sig": heldRamp(0, 0.1, 101)})
gt, gv, ok := ch.slice("s1:sig", 2, 3)
if !ok {
t.Fatal("held capture declined a range inside its window")
}
if gt[0] < 2 || gt[len(gt)-1] > 3 {
t.Fatalf("range %v..%v escapes the request 2..3", gt[0], gt[len(gt)-1])
}
if len(gt) != len(gv) {
t.Fatalf("t/v length mismatch: %d vs %d", len(gt), len(gv))
}
if gv[0] != 20 {
t.Fatalf("first value %v, want the sample at t=2", gv[0])
}
}
// A range poking outside the capture is a live zoom: only the rings still track
// the stream, so the hold must stand aside rather than answer a clipped range.
func TestCaptureHoldDeclinesRangesOutsideTheWindow(t *testing.T) {
var ch captureHold
ch.publish(0, 10, map[string]sigData{"s1:sig": heldRamp(0, 0.1, 101)})
for _, r := range [][2]float64{{-1, 5}, {5, 11}, {20, 30}, {-5, -1}} {
if _, _, ok := ch.slice("s1:sig", r[0], r[1]); ok {
t.Fatalf("held capture answered %v..%v, which is not inside 0..10", r[0], r[1])
}
}
if _, _, ok := ch.slice("other:sig", 2, 3); ok {
t.Fatal("held capture answered for a signal it does not hold")
}
}
func TestCaptureHoldZeroValueAndClearDecline(t *testing.T) {
var ch captureHold
if _, _, ok := ch.slice("s1:sig", 0, 1); ok {
t.Fatal("empty hold answered a request")
}
ch.publish(0, 10, map[string]sigData{"s1:sig": heldRamp(0, 0.1, 101)})
ch.clear()
if _, _, ok := ch.slice("s1:sig", 2, 3); ok {
t.Fatal("cleared hold still answered a request")
}
}
// The point of the double buffer: the window a client is exploring survives the
// re-armed acquisition rolling the rings past it, and is replaced only when the
// next shot completes.
func TestZoomIntoACaptureSurvivesTheRingRollingPast(t *testing.T) {
h := NewHub()
rb := newSigRing(4000)
h.rings["s1:sig"] = rb
// 2 s of 1 kSps, then fire a trigger over [0.5, 1.5].
ts, vs := make([]float64, 2000), make([]float64, 2000)
for i := range ts {
ts[i], vs[i] = float64(i)*1e-3, float64(i)
}
rb.write(ts, vs)
if msg := h.buildTriggerCapture(1.0, 0.5, 0.5); msg == nil {
t.Fatal("buildTriggerCapture produced no frame")
}
// The trigger re-arms and the stream runs on until the captured window has
// been overwritten several times over.
for pass := 0; pass < 5; pass++ {
for i := range ts {
ts[i] += 2.0
}
rb.write(ts, vs)
}
if rt, _ := rb.slice(0.5, 1.5); len(rt) != 0 {
t.Fatalf("ring still holds %d points of the captured window; the test is not exercising the hold", len(rt))
}
got := h.zoomSlice(0.8, 0.9, []string{"s1:sig"}, 1<<30)
sd, ok := got["s1:sig"]
if !ok {
t.Fatal("zoom into the held capture returned nothing")
}
if len(sd.T) != 101 {
t.Fatalf("zoom returned %d points, want the 101 samples in 0.8..0.9", len(sd.T))
}
if sd.V[0] != 800 || sd.V[len(sd.V)-1] != 900 {
t.Fatalf("zoom returned values %v..%v, want 800..900", sd.V[0], sd.V[len(sd.V)-1])
}
// A live zoom outside the held window still reaches the rings.
if live := h.zoomSlice(11.0, 11.1, []string{"s1:sig"}, 1<<30); len(live["s1:sig"].T) == 0 {
t.Fatal("live zoom outside the capture was swallowed by the hold")
}
}
// A shot that yields nothing must not blank the window already on screen.
func TestEmptyCaptureKeepsThePreviousHold(t *testing.T) {
h := NewHub()
rb := newSigRing(4000)
h.rings["s1:sig"] = rb
ts, vs := make([]float64, 2000), make([]float64, 2000)
for i := range ts {
ts[i], vs[i] = float64(i)*1e-3, float64(i)
}
rb.write(ts, vs)
h.buildTriggerCapture(1.0, 0.5, 0.5)
// A window the rings have no samples for at all.
if msg := h.buildTriggerCapture(500.0, 0.5, 0.5); msg != nil {
t.Fatal("capture of an empty window produced a frame")
}
if _, _, ok := h.capture.slice("s1:sig", 0.8, 0.9); !ok {
t.Fatal("empty capture dropped the previously held window")
}
}
File diff suppressed because it is too large Load Diff
+949
View File
@@ -0,0 +1,949 @@
package wshub
import (
"encoding/binary"
"math"
"os"
"path/filepath"
"testing"
"marte2/common/udpsprotocol"
)
// newTestHistory opens a writer in a temp dir with one signal file of the given
// declared rate, and returns the writer plus that signal's key.
func newTestHistory(t *testing.T, cfg HistoryConfig, rate float64) (*historyWriter, string) {
t.Helper()
if cfg.Directory == "" {
cfg.Directory = t.TempDir()
}
hw, err := newHistoryWriter(cfg)
if err != nil {
t.Fatalf("newHistoryWriter: %v", err)
}
hw.onSourceConfigured("src", []udpsprotocol.SignalInfo{
{Name: "sig", TypeCode: 8, SamplingRate: rate},
})
t.Cleanup(hw.close)
return hw, "src:sig"
}
func ramp(t0 float64, dt float64, n int) ([]float64, []float64) {
ts := make([]float64, n)
vs := make([]float64, n)
for i := range ts {
ts[i] = t0 + float64(i)*dt
vs[i] = float64(i)
}
return ts, vs
}
// A budget that cannot hold the window at full rate must buy the window by
// widening the min/max bucket, not by archiving a shorter stretch: a user
// looking at 600 s wants 600 s of it archived, coarser if need be.
func TestHistCapacityKeepsWindowByBucketing(t *testing.T) {
const mega = 1 << 20
cases := []struct {
name string
window float64
rate float64
maxPts int
wantBucket int
}{
// 60 s of 1 kSps is 60 k samples — well inside 1 MPt, so stored verbatim.
{"slow signal keeps full resolution", 60, 1000, mega, 1},
// 600 s of 1 MSps is 600 M samples against 16 Mi points: at 2 points per
// bucket and the headroom, ceil(2 × 1.25 × 600e6 / 16Mi) = 90 per bucket.
{"fast signal is enveloped", 600, 1e6, 16 * mega, 90},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
capacity, bucket := histCapacityFor(c.window, c.rate, 1, c.maxPts)
if bucket != c.wantBucket {
t.Errorf("bucket = %d, want %d", bucket, c.wantBucket)
}
if capacity > uint32(c.maxPts) {
t.Errorf("capacity %d exceeds the %d-point budget", capacity, c.maxPts)
}
// The whole window has to fit, which is the entire point.
if covered := histCoverageSec(capacity, bucket, 1, c.rate); covered < c.window {
t.Errorf("archive covers %.1f s, want the %.1f s window", covered, c.window)
}
})
}
}
// The file exists to serve the window, so it must track it: a client that widens
// what it displays must not be left reading an archive sized for the old span.
func TestHistorySetWindowResizesFiles(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{WindowSec: 10}, 1000)
before := hw.files[key]
if before.bucket != 1 || histCoverageSec(before.capacity, 1, 1, 1000) < 10 {
t.Fatalf("initial geometry = cap %d bucket %d, want 10 s verbatim",
before.capacity, before.bucket)
}
if !hw.setWindow(600) {
t.Fatal("setWindow reported no change for a 60× wider window")
}
after := hw.files[key]
if after == before {
t.Fatal("the file was not re-created")
}
if cov := histCoverageSec(after.capacity, after.bucket, 1, 1000); cov < 600 {
t.Fatalf("archive covers %.1f s, want the new 600 s window", cov)
}
// Same window again: nothing to do, and re-creating the file would throw the
// archive away for nothing.
if hw.setWindow(600) {
t.Fatal("setWindow re-sized for an unchanged window")
}
// A nudge inside the hysteresis band must not either.
if hw.setWindow(610) {
t.Fatal("setWindow re-sized for a 2 % window change")
}
if hw.files[key] != after {
t.Fatal("the file was re-created despite the hysteresis")
}
}
// The archive is what a zoom beyond the rings reads, so a spike that only the
// archive still holds must survive being written to it.
func TestHistoryBucketedWriteKeepsPeaks(t *testing.T) {
// 1 kSps for 1 s = 1000 samples, plus headroom, into a 100-point budget →
// buckets of ceil(2 × 1.25 × 1000 / 100) = 25.
hw, key := newTestHistory(t, HistoryConfig{
WindowSec: 1, MinDiskFreeMB: -1, MaxPointsPerSignal: 100,
}, 1000)
hf := hw.files[key]
if hf.bucket != 25 {
t.Fatalf("bucket = %d, want 25", hf.bucket)
}
ts := make([]float64, 1000)
vs := make([]float64, 1000)
for i := range ts {
ts[i] = float64(i) * 0.001
}
vs[137] = 7.5 // a one-sample positive spike
vs[500] = -3.5 // and a negative one
hw.write(key, ts, vs)
rt, rv := hw.readRange(key, 0, 1, 1000)
if len(rt) == 0 {
t.Fatal("nothing archived")
}
hi, lo := false, false
for i := range rv {
if rv[i] == 7.5 && rt[i] == ts[137] {
hi = true
}
if rv[i] == -3.5 && rt[i] == ts[500] {
lo = true
}
}
if !hi || !lo {
t.Errorf("archive lost a spike (positive kept=%v, negative kept=%v)", hi, lo)
}
// A partial bucket is not written until it completes, so the last few
// samples may be missing; everything before them must be there.
if hf.count == 0 || hf.count > hf.capacity {
t.Errorf("archived %d points into a %d-point file", hf.count, hf.capacity)
}
}
func TestHistoryDisabledWithoutDirectory(t *testing.T) {
hw, err := newHistoryWriter(HistoryConfig{})
if err != nil {
t.Fatalf("newHistoryWriter: %v", err)
}
if hw != nil {
t.Fatal("empty Directory must disable history")
}
// Every method must stay usable on the nil writer, which is how the hub
// avoids guarding each call site.
if hw.enabled() {
t.Fatal("nil writer reports enabled")
}
hw.write("src:sig", []float64{1}, []float64{1})
hw.flushHeaders()
hw.close()
if rt, _ := hw.readRange("src:sig", 0, 1, 10); rt != nil {
t.Fatal("nil writer returned data")
}
if len(hw.info()) != 0 {
t.Fatal("nil writer returned info entries")
}
}
func TestHistoryWriteReadRoundTrip(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{}, 100)
ts, vs := ramp(10, 0.01, 500)
hw.write(key, ts, vs)
rt, rv := hw.readRange(key, 10.5, 11.0, 10000)
if len(rt) != 51 { // inclusive both ends, 0.01 s spacing
t.Fatalf("read %d points, want 51", len(rt))
}
if rt[0] < 10.5-1e-9 || rt[len(rt)-1] > 11.0+1e-9 {
t.Fatalf("range [%v, %v] escapes the request", rt[0], rt[len(rt)-1])
}
for i := range rt {
wantV := math.Round((rt[i] - 10) / 0.01)
if math.Abs(rv[i]-wantV) > 1e-6 {
t.Fatalf("point %d: value %v, want %v", i, rv[i], wantV)
}
}
}
func TestHistoryReadRangeOutsideDataIsEmpty(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{}, 100)
ts, vs := ramp(10, 0.01, 100)
hw.write(key, ts, vs)
if rt, _ := hw.readRange(key, 100, 200, 1000); len(rt) != 0 {
t.Fatalf("read %d points past the newest sample", len(rt))
}
if rt, _ := hw.readRange(key, 0, 5, 1000); len(rt) != 0 {
t.Fatalf("read %d points before the oldest sample", len(rt))
}
if rt, _ := hw.readRange("src:missing", 10, 11, 1000); rt != nil {
t.Fatal("unknown key returned data")
}
if rt, _ := hw.readRange(key, 11, 10, 1000); rt != nil {
t.Fatal("inverted range returned data")
}
}
// Once the file has wrapped, the oldest samples must be gone and the retained
// window must still read back contiguously across the wrap point.
func TestHistoryWrapAround(t *testing.T) {
// A sub-second window at 1 Sps sizes below the 1000-pair floor, which is a
// cheap capacity to wrap.
hw, key := newTestHistory(t, HistoryConfig{WindowSec: 0.36}, 1)
hf := hw.files[key]
if hf.capacity != histMinCapacity {
t.Fatalf("capacity = %d, want the %d floor", hf.capacity, histMinCapacity)
}
// 2.5 fills, in batches that do not align with the capacity so the wrap
// lands mid-batch.
total := 2500
ts, vs := ramp(0, 1, total)
for i := 0; i < total; i += 333 {
end := i + 333
if end > total {
end = total
}
hw.write(key, ts[i:end], vs[i:end])
}
if hf.count != histMinCapacity {
t.Fatalf("count = %d, want a full %d", hf.count, histMinCapacity)
}
wantOldest := float64(total - histMinCapacity)
if hf.tOldest != wantOldest {
t.Fatalf("tOldest = %v, want %v", hf.tOldest, wantOldest)
}
if hf.tNewest != float64(total-1) {
t.Fatalf("tNewest = %v, want %v", hf.tNewest, float64(total-1))
}
rt, rv := hw.readRange(key, wantOldest, float64(total-1), 10000)
if len(rt) != histMinCapacity {
t.Fatalf("read %d points, want the full %d", len(rt), histMinCapacity)
}
for i := range rt {
want := wantOldest + float64(i)
if rt[i] != want || rv[i] != want {
t.Fatalf("point %d = (%v, %v), want (%v, %v)", i, rt[i], rv[i], want, want)
}
}
// The evicted samples must not come back.
if et, _ := hw.readRange(key, 0, wantOldest-1, 10000); len(et) != 0 {
t.Fatalf("read %d evicted points", len(et))
}
}
// A single batch larger than the file keeps its tail, not its head.
func TestHistoryOversizedBatchKeepsTail(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{WindowSec: 0.36}, 1)
ts, vs := ramp(0, 1, 3000)
hw.write(key, ts, vs)
hf := hw.files[key]
if hf.count != histMinCapacity {
t.Fatalf("count = %d, want %d", hf.count, histMinCapacity)
}
if hf.tNewest != 2999 {
t.Fatalf("tNewest = %v, want 2999", hf.tNewest)
}
rt, _ := hw.readRange(key, 2000, 2999, 10000)
if len(rt) != histMinCapacity || rt[0] != 2000 {
t.Fatalf("retained window starts at %v with %d points, want 2000 / %d",
rt[0], len(rt), histMinCapacity)
}
}
func TestHistoryDecimation(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{Decimation: 4}, 100)
// Two batches, so the decimation phase must carry across the call boundary
// rather than restarting.
ts, vs := ramp(0, 0.01, 100)
hw.write(key, ts[:37], vs[:37])
hw.write(key, ts[37:], vs[37:])
rt, _ := hw.readRange(key, -1, 1e9, 10000)
if len(rt) != 25 {
t.Fatalf("kept %d of 100 points at decimation 4, want 25", len(rt))
}
for i := 1; i < len(rt); i++ {
if d := rt[i] - rt[i-1]; math.Abs(d-0.04) > 1e-9 {
t.Fatalf("spacing at %d = %v, want 0.04", i, d)
}
}
}
// The input slices are shared with the zoom ring and the trigger, so decimation
// must not touch them.
func TestHistoryWriteDoesNotMutateInput(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{Decimation: 3}, 100)
ts, vs := ramp(0, 0.01, 30)
tCopy := append([]float64(nil), ts...)
vCopy := append([]float64(nil), vs...)
hw.write(key, ts, vs)
for i := range ts {
if ts[i] != tCopy[i] || vs[i] != vCopy[i] {
t.Fatalf("write mutated input at %d", i)
}
}
}
// Reopening the same directory must pick the file back up with its contents,
// which is the whole point of persisting the header.
func TestHistoryReopenPreservesData(t *testing.T) {
dir := t.TempDir()
cfg := HistoryConfig{Directory: dir, WindowSec: 0.36}
sigs := []udpsprotocol.SignalInfo{{Name: "sig", TypeCode: 8, SamplingRate: 1}}
hw, err := newHistoryWriter(cfg)
if err != nil {
t.Fatalf("newHistoryWriter: %v", err)
}
hw.onSourceConfigured("src", sigs)
ts, vs := ramp(0, 1, 400)
hw.write("src:sig", ts, vs)
hw.close()
hw2, err := newHistoryWriter(cfg)
if err != nil {
t.Fatalf("reopen: %v", err)
}
defer hw2.close()
hw2.onSourceConfigured("src", sigs)
hf := hw2.files["src:sig"]
if hf.count != 400 || hf.head != 400 {
t.Fatalf("reopened count=%d head=%d, want 400/400", hf.count, hf.head)
}
rt, rv := hw2.readRange("src:sig", 100, 199, 10000)
if len(rt) != 100 || rt[0] != 100 || rv[0] != 100 {
t.Fatalf("reopened read = %d points starting (%v, %v)", len(rt), rt[0], rv[0])
}
// Appending after the reopen must continue where the file left off.
ts2, vs2 := ramp(400, 1, 50)
hw2.write("src:sig", ts2, vs2)
if hf.tNewest != 449 {
t.Fatalf("tNewest after append = %v, want 449", hf.tNewest)
}
}
// A file sized for a different rate cannot be reused, so it must be recreated
// rather than reopened with a mismatched capacity.
func TestHistoryReopenWithDifferentCapacityRecreates(t *testing.T) {
dir := t.TempDir()
cfg := HistoryConfig{Directory: dir, WindowSec: 3600}
hw, _ := newHistoryWriter(cfg)
hw.onSourceConfigured("src", []udpsprotocol.SignalInfo{
{Name: "sig", TypeCode: 8, SamplingRate: 10},
})
firstCap := hw.files["src:sig"].capacity
hw.write("src:sig", []float64{1, 2}, []float64{1, 2})
hw.close()
hw2, _ := newHistoryWriter(cfg)
defer hw2.close()
hw2.onSourceConfigured("src", []udpsprotocol.SignalInfo{
{Name: "sig", TypeCode: 8, SamplingRate: 100}, // 10× the rate
})
hf := hw2.files["src:sig"]
if hf.capacity == firstCap {
t.Fatalf("capacity unchanged at %d despite a 10x rate change", firstCap)
}
if hf.count != 0 {
t.Fatalf("recreated file kept %d samples", hf.count)
}
}
// A corrupt header must not be trusted: the file gets rebuilt instead.
func TestHistoryCorruptHeaderRecreates(t *testing.T) {
dir := t.TempDir()
cfg := HistoryConfig{Directory: dir, WindowSec: 0.36}
sigs := []udpsprotocol.SignalInfo{{Name: "sig", TypeCode: 8, SamplingRate: 1}}
hw, _ := newHistoryWriter(cfg)
hw.onSourceConfigured("src", sigs)
hw.write("src:sig", []float64{1, 2, 3}, []float64{1, 2, 3})
hw.close()
path := filepath.Join(dir, "src", "sig.shist")
f, err := os.OpenFile(path, os.O_RDWR, 0o644)
if err != nil {
t.Fatalf("open: %v", err)
}
if _, err := f.WriteAt([]byte("XXXX"), 0); err != nil { // clobber the magic
t.Fatalf("clobber: %v", err)
}
f.Close()
hw2, _ := newHistoryWriter(cfg)
defer hw2.close()
hw2.onSourceConfigured("src", sigs)
if got := hw2.files["src:sig"].count; got != 0 {
t.Fatalf("count = %d, want a recreated empty file", got)
}
}
// Raising the budget from the UI has to buy resolution: same duration, a
// narrower min/max bucket. Lowering it again must not overrun the new budget.
func TestSetBudgetRebucketsAtTheSameDuration(t *testing.T) {
// 100 s of 100 kSps is 10 M samples, well past either budget.
hw, key := newTestHistory(t, HistoryConfig{
WindowSec: 100, MaxPointsPerSignal: 100_000,
}, 1e5)
before := hw.files[key]
if before.bucket <= 1 {
t.Fatalf("bucket = %d, want the signal enveloped to fit the budget", before.bucket)
}
if got := hw.setBudget(1_000_000); got != 1_000_000 {
t.Fatalf("setBudget = %d, want 1000000", got)
}
after := hw.files[key]
if after == before {
t.Fatal("the file was not re-created")
}
if after.bucket >= before.bucket {
t.Fatalf("bucket %d → %d, want a finer envelope for a 10× budget",
before.bucket, after.bucket)
}
if after.capacity > 1_000_000 {
t.Fatalf("capacity = %d, over the 1 MPts budget", after.capacity)
}
// The point of the envelope: the duration is covered whatever the budget.
if cov := float64(after.capacity) * float64(after.bucket) / 2 / 1e5; cov < 99 {
t.Fatalf("coverage = %.1f s, want ~100 s", cov)
}
if got := hw.setBudget(100_000); got != 100_000 {
t.Fatalf("setBudget back = %d, want 100000", got)
}
if c := hw.files[key].capacity; c > 100_000 {
t.Fatalf("capacity = %d, over the restored 100 kPts budget", c)
}
}
// A budget that leaves a signal's geometry alone must leave its archive alone
// too — re-creating files nobody asked to resize would throw away history.
func TestSetBudgetKeepsUnaffectedFiles(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{
WindowSec: 1, MaxPointsPerSignal: 16 << 20,
}, 1000)
ts, vs := ramp(0, 0.001, 100)
hw.write(key, ts, vs)
hw.setBudget(8 << 20) // still far more than the 1000 points this signal needs
hf := hw.files[key]
if hf.bucket != 1 {
t.Fatalf("bucket = %d, want the slow signal still archived verbatim", hf.bucket)
}
if hf.count != 100 {
t.Fatalf("count = %d, want the 100 archived samples kept", hf.count)
}
}
// Time-reference signals are the clock for the others, so archiving them would
// just waste disk.
func TestHistorySkipsTimeSignals(t *testing.T) {
dir := t.TempDir()
hw, _ := newHistoryWriter(HistoryConfig{Directory: dir})
defer hw.close()
hw.onSourceConfigured("src", []udpsprotocol.SignalInfo{
{Name: "TimeArray", TypeCode: histTypeCodeUint64, SamplingRate: 1000},
{Name: "data", TypeCode: 8, SamplingRate: 1000},
})
if _, ok := hw.files["src:TimeArray"]; ok {
t.Fatal("uint64 time signal was archived")
}
if _, ok := hw.files["src:data"]; !ok {
t.Fatal("data signal was not archived")
}
}
// A second CONFIG for the same source must not throw away the history already
// collected for signals it re-declares.
func TestHistoryReconfigureKeepsExistingFile(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{WindowSec: 0.36}, 1)
hw.write(key, []float64{1, 2, 3}, []float64{1, 2, 3})
before := hw.files[key]
hw.onSourceConfigured("src", []udpsprotocol.SignalInfo{
{Name: "sig", TypeCode: 8, SamplingRate: 1},
{Name: "sig2", TypeCode: 8, SamplingRate: 1},
})
if hw.files[key] != before {
t.Fatal("re-CONFIG replaced the existing signal file")
}
if before.count != 3 {
t.Fatalf("count = %d, want the 3 already written", before.count)
}
if _, ok := hw.files["src:sig2"]; !ok {
t.Fatal("newly declared signal was not opened")
}
}
// The C++ UDPStreamer declares samplingRate=0, so sizing the file on the spot
// would use a guess that is three orders of magnitude out at 1 MSps.
func TestHistoryDefersSignalsWithoutDeclaredRate(t *testing.T) {
dir := t.TempDir()
hw, _ := newHistoryWriter(HistoryConfig{Directory: dir})
defer hw.close()
hw.onSourceConfigured("src", []udpsprotocol.SignalInfo{
{Name: "fast", TypeCode: 8, SamplingRate: 0},
{Name: "known", TypeCode: 8, SamplingRate: 100},
})
if _, ok := hw.files["src:fast"]; ok {
t.Fatal("undeclared-rate signal was sized before its rate was measured")
}
if got := hw.pendingKeys(); len(got) != 1 || got[0] != "src:fast" {
t.Fatalf("pendingKeys = %v, want [src:fast]", got)
}
if _, ok := hw.files["src:known"]; !ok {
t.Fatal("declared-rate signal was deferred")
}
// Data for a deferred signal is dropped, not misfiled.
hw.write("src:fast", []float64{1}, []float64{1})
// A repeated CONFIG must not queue it twice.
hw.onSourceConfigured("src", []udpsprotocol.SignalInfo{
{Name: "fast", TypeCode: 8, SamplingRate: 0},
})
if got := hw.pendingKeys(); len(got) != 1 {
t.Fatalf("pendingKeys = %v after re-CONFIG, want one entry", got)
}
if !hw.openPending("src:fast", 100000) {
t.Fatal("openPending refused a measured rate")
}
hf, ok := hw.files["src:fast"]
if !ok {
t.Fatal("file not opened after the rate was measured")
}
// The default window × 100 kSps, enveloped if it does not fit the budget.
wantCap, wantBucket := histCapacityFor(defaultLiveWindowSec, 100000, 1, histDefaultMaxPoints)
if hf.capacity != wantCap || hf.bucket != wantBucket {
t.Fatalf("capacity/bucket = %d/%d, want %d/%d", hf.capacity, hf.bucket, wantCap, wantBucket)
}
if len(hw.pendingKeys()) != 0 {
t.Fatal("signal still pending after being opened")
}
if hw.openPending("src:fast", 100000) {
t.Fatal("openPending reopened an already-open signal")
}
}
func TestOpenPendingHistoryFilesUsesMeasuredRate(t *testing.T) {
h := NewHub()
if err := h.EnableHistory(HistoryConfig{Directory: t.TempDir(), WindowSec: 3.6}); err != nil {
t.Fatalf("EnableHistory: %v", err)
}
defer h.CloseHistory()
h.hist.onSourceConfigured("s1", []udpsprotocol.SignalInfo{
{Name: "sig", TypeCode: 8, SamplingRate: 0},
})
rb := newSigRing(200000)
h.rings["s1:sig"] = rb
// Too little data to measure a rate from: the sweep must wait rather than
// size the file from a burst.
fillRing(rb, 0, 100000, 100) // 1 ms of data
h.openPendingHistoryFiles(100)
if len(h.hist.pendingKeys()) != 1 {
t.Fatal("sweep sized the file from a sub-millisecond sample")
}
fillRing(rb, 0, 100000, 100000) // 1 s at 100 kSps
h.openPendingHistoryFiles(200)
hf, ok := h.hist.files["s1:sig"]
if !ok {
t.Fatal("file not opened once the rate was measurable")
}
// 3.6 s at ~100 kSps, plus headroom, ≈ 450 000 pairs; a fixed 1 kHz guess
// would have produced the 1000-sample floor instead.
if hf.capacity < 400_000 || hf.capacity > 500_000 {
t.Fatalf("capacity = %d, want ~450000 from the measured 100 kSps", hf.capacity)
}
}
func TestOpenPendingHistoryFilesIsThrottled(t *testing.T) {
h := NewHub()
if err := h.EnableHistory(HistoryConfig{Directory: t.TempDir()}); err != nil {
t.Fatalf("EnableHistory: %v", err)
}
defer h.CloseHistory()
h.hist.onSourceConfigured("s1", []udpsprotocol.SignalInfo{
{Name: "sig", TypeCode: 8, SamplingRate: 0},
})
h.openPendingHistoryFiles(100) // no ring yet: nothing to measure
rb := newSigRing(20000)
fillRing(rb, 0, 1000, 20000)
h.rings["s1:sig"] = rb
h.openPendingHistoryFiles(100.5)
if len(h.hist.files) != 0 {
t.Fatal("sweep ran inside the throttle window")
}
h.openPendingHistoryFiles(200)
if len(h.hist.files) != 1 {
t.Fatal("sweep did not run after the throttle window elapsed")
}
}
// A hub without history must tolerate the sweep, since Run() calls it every tick.
func TestOpenPendingHistoryFilesNoopWithoutHistory(t *testing.T) {
h := NewHub()
h.openPendingHistoryFiles(100)
}
func TestHistoryInfoShape(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{WindowSec: 0.36}, 1)
// Reported before any data arrives, so clients can enable their history UI.
inf := hw.info()
if e, ok := inf[key]; !ok || e.Count != 0 || e.Capacity != histMinCapacity {
t.Fatalf("pre-data info = %+v (present=%v)", inf[key], ok)
}
ts, vs := ramp(5, 1, 10)
hw.write(key, ts, vs)
e := hw.info()[key]
if e.Count != 10 || e.T0 != 5 || e.T1 != 14 {
t.Fatalf("info = %+v, want count=10 t0=5 t1=14", e)
}
}
func TestHistoryHeaderIsPersistedOnFlush(t *testing.T) {
dir := t.TempDir()
hw, key := newTestHistory(t, HistoryConfig{Directory: dir, WindowSec: 0.36, Decimation: 2}, 1)
ts, vs := ramp(0, 1, 20)
hw.write(key, ts, vs)
hw.flushHeaders()
hdr, err := os.ReadFile(filepath.Join(dir, "src", "sig.shist"))
if err != nil {
t.Fatalf("read: %v", err)
}
if string(hdr[0:4]) != "SHR1" {
t.Fatalf("magic = %q", hdr[0:4])
}
if v := binary.LittleEndian.Uint32(hdr[4:]); v != histVersion {
t.Fatalf("version = %d, want %d", v, histVersion)
}
if c := binary.LittleEndian.Uint32(hdr[8:]); c != histMinCapacity {
t.Fatalf("capacity = %d, want %d", c, histMinCapacity)
}
if h := binary.LittleEndian.Uint32(hdr[12:]); h != 10 {
t.Fatalf("head = %d, want 10 (20 samples, decimation 2)", h)
}
if n := binary.LittleEndian.Uint32(hdr[16:]); n != 10 {
t.Fatalf("count = %d, want 10", n)
}
if d := binary.LittleEndian.Uint32(hdr[20:]); d != 2 {
t.Fatalf("decimation = %d, want 2", d)
}
if got := math.Float64frombits(binary.LittleEndian.Uint64(hdr[32:])); got != 19 {
t.Fatalf("tNewest = %v, want 19", got)
}
// The data region must be pre-allocated in full, not grown as it fills.
if want := int64(histHeaderSize) + histMinCapacity*histPairSize; int64(len(hdr)) != want {
t.Fatalf("file size = %d, want the pre-allocated %d", len(hdr), want)
}
}
func TestSanitizeHistName(t *testing.T) {
cases := map[string]string{
"Signal_1": "Signal_1",
"GAM.Out[0]": "GAM.Out[0]",
"a/b": "a_b",
"../../etc/pass": ".._.._etc_pass",
"": "_",
".": "_",
"..": "_",
"with space": "with_space",
"nul\x00byte": "nul_byte",
}
for in, want := range cases {
if got := sanitizeHistName(in); got != want {
t.Errorf("sanitizeHistName(%q) = %q, want %q", in, got, want)
}
}
}
// A producer-supplied name must never place a file outside the history dir.
func TestHistoryNameCannotEscapeDirectory(t *testing.T) {
dir := t.TempDir()
hw, _ := newHistoryWriter(HistoryConfig{Directory: dir})
defer hw.close()
hw.onSourceConfigured("../evil", []udpsprotocol.SignalInfo{
{Name: "../../pwned", TypeCode: 8, SamplingRate: 1},
})
found := false
err := filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
if err == nil && !info.IsDir() {
found = true
}
return err
})
if err != nil {
t.Fatalf("walk: %v", err)
}
if !found {
t.Fatal("no file created inside the history directory")
}
if _, err := os.Stat(filepath.Join(dir, "..", "..", "pwned.shist")); err == nil {
t.Fatal("a file escaped the history directory")
}
}
func TestHistCapacityFor(t *testing.T) {
cases := []struct {
window float64
rate float64
decim int
maxPts int
want uint32
wantBucket int
}{
// window × rate / decimation, plus the 1.25 headroom.
{600, 1000, 1, 0, 750_000, 1},
{600, 1000, 10, 0, 75_000, 1},
{10, 100, 1, 0, 1250, 1},
{600, 0.001, 1, 0, histMinCapacity, 1}, // absurdly slow → the floor
// Absurdly fast: bounded by histMaxCapacity, and the window is bought with
// a correspondingly absurd bucket rather than by storing less of it.
{600, 1e9, 1, 0, 1_073_729_421, 1397},
{math.NaN(), 1000, 1, 0, histMinCapacity, 1},
{600, math.NaN(), 1, 0, histMinCapacity, 1},
// A budget envelopes a fast signal without touching a slow one, and the
// window is kept either way.
{600, 1e6, 1, 16 << 20, 16_666_667, 90},
{600, 1000, 1, 16 << 20, 750_000, 1},
}
for _, c := range cases {
got, bucket := histCapacityFor(c.window, c.rate, c.decim, c.maxPts)
if got != c.want || bucket != c.wantBucket {
t.Errorf("histCapacityFor(%v, %v, %d, %d) = %d/%d, want %d/%d",
c.window, c.rate, c.decim, c.maxPts, got, bucket, c.want, c.wantBucket)
}
}
}
func TestHistoryConfigDefaults(t *testing.T) {
c := HistoryConfig{}.withDefaults()
if c.WindowSec != defaultLiveWindowSec || c.Decimation != 1 || c.FlushIntervalSec != 5 || c.MinDiskFreeMB != 500 {
t.Fatalf("defaults = %+v", c)
}
// A negative value is the explicit "no disk guard", so it must survive
// defaulting rather than being turned back into 500.
if got := (HistoryConfig{MinDiskFreeMB: -1}).withDefaults().MinDiskFreeMB; got != -1 {
t.Fatalf("MinDiskFreeMB = %d, want the -1 that disables the guard", got)
}
}
func TestHistoryWritePausedWhenDiskLow(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{}, 100)
hw.diskLow = true
hw.write(key, []float64{1, 2, 3}, []float64{1, 2, 3})
if hw.files[key].count != 0 {
t.Fatalf("count = %d, want 0 while the disk guard is tripped", hw.files[key].count)
}
hw.diskLow = false
hw.write(key, []float64{1, 2, 3}, []float64{1, 2, 3})
if hw.files[key].count != 3 {
t.Fatalf("count = %d, want 3 once writing resumes", hw.files[key].count)
}
}
func TestHistoryReadRangeRespectsMaxOut(t *testing.T) {
// A window wide enough that the whole ramp is still on disk when it is read.
hw, key := newTestHistory(t, HistoryConfig{WindowSec: 50}, 100)
ts, vs := ramp(0, 0.01, 5000)
hw.write(key, ts, vs)
rt, rv := hw.readRange(key, -1, 1e9, 100)
if len(rt) != 100 || len(rv) != 100 {
t.Fatalf("read %d/%d points, want the 100 cap", len(rt), len(rv))
}
}
func TestHistoryReadRangeSpansWholeRange(t *testing.T) {
// A capped read must thin the range out, not return its first maxOut
// samples: a client asking for 100 points over 50 s and getting the first
// second of it draws a flat line and falls back to its coarse copy.
hw, key := newTestHistory(t, HistoryConfig{WindowSec: 50}, 100)
ts, vs := ramp(0, 0.01, 5000)
hw.write(key, ts, vs)
rt, _ := hw.readRange(key, 0, 49.99, 100)
if len(rt) == 0 {
t.Fatal("no points read")
}
if got := rt[len(rt)-1] - rt[0]; got < 0.95*49.99 {
t.Fatalf("read spans %.2f s of the 49.99 s asked; a capped read must "+
"cover the whole range", got)
}
}
func TestHistoryReadRangeUncappedIsExact(t *testing.T) {
// Below the cap every sample in the range comes back, so a zoom deep enough
// to fit is served at full resolution.
hw, key := newTestHistory(t, HistoryConfig{WindowSec: 50}, 100)
ts, vs := ramp(0, 0.01, 5000)
hw.write(key, ts, vs)
rt, rv := hw.readRange(key, 1, 1.99, 1000)
if len(rt) != 100 {
t.Fatalf("read %d points, want the 100 samples in [1, 1.99]", len(rt))
}
if rv[0] != 100 || rv[len(rv)-1] != 199 {
t.Fatalf("values %.0f..%.0f, want 100..199", rv[0], rv[len(rv)-1])
}
}
// The capture copy is what makes a trigger window zoomable long after the
// circular archive has wrapped over it.
func TestCaptureRangeOutlivesTheArchive(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{}, 0.001) // floor capacity: 1000
hf := hw.files[key]
if hf.capacity != histMinCapacity {
t.Fatalf("capacity = %d, want the %d floor", hf.capacity, histMinCapacity)
}
ts, vs := ramp(0, 1, 1000) // t = 0..999, exactly full
hw.write(key, ts, vs)
hw.captureRange(500, 600)
// Wrap the archive right over the captured window.
ts2, vs2 := ramp(1000, 1, 1000)
hw.write(key, ts2, vs2)
if hf.tOldest != 1000 || hf.tNewest != 1999 {
t.Fatalf("archive holds [%v, %v], want [1000, 1999]: capturing must not "+
"stop or divert the archive", hf.tOldest, hf.tNewest)
}
rt, rv := hw.readRange(key, 500, 600, 1000)
if len(rt) != 101 {
t.Fatalf("read %d captured samples in [500, 600], want 101", len(rt))
}
if rv[0] != 500 || rv[len(rv)-1] != 600 {
t.Fatalf("captured values %.0f..%.0f, want 500..600", rv[0], rv[len(rv)-1])
}
// A range the capture does not hold is still answered by the archive.
if at, _ := hw.readRange(key, 1500, 1600, 1000); len(at) != 101 {
t.Fatalf("read %d archived samples in [1500, 1600], want 101", len(at))
}
// The next capture replaces the last one, and only then.
hw.captureRange(1500, 1600)
if ct, _ := hw.readRange(key, 500, 600, 1000); len(ct) != 0 {
t.Fatalf("read %d samples of a replaced capture, want 0", len(ct))
}
}
// Delivering a capture copies its window out of the archive, and the archive
// keeps rolling so the next capture's pre-trigger window is there when it fires.
func TestTriggerCaptureCopiesWindowToDisk(t *testing.T) {
h := NewHub()
if err := h.EnableHistory(HistoryConfig{
Directory: t.TempDir(), WindowSec: 36, MinDiskFreeMB: -1,
}); err != nil {
t.Fatalf("EnableHistory: %v", err)
}
t.Cleanup(h.CloseHistory)
h.hist.onSourceConfigured("s1", []udpsprotocol.SignalInfo{
{Name: "sig", TypeCode: 8, SamplingRate: 1000},
})
h.rings["s1:sig"] = newSigRing(10000)
h.trigger.SetConfig(trigConfig{signalKey: "s1:sig", edge: "rising", threshold: 0,
windowSec: 1, prePercent: 20, mode: "single"})
h.trigger.Arm()
// Cross the threshold, then cover the post-trigger window so the capture
// comes due on the next tick.
h.ingest("s1:sig", 1, []float64{5.0, 5.001}, []float64{-1, 1})
h.ingest("s1:sig", 1, []float64{6.0}, []float64{1})
h.triggerTick()
if h.trigger.State() != trigTriggered {
t.Fatalf("state = %q, want triggered", h.trigger.State())
}
cf := h.hist.captures["s1:sig"]
if cf == nil {
t.Fatal("capture delivered but its window was not copied to disk")
}
// The window is [trigTime-0.2, trigTime+0.8] around the 5.001 crossing, so
// the sample at 6.0 falls outside it.
if cf.count != 2 || cf.tOldest != 5.0 || cf.tNewest != 5.001 {
t.Fatalf("capture holds %d samples in [%v, %v], want 2 in [5, 5.001]",
cf.count, cf.tOldest, cf.tNewest)
}
// Copying the window leaves the archive rolling, so the next capture's
// pre-trigger window — written before its trigger fires — is there for it.
h.ingest("s1:sig", 1, []float64{7.0}, []float64{1})
if got := h.hist.files["s1:sig"].count; got != 4 {
t.Fatalf("archived %d samples, want 4: capturing must not stop writing", got)
}
// Rearming does not discard the capture: it stays on screen until the next
// trigger replaces it.
h.trigger.Arm()
h.triggerTick()
if h.hist.captures["s1:sig"] != cf {
t.Fatal("rearming discarded the capture the client is still showing")
}
}
func TestHistSearch(t *testing.T) {
vals := []float64{0, 1, 2, 3, 4, 5}
at := func(i uint32) float64 { return vals[i] }
if got := histSearch(0, 6, func(i uint32) bool { return at(i) < 3 }); got != 3 {
t.Fatalf("lower bound = %d, want 3", got)
}
if got := histSearch(0, 6, func(i uint32) bool { return at(i) <= 3 }); got != 4 {
t.Fatalf("upper bound = %d, want 4", got)
}
if got := histSearch(0, 6, func(i uint32) bool { return at(i) < -1 }); got != 0 {
t.Fatalf("all-false = %d, want 0", got)
}
if got := histSearch(0, 6, func(i uint32) bool { return at(i) < 100 }); got != 6 {
t.Fatalf("all-true = %d, want 6", got)
}
}
+233 -74
View File
@@ -9,6 +9,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"unsafe"
@@ -27,6 +28,29 @@ type wsClient struct {
hub *Hub
conn *websocket.Conn
send chan wsMessage
// window is the timespan this client is displaying, in seconds, held as
// float64 bits. The retune sweep sizes the rings from the widest window in
// use, so it must be readable from the hub goroutine while readPump writes
// it. Zero means the client has not said, and the default applies.
window atomic.Uint64
}
func (c *wsClient) setDisplayWindowSec(s float64) {
c.window.Store(math.Float64bits(s))
}
func (c *wsClient) displayWindowSec() float64 {
return math.Float64frombits(c.window.Load())
}
// sendText enqueues one JSON frame for this client, dropping it if the client
// is not draining its queue.
func (c *wsClient) sendText(msg []byte) {
select {
case c.send <- wsMessage{websocket.TextMessage, msg}:
default:
}
}
func (c *wsClient) writePump() {
@@ -128,6 +152,13 @@ func (c *wsClient) readPump() {
case c.hub.commandCh <- hubCmd{op: "wsReloadConfig"}:
default:
}
case "setWindow":
// Sizes the zoom rings: the hub cannot know how far back a
// client is plotting, and a window it has not been told
// about is a window the buffers may not reach.
if sec, ok := env["seconds"].(float64); ok && sec > 0 && !math.IsInf(sec, 0) {
c.setDisplayWindowSec(sec)
}
case "setMonotonic":
enabled, _ := env["enabled"].(bool)
select {
@@ -140,6 +171,9 @@ func (c *wsClient) readPump() {
if c.hub.handleTriggerCommand(t, env) {
break
}
if c.hub.handleHistoryCommand(c, t, env) {
break
}
// Unrecognized message type — forward to DebugCh
select {
case c.hub.DebugCh <- msg:
@@ -271,11 +305,27 @@ type Hub struct {
ringsMu sync.RWMutex
rings map[string]*sigRing // "sourceId:signalKey" → ring
// hist is the disk-backed archive behind long time windows, which hold far
// more samples than the in-memory rings can. nil when history is disabled.
// histOpenAt throttles the sweep that opens the files of signals whose
// producer declared no sampling rate; both are touched only from Run().
hist *historyWriter
histOpenAt float64
statsMu sync.RWMutex
statsMap map[string]*SourceStat
// trigger is the hub-side trigger FSM driving the oscilloscope capture mode.
trigger *triggerEngine
// ringTuneAt throttles the sweep that keeps each ring's depth and min/max
// bucket matched to the window being displayed; both are touched only from
// Run(). ringBudgetPts is that sweep's per-signal budget; set before Run().
trigger *triggerEngine
ringTuneAt float64
// capture is the trigger double buffer's read half: the last delivered
// capture window, kept out of the rings' way so the shot being viewed
// survives the re-arm that immediately follows it.
capture captureHold
ringBudgetPts int
onClientConnectMu sync.RWMutex
onClientConnect func(send func([]byte))
@@ -301,6 +351,44 @@ func NewHub() *Hub {
}
}
// SetRingBudget overrides the per-signal in-memory buffer budget, in points.
// Non-positive values restore the default. It must be called before Run().
// Each point costs 16 bytes, so the budget is the memory bound per temporal
// signal. It does not limit how long a window can be held: a window too long
// to fit at full rate is stored as min/max pairs instead (see retuneRings).
func (h *Hub) SetRingBudget(n int) {
if n <= 0 {
n = defaultRingPts
}
if n < ringCapInitial {
n = ringCapInitial
}
h.ringBudgetPts = n
}
func (h *Hub) ringBudget() int {
if h.ringBudgetPts <= 0 {
return defaultRingPts
}
return h.ringBudgetPts
}
// EnableHistory turns on the disk-backed history archive. It must be called
// before Run(). A HistoryConfig with an empty Directory leaves history off.
func (h *Hub) EnableHistory(cfg HistoryConfig) error {
hw, err := newHistoryWriter(cfg)
if err != nil {
return err
}
h.hist = hw
return nil
}
// CloseHistory flushes and closes the history files. Without it the samples
// written since the last periodic flush are on disk but unaccounted for in the
// file headers, so a restart would not see them.
func (h *Hub) CloseHistory() { h.hist.close() }
// SetOnClientConnect registers a callback invoked synchronously (from Run())
// each time a new WebSocket client connects. The callback receives a send
// function that enqueues one message to that specific client.
@@ -315,6 +403,22 @@ func (h *Hub) SetSourceManager(sm *SourceManager) {
h.sm = sm
}
// ingest routes one batch of full-resolution samples for a signal to every
// consumer that needs them at full rate: the in-memory zoom ring, the disk
// history and the trigger comparator. The live push is decimated separately by
// the caller. The ring and the archive may reduce what they store to fit their
// budget, but they are handed every sample so the reduction sees the extrema.
func (h *Hub) ingest(key string, nElem int, t, v []float64) {
if len(t) == 0 {
return
}
if rb := h.getRing(key); rb != nil {
rb.write(t, v)
}
h.hist.write(key, t, v)
h.trigger.feed(key, nElem, t, v)
}
// getRing returns the ring buffer for a fully-prefixed signal key, or nil.
func (h *Hub) getRing(key string) *sigRing {
h.ringsMu.RLock()
@@ -323,8 +427,10 @@ func (h *Hub) getRing(key string) *sigRing {
return rb
}
// zoomSlice extracts [t0, t1] from the full-resolution rings for the named
// signals, decimating each to at most n points.
// zoomSlice extracts [t0, t1] for the named signals, decimating each to at most
// n points. A range inside the last trigger capture is served from the held
// copy of it, which the re-arming acquisition cannot overwrite; everything else
// comes from the live rings.
func (h *Hub) zoomSlice(t0, t1 float64, keys []string, n int) map[string]sigData {
h.ringsMu.RLock()
refs := make(map[string]*sigRing, len(keys))
@@ -341,11 +447,14 @@ func (h *Hub) zoomSlice(t0, t1 float64, keys []string, n int) map[string]sigData
result := make(map[string]sigData, len(refs))
for k, rb := range refs {
rt, rv := rb.slice(t0, t1)
rt, rv, ok := h.capture.slice(k, t0, t1)
if !ok {
rt, rv = rb.slice(t0, t1)
}
if len(rt) == 0 {
continue
}
dt, dv := lttbDecimate(rt, rv, n)
dt, dv := minMaxDecimate(rt, rv, n)
result[k] = sigData{T: dt, V: dv}
}
return result
@@ -388,10 +497,7 @@ func (h *Hub) handleWSZoom(c *wsClient, env map[string]interface{}) {
log.Printf("hub: ws zoom encode: %v", err)
return
}
select {
case c.send <- wsMessage{websocket.TextMessage, reply}:
default:
}
c.sendText(reply)
}
// HandleZoom serves GET /api/zoom?...
@@ -526,6 +632,16 @@ func (h *Hub) Run() {
statsTicker := time.NewTicker(time.Second)
defer statsTicker.Stop()
// Header flushes are what make the archived samples findable again; the
// data region is written as it arrives. Ticks are ignored when history is
// off, so a disabled writer costs one no-op call per period.
flushPeriod := time.Duration(5) * time.Second
if h.hist.enabled() {
flushPeriod = time.Duration(h.hist.cfg.FlushIntervalSec) * time.Second
}
flushTicker := time.NewTicker(flushPeriod)
defer flushTicker.Stop()
sourcesMap := make(map[string]*sourceHubState)
var sourcesMsg []byte
@@ -570,6 +686,11 @@ func (h *Hub) Run() {
case c.send <- wsMessage{websocket.TextMessage, calMsg}:
default:
}
if h.hist.enabled() {
if msg := h.buildHistoryInfoMsg(); msg != nil {
c.sendText(msg)
}
}
// Notify the application layer so it can replay any persistent state
// (e.g., MARTe2 connection status, forced/traced signals).
h.onClientConnectMu.RLock()
@@ -670,16 +791,29 @@ func (h *Hub) Run() {
ne := sig.NumElements()
isTemporal := ne > 1 && sig.TimeMode != udpsprotocol.TimeModePacket
if isTemporal {
h.rings[pfxUpd+sig.Name] = newSigRing(ringCapTemporal)
h.rings[pfxUpd+sig.Name] = newSigRing(ringCapInitial)
} else if ne == 1 {
h.rings[pfxUpd+sig.Name] = newSigRing(ringCapScalar)
} else {
// n>1, TimeModePacket snapshot-waveform: each packet contributes n
// elements, so use the temporal capacity to hold enough history.
h.rings[pfxUpd+sig.Name] = newSigRing(ringCapTemporal)
// elements, so this is a fast stream too and gets the same budget.
h.rings[pfxUpd+sig.Name] = newSigRing(ringCapInitial)
}
}
h.ringsMu.Unlock()
// The held capture describes rings that no longer exist. A
// restarted producer can even replay the same timestamps, so
// keeping it would answer zooms with the old run's samples.
h.capture.clear()
// Opening the archive files touches the filesystem, so keep it
// off the Run() goroutine; the write path simply drops samples
// for a key whose file is not open yet.
if h.hist.enabled() {
go func(id string, sigs []udpsprotocol.SignalInfo) {
h.hist.onSourceConfigured(id, sigs)
h.broadcast(h.buildHistoryInfoMsg())
}(cmd.sourceID, cmd.sigs)
}
case "wsAddSource":
if h.sm != nil {
@@ -748,10 +882,15 @@ func (h *Hub) Run() {
continue
}
src, ok := sourcesMap[srcID]
if !ok || len(src.signals) == 0 || len(h.clients) == 0 {
if !ok || len(src.signals) == 0 {
pending[srcID] = pending[srcID][:0]
continue
}
// Built even with no clients connected: this is also what feeds
// the rings, the disk history and the trigger, none of which may
// stop just because nobody is watching. It also keeps the push
// cursors advancing, so the first client to connect does not get
// a backlog burst. Matches the C++ StreamHub.
msg := h.buildBinaryDataMessageForSource(src, samples)
pending[srcID] = pending[srcID][:0]
if msg != nil {
@@ -765,6 +904,9 @@ func (h *Hub) Run() {
}
h.triggerTick()
case <-flushTicker.C:
h.hist.flushHeaders()
case <-statsTicker.C:
h.statsMu.RLock()
snap := make(map[string]StatInfo, len(h.statsMap))
@@ -802,9 +944,21 @@ func writeFloat64s(buf []byte, off int, f []float64) int {
// ever recover, and the browser already decimates for display.
const maxPushPoints = 50
// Zoom ring depth, in samples per signal (16 bytes each). ringCapTemporal
// holds 6 s of a 1 MSps waveform; ringCapScalar holds 100 000 packets.
const ringCapTemporal = 6_000_000
// Ring geometry, in samples per signal (16 bytes each).
//
// defaultRingPts is the per-signal memory budget for temporal (array) signals:
// what the hub may spend keeping one signal available for zoom and for trigger
// captures. 10 M points is 160 MB. The budget buys resolution, not span —
// retuneRings buckets the input so the display window fits whatever the source
// rate is.
//
// ringCapInitial is where a ring starts, so a source that is configured but
// never sends costs nothing; the first retune sweep grows it to the budget.
//
// ringCapScalar sizes scalar signals, which arrive at the packet rate and would
// squander a budget meant for megasample streams.
const defaultRingPts = 10_000_000
const ringCapInitial = 250_000
const ringCapScalar = 100_000
// monotonicTolerance is the maximum inter-frame timestamp deviation (seconds)
@@ -817,52 +971,59 @@ const monotonicTolerance = 0.005 // 5 ms
// track real rate changes, slow enough to average out per-frame jitter.
const monotonicEMAAlpha = 0.01
// lttbDecimate reduces (tIn, vIn) to at most threshold representative points
// using the Largest-Triangle-Three-Buckets algorithm.
func lttbDecimate(tIn, vIn []float64, threshold int) ([]float64, []float64) {
// minMaxDecimate reduces (tIn, vIn) to at most threshold points the way an
// oscilloscope draws a trace it cannot show pixel-for-pixel: the range is split
// into threshold/2 equal buckets and each contributes its smallest and largest
// sample, in the order the two occurred.
//
// This is what replaced LTTB on every path here. LTTB picks the sample that
// makes the largest triangle with its neighbours, which reads as a plausible
// shape but silently drops a one-sample spike whenever a smoother neighbour
// scores higher — precisely the sample the user is looking for. The envelope
// cannot drop it: a spike is by definition its bucket's min or max. The cost is
// that a flat trace is drawn as a band rather than a line, which is how a scope
// behaves too.
//
// Both output arrays hold real samples with their real timestamps; nothing is
// interpolated or averaged.
func minMaxDecimate(tIn, vIn []float64, threshold int) ([]float64, []float64) {
n := len(tIn)
if n <= threshold || threshold < 3 {
// Below four there is no room for a single min/max pair plus endpoints.
if n <= threshold || threshold < 4 {
return tIn, vIn
}
outT := make([]float64, threshold)
outV := make([]float64, threshold)
outT[0], outV[0] = tIn[0], vIn[0]
outT[threshold-1], outV[threshold-1] = tIn[n-1], vIn[n-1]
every := float64(n-2) / float64(threshold-2)
a := 0
for i := 0; i < threshold-2; i++ {
avgS := int(float64(i+1)*every) + 1
avgE := int(float64(i+2)*every) + 1
if avgE > n {
avgE = n
buckets := threshold / 2
outT := make([]float64, 0, threshold)
outV := make([]float64, 0, threshold)
for b := 0; b < buckets; b++ {
lo := b * n / buckets
hi := (b + 1) * n / buckets
if b == buckets-1 {
hi = n
}
avgT, avgV, cnt := 0.0, 0.0, 0
for j := avgS; j < avgE; j++ {
avgT += tIn[j]
avgV += vIn[j]
cnt++
if lo >= hi {
continue
}
if cnt > 0 {
avgT /= float64(cnt)
avgV /= float64(cnt)
}
rS := int(float64(i)*every) + 1
rE := int(float64(i+1)*every) + 1
if rE > n {
rE = n
}
maxArea, next := -1.0, rS
aT, aV := tIn[a], vIn[a]
for j := rS; j < rE; j++ {
area := math.Abs((aT-avgT)*(vIn[j]-aV) - (aT-tIn[j])*(avgV-aV))
if area > maxArea {
maxArea = area
next = j
iMin, iMax := lo, lo
for j := lo + 1; j < hi; j++ {
if vIn[j] < vIn[iMin] {
iMin = j
}
if vIn[j] > vIn[iMax] {
iMax = j
}
}
outT[i+1], outV[i+1] = tIn[next], vIn[next]
a = next
// Emit in time order so the result plots as one ascending trace.
if iMin > iMax {
iMin, iMax = iMax, iMin
}
outT = append(outT, tIn[iMin])
outV = append(outV, vIn[iMin])
// A bucket whose samples are all equal has one extreme, not two.
if iMax != iMin {
outT = append(outT, tIn[iMax])
outV = append(outV, vIn[iMax])
}
}
return outT, outV
}
@@ -974,11 +1135,8 @@ func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []udpsp
allV = append(allV, vals[k])
}
}
if rb := h.getRing(pfx + sig.Name); rb != nil {
rb.write(allT, allV)
}
h.trigger.feed(pfx+sig.Name, n, allT, allV)
decimT, decimV := lttbDecimate(allT, allV, maxPushPoints)
h.ingest(pfx+sig.Name, n, allT, allV)
decimT, decimV := minMaxDecimate(allT, allV, maxPushPoints)
pairs[sig.Name] = pairBuf{t: decimT, v: decimV}
case sig.TimeMode == udpsprotocol.TimeModeFullArray:
@@ -1020,11 +1178,8 @@ func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []udpsp
allV = append(allV, vals[k])
}
}
if rb := h.getRing(pfx + sig.Name); rb != nil {
rb.write(allT, allV)
}
h.trigger.feed(pfx+sig.Name, n, allT, allV)
decimT, decimV := lttbDecimate(allT, allV, maxPushPoints)
h.ingest(pfx+sig.Name, n, allT, allV)
decimT, decimV := minMaxDecimate(allT, allV, maxPushPoints)
pairs[sig.Name] = pairBuf{t: decimT, v: decimV}
case n == 1:
@@ -1038,10 +1193,7 @@ func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []udpsp
ts = append(ts, float64(s.WallTime.UnixNano())/1e9)
vs = append(vs, vals[0])
}
if rb := h.getRing(pfx + sig.Name); rb != nil {
rb.write(ts, vs)
}
h.trigger.feed(pfx+sig.Name, 1, ts, vs)
h.ingest(pfx+sig.Name, 1, ts, vs)
pairs[sig.Name] = pairBuf{t: ts, v: vs}
default:
@@ -1107,12 +1259,19 @@ func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []udpsp
src.lastPktNs[sig.Name] = batch[len(batch)-1].WallTime.UnixNano()
}
if len(allT) > 0 {
if rb := h.getRing(pfx + sig.Name); rb != nil {
rb.write(allT, allV)
h.ingest(pfx+sig.Name, n, allT, allV)
// Live push: never below one packet's worth of elements, or LTTB
// would flatten the snapshot waveform itself; never above it
// either, since anything more is just packets that piled up
// during the tick. Pushing every point unconditionally does not
// survive a fast producer: a 5 kHz x 1000-element array is 5M
// points/s on the wire and the client queue never drains.
thr := maxPushPoints
if n > thr {
thr = n
}
h.trigger.feed(pfx+sig.Name, n, allT, allV)
// Live push: send all points without LTTB (fix 2).
pairs[sig.Name] = pairBuf{t: allT, v: allV}
decimT, decimV := minMaxDecimate(allT, allV, thr)
pairs[sig.Name] = pairBuf{t: decimT, v: decimV}
}
}
}
@@ -74,7 +74,7 @@ func TestHubSetCalibrationCommand(t *testing.T) {
sendCh := make(chan wsMessage, 64)
c := &wsClient{hub: h, send: sendCh}
h.register <- c
sleepMillis(20) // let Run() process the register and flush initial state msgs
sleepMillis(20) // let Run() process the register and flush initial state msgs
drainSendCh(sendCh) // discard state-sync messages (sources, trigger, cal, ...)
h.commandCh <- hubCmd{op: "wsSetCalibration", cal: CalConfig{
@@ -0,0 +1,116 @@
//go:build linux
package wshub
import (
"net"
"syscall"
"testing"
"time"
)
// setMulticastIf pins a socket's outgoing multicast interface (IP_MULTICAST_IF),
// which is exactly what UDPStreamer/UDPSServer does with its `Interface` key.
func setMulticastIf(t *testing.T, conn *net.UDPConn, ip [4]byte) {
t.Helper()
rc, err := conn.SyscallConn()
if err != nil {
t.Fatalf("SyscallConn: %v", err)
}
var sockErr error
if err := rc.Control(func(fd uintptr) {
sockErr = syscall.SetsockoptInet4Addr(int(fd), syscall.IPPROTO_IP, syscall.IP_MULTICAST_IF, ip)
}); err != nil {
t.Fatalf("Control: %v", err)
}
if sockErr != nil {
t.Fatalf("IP_MULTICAST_IF: %v", sockErr)
}
}
func TestInterfaceForIPResolvesLoopback(t *testing.T) {
ifi := interfaceForIP(net.ParseIP("127.0.0.1"))
if ifi == nil {
t.Fatal("no interface resolved for 127.0.0.1")
}
if ifi.Flags&net.FlagLoopback == 0 {
t.Fatalf("resolved %q for 127.0.0.1, which is not a loopback interface", ifi.Name)
}
}
func TestInterfaceForIPUnknownAddressIsNil(t *testing.T) {
// Unspecified and unassigned addresses must fall back to "let the kernel
// choose" rather than resolving to an arbitrary interface.
if ifi := interfaceForIP(net.IPv4zero); ifi != nil {
t.Fatalf("0.0.0.0 resolved to %q, want nil", ifi.Name)
}
if ifi := interfaceForIP(nil); ifi != nil {
t.Fatalf("nil IP resolved to %q, want nil", ifi.Name)
}
if ifi := interfaceForIP(net.ParseIP("203.0.113.42")); ifi != nil {
t.Fatalf("unassigned address resolved to %q, want nil", ifi.Name)
}
}
// TestMulticastJoinOnControlInterfaceReceivesData is the regression test for the
// bug that left the web UI permanently blank: the hub joined the group with a
// nil interface, so imr_interface stayed INADDR_ANY and the kernel picked the
// default-route interface. A UDPStreamer configured with Interface = "127.0.0.1"
// sends out the loopback instead, and every datagram was silently dropped.
//
// The sender here mimics that server exactly (IP_MULTICAST_IF = 127.0.0.1); the
// receiver joins the way runMulticastSession now does, via the interface that
// owns the control connection's local address.
func TestMulticastJoinOnControlInterfaceReceivesData(t *testing.T) {
const group = "239.255.13.37"
ifi := interfaceForIP(net.ParseIP("127.0.0.1"))
if ifi == nil {
t.Skip("no loopback interface available")
}
rx, err := net.ListenMulticastUDP("udp4", ifi, &net.UDPAddr{IP: net.ParseIP(group), Port: 0})
if err != nil {
t.Fatalf("join %s on %s: %v", group, ifi.Name, err)
}
defer rx.Close()
port := rx.LocalAddr().(*net.UDPAddr).Port
tx, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")})
if err != nil {
t.Fatalf("sender socket: %v", err)
}
defer tx.Close()
setMulticastIf(t, tx, [4]byte{127, 0, 0, 1})
payload := []byte("UDPS-multicast-probe")
dst := &net.UDPAddr{IP: net.ParseIP(group), Port: port}
// Datagrams are lossy even on loopback if the join has not settled, so send
// a few and accept the first that lands.
done := make(chan struct{})
defer close(done)
go func() {
for {
select {
case <-done:
return
default:
}
tx.WriteToUDP(payload, dst)
time.Sleep(20 * time.Millisecond)
}
}()
buf := make([]byte, 128)
if err := rx.SetReadDeadline(time.Now().Add(3 * time.Second)); err != nil {
t.Fatal(err)
}
n, _, err := rx.ReadFromUDP(buf)
if err != nil {
t.Fatalf("no multicast received on %s within 3s: %v", ifi.Name, err)
}
if got := string(buf[:n]); got != string(payload) {
t.Fatalf("payload = %q, want %q", got, payload)
}
}
+317 -9
View File
@@ -1,6 +1,10 @@
package wshub
import "sync"
import (
"log"
"math"
"sync"
)
// sigRing is a fixed-capacity circular buffer storing (time, value) pairs.
// Writes come from the Hub.Run() goroutine; reads come from HTTP handler goroutines.
@@ -10,30 +14,334 @@ type sigRing struct {
t, v []float64
cap int
head, size int // next write position; current fill
// bucket is how many source samples collapse into one min/max pair on the
// way in. 1 stores the stream verbatim. Raising it trades resolution for
// the timespan a fixed capacity covers, which is what lets a long display
// window fit in a fixed per-signal memory budget.
bucket int
// In-progress bucket. accN counts source samples seen since the last pair
// was emitted; the four acc fields are the extrema and when they occurred.
accN int
accTMin, accVMin float64
accTMax, accVMax float64
// Source-sample accounting, kept because size and the stored timespan no
// longer give the source rate once bucket > 1. Reset every
// srcRateWindowSec so a producer restart or a rate change is not averaged
// against the whole run.
srcCount int64
srcT0, srcT1 float64
haveSrc bool
}
// srcRateWindowSec bounds how long a source-rate measurement accumulates before
// starting over. Long enough to average out per-frame jitter, short enough that
// a rate change is reflected within a few seconds.
const srcRateWindowSec = 10.0
func newSigRing(capacity int) *sigRing {
return &sigRing{
t: make([]float64, capacity),
v: make([]float64, capacity),
cap: capacity,
t: make([]float64, capacity),
v: make([]float64, capacity),
cap: capacity,
bucket: 1,
}
}
// write appends (tArr[i], vArr[i]) pairs, overwriting oldest entries when full.
// With bucket > 1 each group of bucket samples contributes only its minimum and
// its maximum, in the order the two occurred.
func (rb *sigRing) write(tArr, vArr []float64) {
rb.mu.Lock()
defer rb.mu.Unlock()
if n := len(tArr); n > 0 {
if !rb.haveSrc || tArr[n-1]-rb.srcT0 > srcRateWindowSec || tArr[0] < rb.srcT0 {
rb.srcT0, rb.srcCount, rb.haveSrc = tArr[0], 0, true
}
rb.srcT1 = tArr[n-1]
rb.srcCount += int64(n)
}
if rb.bucket <= 1 {
for i := 0; i < len(tArr); i++ {
rb.pushLocked(tArr[i], vArr[i])
}
return
}
for i := 0; i < len(tArr); i++ {
rb.t[rb.head] = tArr[i]
rb.v[rb.head] = vArr[i]
rb.head = (rb.head + 1) % rb.cap
if rb.size < rb.cap {
rb.size++
t, v := tArr[i], vArr[i]
if rb.accN == 0 {
rb.accTMin, rb.accVMin, rb.accTMax, rb.accVMax = t, v, t, v
} else {
if v < rb.accVMin {
rb.accTMin, rb.accVMin = t, v
}
if v > rb.accVMax {
rb.accTMax, rb.accVMax = t, v
}
}
rb.accN++
if rb.accN >= rb.bucket {
rb.flushBucketLocked()
}
}
}
func (rb *sigRing) pushLocked(t, v float64) {
rb.t[rb.head] = t
rb.v[rb.head] = v
rb.head = (rb.head + 1) % rb.cap
if rb.size < rb.cap {
rb.size++
}
}
// flushBucketLocked emits the accumulated extrema oldest-first. Time order
// matters: every read binary-searches rb.t, so the stored timestamps must stay
// non-decreasing.
func (rb *sigRing) flushBucketLocked() {
if rb.accN == 0 {
return
}
if rb.accTMin <= rb.accTMax {
rb.pushLocked(rb.accTMin, rb.accVMin)
rb.pushLocked(rb.accTMax, rb.accVMax)
} else {
rb.pushLocked(rb.accTMax, rb.accVMax)
rb.pushLocked(rb.accTMin, rb.accVMin)
}
rb.accN = 0
}
// setBucket changes the min/max reduction applied to incoming samples and
// reports whether it changed. Samples already stored keep the resolution they
// were written at; the ring converges on the new one as it rolls.
func (rb *sigRing) setBucket(n int) bool {
if n < 1 {
n = 1
}
rb.mu.Lock()
defer rb.mu.Unlock()
if n == rb.bucket {
return false
}
// Emit what the old bucket had collected rather than dropping it.
rb.flushBucketLocked()
rb.bucket = n
return true
}
func (rb *sigRing) bucketSize() int {
rb.mu.RLock()
defer rb.mu.RUnlock()
return rb.bucket
}
// sourceRate is the measured rate of the incoming stream in samples per second,
// or 0 while there is too little to extrapolate from. Unlike stats() it counts
// source samples, so it is unaffected by bucketing.
func (rb *sigRing) sourceRate() float64 {
rb.mu.RLock()
defer rb.mu.RUnlock()
if rb.srcCount < 2 || rb.srcT1 <= rb.srcT0 {
return 0
}
return float64(rb.srcCount-1) / (rb.srcT1 - rb.srcT0)
}
// stats reports the current fill and the timespan it covers, so callers can
// estimate the stream's sample rate without copying the data out.
func (rb *sigRing) stats() (count int, span float64) {
rb.mu.RLock()
defer rb.mu.RUnlock()
if rb.size < 2 {
return rb.size, 0
}
start := 0
if rb.size == rb.cap {
start = rb.head
}
oldest := rb.t[start]
newest := rb.t[(start+rb.size-1)%rb.cap]
return rb.size, newest - oldest
}
func (rb *sigRing) capacity() int {
rb.mu.RLock()
defer rb.mu.RUnlock()
return rb.cap
}
// ─── Ring tuning ─────────────────────────────────────────────────────────────
// ringHeadroom oversizes a reduced ring's span. It absorbs rate jitter and
// keeps the tail of a trigger window in the buffer long enough for the capture
// to read it. It applies only once the window no longer fits verbatim: at the
// boundary, spending a whole extra bucket step to buy 25 % more span would cost
// half the resolution.
const ringHeadroom = 1.25
// ringTuneIntervalSec throttles the retune sweep. The source rate only settles
// once data flows, so the sweep repeats rather than running once.
const ringTuneIntervalSec = 1.0
// defaultLiveWindowSec is the window assumed when no client has said what it is
// displaying — the native clients never do, and a browser has not yet at the
// moment the first samples land.
const defaultLiveWindowSec = 10.0
// ringBucketFor is how many source samples must collapse into one min/max pair
// for `window` seconds at `rate` samples/s to fit in `capacity` points.
//
// rate*window <= capacity → 1, the buffer stays verbatim and reaches further
// back than the window, which is free zoom headroom
// rate*window > capacity → >1, so the whole window fits at reduced resolution
//
// A bucket costs two points (its minimum and its maximum), hence the factor 2.
func ringBucketFor(rate, window float64, capacity int) int {
if capacity <= 0 || rate <= 0 || window <= 0 {
return 1
}
need := rate * window
if need <= float64(capacity) {
return 1
}
return int(math.Ceil(2 * need * ringHeadroom / float64(capacity)))
}
// ringCoverage is how many source samples a ring of `capacity` points holds at
// the given bucket. A bucket of 2 stores both of its samples, so it covers no
// more ground than a bucket of 1.
func ringCoverage(bucket, capacity int) int {
if bucket <= 2 {
return capacity
}
return capacity / 2 * bucket
}
// activeWindowSec is the timespan the buffers must cover. An armed trigger owns
// it: its pre-window has to already be in the ring when the trigger fires or
// there is nothing to back-fill the capture from. Otherwise it is the widest
// window any connected client is displaying.
func (h *Hub) activeWindowSec() float64 {
if h.trigger != nil && h.trigger.Active() {
if cfg := h.trigger.Config(); cfg.windowSec > 0 {
return cfg.windowSec
}
}
widest := 0.0
for c := range h.clients {
if w := c.displayWindowSec(); w > widest {
widest = w
}
}
if widest <= 0 {
return defaultLiveWindowSec
}
return widest
}
// retuneRings keeps every ring matched to the window being displayed: grown
// towards the per-signal budget, and bucketed so the window fits inside it.
//
// A fixed sample-count ring covers a fraction of a second at a megasample rate,
// which is why long windows used to come back with only their tail populated —
// in live mode as much as under a trigger. Spending the budget on min/max pairs
// rather than on a bigger allocation is what makes an arbitrarily long window
// work within a fixed memory bound.
//
// Called from Hub.Run() only, so reading h.clients here needs no lock.
func (h *Hub) retuneRings(nowSec float64) {
if nowSec < h.ringTuneAt {
return
}
h.ringTuneAt = nowSec + ringTuneIntervalSec
window := h.activeWindowSec()
if window <= 0 {
return
}
// The archive answers for the same window as the rings — it is what a zoom
// or a capture falls back on once they have rolled past it — so it is sized
// from the same number.
if h.hist.setWindow(window) {
if msg := h.buildHistoryInfoMsg(); msg != nil {
h.broadcast(msg)
}
}
budget := h.ringBudget()
h.ringsMu.RLock()
keys := make([]string, 0, len(h.rings))
rings := make([]*sigRing, 0, len(h.rings))
for k, rb := range h.rings {
keys = append(keys, k)
rings = append(rings, rb)
}
h.ringsMu.RUnlock()
for i, rb := range rings {
rate := rb.sourceRate()
if rate <= 0 {
continue
}
// Claim the whole budget before deciding on a bucket: memory is what
// buys resolution, so it is spent first and reduced from only if the
// window still does not fit.
if rb.capacity() < budget && rate*window > float64(rb.capacity()) {
rb.grow(budget)
}
cur := rb.bucketSize()
need := rate * window
covered := float64(ringCoverage(cur, rb.capacity()))
// Hysteresis. Retuning up and retuning down must not share a threshold:
// a bucket step doubles or halves the span, so a rate jittering across
// the boundary would otherwise flip the resolution every second. Hold
// the current bucket while it covers the window without covering more
// than twice it.
if covered >= need && covered <= 2*need {
continue
}
want := ringBucketFor(rate, window, rb.capacity())
if !rb.setBucket(want) {
continue
}
if want > 1 {
log.Printf("hub: ring %s stores min/max over %d samples: %.0f s at %.0f kSps does not fit in %d points",
keys[i], want, window, rate/1e3, rb.capacity())
} else {
log.Printf("hub: ring %s back to full resolution: %.0f s at %.0f kSps fits in %d points",
keys[i], window, rate/1e3, rb.capacity())
}
}
}
// grow enlarges the buffer to newCap, keeping every sample it currently holds.
// Shrinking is refused: it would discard history a pending capture may need.
func (rb *sigRing) grow(newCap int) bool {
rb.mu.Lock()
defer rb.mu.Unlock()
if newCap <= rb.cap {
return false
}
nt := make([]float64, newCap)
nv := make([]float64, newCap)
start := 0
if rb.size == rb.cap {
start = rb.head
}
for i := 0; i < rb.size; i++ {
p := (start + i) % rb.cap
nt[i], nv[i] = rb.t[p], rb.v[p]
}
rb.t, rb.v = nt, nv
rb.cap = newCap
rb.head = rb.size // size < newCap, so no wrap
return true
}
// slice returns copies of all (t, v) pairs whose timestamp falls in [t0, t1].
// The returned slices are safe to use after the call without holding any lock.
func (rb *sigRing) slice(t0, t1 float64) ([]float64, []float64) {
+147
View File
@@ -0,0 +1,147 @@
package wshub
import (
"math"
"testing"
)
// dump returns the ring's contents oldest-first, which is what every reader
// sees through slice() but is easier to assert on directly.
func dump(rb *sigRing) ([]float64, []float64) {
return rb.slice(math.Inf(-1), math.Inf(1))
}
func TestRingBucketStoresMinMaxPairsInTimeOrder(t *testing.T) {
rb := newSigRing(100)
rb.setBucket(4)
// Two buckets. In the first the minimum comes before the maximum, in the
// second the order is reversed, so the emitted pairs must not be sorted by
// value — a ring whose timestamps are not monotonic breaks slice()'s
// binary search.
ts := []float64{0, 1, 2, 3, 4, 5, 6, 7}
vs := []float64{-5, 0, 0, 9, 9, 0, 0, -5}
rb.write(ts, vs)
gotT, gotV := dump(rb)
wantT := []float64{0, 3, 4, 7}
wantV := []float64{-5, 9, 9, -5}
if len(gotT) != len(wantT) {
t.Fatalf("stored %d points, want %d", len(gotT), len(wantT))
}
for i := range wantT {
if gotT[i] != wantT[i] || gotV[i] != wantV[i] {
t.Fatalf("point %d = (%v,%v), want (%v,%v)", i, gotT[i], gotV[i], wantT[i], wantV[i])
}
}
}
func TestRingBucketExtendsTheSpanAFixedCapacityCovers(t *testing.T) {
const cap = 200
// 2000 samples at 1 kHz is 2 s, ten times what the capacity holds verbatim.
ts := make([]float64, 2000)
vs := make([]float64, 2000)
for i := range ts {
ts[i] = float64(i) * 1e-3
vs[i] = math.Sin(float64(i))
}
full := newSigRing(cap)
full.write(ts, vs)
if _, span := full.stats(); span > 0.25 {
t.Fatalf("full-rate ring spans %.3f s, expected ~0.2 s", span)
}
// bucket 20 turns 20 samples into 2 points, so the same capacity reaches
// 10x further: 200/2*20 = 2000 samples = 2 s.
bucketed := newSigRing(cap)
bucketed.setBucket(20)
bucketed.write(ts, vs)
count, span := bucketed.stats()
if count != cap {
t.Fatalf("bucketed ring holds %d points, want the full %d", count, cap)
}
if span < 1.9 {
t.Fatalf("bucketed ring spans %.3f s, want the whole ~2 s", span)
}
}
func TestRingSourceRateIsUnaffectedByBucketing(t *testing.T) {
rb := newSigRing(1000)
rb.setBucket(50)
ts := make([]float64, 5000)
vs := make([]float64, 5000)
for i := range ts {
ts[i] = float64(i) * 1e-4 // 10 kHz
}
rb.write(ts, vs)
got := rb.sourceRate()
if math.Abs(got-10000) > 10 {
t.Fatalf("sourceRate = %.1f, want ~10000", got)
}
}
func TestSetBucketFlushesThePartialBucket(t *testing.T) {
rb := newSigRing(100)
rb.setBucket(10)
// Three samples: not enough to close a bucket of 10, so nothing is stored
// yet and they would be silently dropped by a re-bucket that just reset the
// accumulator.
rb.write([]float64{0, 1, 2}, []float64{7, -7, 0})
if n, _ := rb.stats(); n != 0 {
t.Fatalf("partial bucket already emitted %d points", n)
}
rb.setBucket(2)
gotT, gotV := dump(rb)
if len(gotT) != 2 || gotT[0] != 0 || gotV[0] != 7 || gotT[1] != 1 || gotV[1] != -7 {
t.Fatalf("flushed pair = %v/%v, want t=[0 1] v=[7 -7]", gotT, gotV)
}
}
func TestActiveWindowSecFallsBackToTheDefault(t *testing.T) {
h := NewHub()
if got := h.activeWindowSec(); got != defaultLiveWindowSec {
t.Fatalf("activeWindowSec with no clients = %v, want %v", got, defaultLiveWindowSec)
}
}
// Clients disagree about how far back they are plotting, and a buffer sized for
// the narrowest one leaves the others with nothing to zoom into.
func TestActiveWindowSecTakesTheWidestClientWindow(t *testing.T) {
h := NewHub()
narrow, wide, silent := &wsClient{}, &wsClient{}, &wsClient{}
narrow.setDisplayWindowSec(1)
wide.setDisplayWindowSec(120)
h.clients[narrow], h.clients[wide], h.clients[silent] = true, true, true
if got := h.activeWindowSec(); got != 120 {
t.Fatalf("activeWindowSec = %v, want the widest 120", got)
}
}
// An armed trigger owns the window: its pre-window has to be in the buffer
// before the trigger fires or the capture has nothing to back-fill from.
func TestActiveWindowSecPrefersTheArmedTrigger(t *testing.T) {
h := NewHub()
c := &wsClient{}
c.setDisplayWindowSec(1)
h.clients[c] = true
h.trigger.SetConfig(trigConfig{signalKey: "s1:sig", windowSec: 45, mode: "normal"})
if got := h.activeWindowSec(); got != 45 {
t.Fatalf("activeWindowSec = %v, want the trigger's 45", got)
}
}
func TestSetBucketToOneRestoresVerbatimStorage(t *testing.T) {
rb := newSigRing(100)
rb.setBucket(4)
rb.setBucket(1)
ts := []float64{0, 1, 2, 3}
vs := []float64{1, 2, 3, 4}
rb.write(ts, vs)
gotT, _ := dump(rb)
if len(gotT) != 4 {
t.Fatalf("stored %d points, want all 4", len(gotT))
}
}
+61 -2
View File
@@ -447,6 +447,52 @@ func (u *UDPClient) runSession() error {
}
}
// interfaceForIP returns the interface that owns the given local address, or
// nil if no interface matches (in which case callers fall back to letting the
// kernel choose).
func interfaceForIP(ip net.IP) *net.Interface {
if ip == nil || ip.IsUnspecified() {
return nil
}
ifaces, err := net.Interfaces()
if err != nil {
return nil
}
for i := range ifaces {
addrs, err := ifaces[i].Addrs()
if err != nil {
continue
}
for _, a := range addrs {
var aIP net.IP
switch v := a.(type) {
case *net.IPNet:
aIP = v.IP
case *net.IPAddr:
aIP = v.IP
}
if aIP != nil && aIP.Equal(ip) {
return &ifaces[i]
}
}
}
return nil
}
// interfaceForConn returns the interface a connection's local endpoint sits on.
func interfaceForConn(c net.Conn) *net.Interface {
if c == nil {
return nil
}
switch a := c.LocalAddr().(type) {
case *net.TCPAddr:
return interfaceForIP(a.IP)
case *net.UDPAddr:
return interfaceForIP(a.IP)
}
return nil
}
// runMulticastSession handles the multicast mode session.
func (u *UDPClient) runMulticastSession() error {
tcpAddr, err := net.ResolveTCPAddr("tcp4", u.serverAddr)
@@ -500,7 +546,15 @@ func (u *UDPClient) runMulticastSession() error {
return &net.AddrError{Err: "invalid multicast group IP", Addr: u.multicastGroup}
}
mcastAddr := &net.UDPAddr{IP: mcastIP, Port: mcastPort}
mcastConn, err := net.ListenMulticastUDP("udp4", nil, mcastAddr)
// Join on the interface that reaches the control connection. The UDPStreamer
// pins its multicast sends to its configured Interface (IP_MULTICAST_IF), so
// a join with a nil interface — which leaves imr_interface at INADDR_ANY and
// lets the kernel pick the default-route interface — silently receives
// nothing whenever that is not the sending interface. The local address of
// the control connection is the interface the server is reachable on, which
// is the sending interface in every single-homed and same-host deployment.
ifi := interfaceForConn(tcpConn)
mcastConn, err := net.ListenMulticastUDP("udp4", ifi, mcastAddr)
if err != nil {
return err
}
@@ -508,7 +562,12 @@ func (u *UDPClient) runMulticastSession() error {
if err := mcastConn.SetReadBuffer(udpRcvBufSize); err != nil {
log.Printf("[%s] multicast SetReadBuffer: %v", u.sourceID, err)
}
log.Printf("[%s] joined multicast %s:%s", u.sourceID, u.multicastGroup, strconv.Itoa(mcastPort))
ifName := "default"
if ifi != nil {
ifName = ifi.Name
}
log.Printf("[%s] joined multicast %s:%s on interface %s",
u.sourceID, u.multicastGroup, strconv.Itoa(mcastPort), ifName)
tcpDone := make(chan error, 1)
go func() {
+3 -2
View File
@@ -32,8 +32,9 @@ type SourceStat struct {
}
// RecordFragment is called for every UDP datagram of a DATA packet.
// complete: this fragment completed the DATA reassembly.
// nBytes: raw datagram size (header+payload).
//
// complete: this fragment completed the DATA reassembly.
// nBytes: raw datagram size (header+payload).
func (s *SourceStat) RecordFragment(counter uint32, nBytes int, arrivalNs int64, complete bool) {
s.mu.Lock()
defer s.mu.Unlock()
+355 -9
View File
@@ -3,7 +3,9 @@ package wshub
import (
"encoding/binary"
"encoding/json"
"log"
"math"
"sort"
"strconv"
"strings"
"sync"
@@ -25,10 +27,30 @@ const (
// capture is extracted, so the rings have received the last samples.
const captureMarginSec = 0.15
// captureStallSec is how long the stream may be silent before a collecting
// trigger gives up waiting for the rest of its window and delivers what it has.
const captureStallSec = 2.0
// autoRearmDelaySec is the pause between a completed capture and the automatic
// rearm in "normal" mode.
const autoRearmDelaySec = 0.2
// trigCapturePts caps the points sent per signal in a capture frame. A window
// of 60 s at 1 MSps is 60 M raw samples — ~960 MB per signal on the wire, which
// no client can take and which the send path would simply drop. Matches the C++
// StreamHub's kTrigCapturePts.
const trigCapturePts = 20000
// shortCaptureTol is the fraction of the window a capture may miss at its front
// before it is reported. One min/max bucket of slack, not a quality target.
const shortCaptureTol = 0.01
// maxTriggerWindowSec bounds the capture window, matching the longest option
// the web UI offers. It is not a resolution limit: retuneRings buckets the
// rings so any window fits the per-signal memory budget, at the cost of storing
// min/max pairs rather than every sample.
const maxTriggerWindowSec = 600.0
// trigConfig is the client-settable part of the trigger.
type trigConfig struct {
signalKey string // "src:sig" or "src:sig[i]"
@@ -36,7 +58,8 @@ type trigConfig struct {
threshold float64
windowSec float64
prePercent float64
mode string // "normal" | "single"
mode string // "normal" | "single"
holdoffSec float64 // rearm delay after a capture (double-trigger guard)
}
// triggerEngine implements the hub-side trigger FSM. Its methods are safe to
@@ -51,11 +74,34 @@ type triggerEngine struct {
state string
stopped bool
// sentState is the state carried by the last stateMsg handed out. The
// armed→collecting transition happens inside feed(), on the ingest path,
// so the hub cannot see it by sampling State() across a tick — by the time
// the tick runs, ingest has already moved the FSM.
sentState string
// sentFill is the pre-fill fraction carried by the last stateMsg, so a
// trigger that is armed but still filling can report progress.
sentFill float64
// How far back the trigger signal's ring reaches and how fast that is
// growing (seconds of span per second of wall clock), refreshed by the hub.
// bufKnown is false when there is no ring to measure, which disables the
// fill gate rather than blocking the trigger on a measurement that will
// never arrive; bufRateOK is false until two measurements exist.
bufSpan float64
bufGrowth float64
bufKnown bool
bufRateOK bool
// Reference point the growth is measured against.
bufRefSpan, bufRefWall float64
prevValue float64
prevValid bool
lastT float64
lastTOK bool
// lastFeedWall is the wall clock at the last feed(), used only to notice a
// stalled stream — the window itself is measured on the sample clock.
lastFeedWall float64
trigTime float64
firedPre float64
@@ -67,7 +113,7 @@ type triggerEngine struct {
func newTriggerEngine() *triggerEngine {
return &triggerEngine{
cfg: trigConfig{edge: "rising", windowSec: 1, prePercent: 20, mode: "normal"},
cfg: trigConfig{edge: "rising", windowSec: 1, prePercent: 20, mode: "normal", holdoffSec: autoRearmDelaySec},
elemIdx: -1,
state: trigIdle,
}
@@ -97,8 +143,8 @@ func (te *triggerEngine) SetConfig(cfg trigConfig) {
if cfg.windowSec < 1e-4 {
cfg.windowSec = 1e-4
}
if cfg.windowSec > 10 {
cfg.windowSec = 10
if cfg.windowSec > maxTriggerWindowSec {
cfg.windowSec = maxTriggerWindowSec
}
if cfg.prePercent < 0 {
cfg.prePercent = 0
@@ -106,8 +152,19 @@ func (te *triggerEngine) SetConfig(cfg trigConfig) {
if cfg.prePercent > 100 {
cfg.prePercent = 100
}
if cfg.holdoffSec < 0 {
cfg.holdoffSec = 0
}
if cfg.holdoffSec > 60 {
cfg.holdoffSec = 60
}
te.cfg = cfg
te.baseKey, te.elemIdx = parseSignalKey(cfg.signalKey)
base, idx := parseSignalKey(cfg.signalKey)
if base != te.baseKey {
// The buffer measurement belongs to the old signal's ring.
te.bufKnown, te.bufRateOK = false, false
}
te.baseKey, te.elemIdx = base, idx
te.prevValid = false
te.prevValue = 0
}
@@ -168,6 +225,105 @@ func (te *triggerEngine) Active() bool {
return te.baseKey != ""
}
// baseSignalKey is the configured trigger signal without its "[i]" suffix, or
// "" when no trigger signal is set.
func (te *triggerEngine) baseSignalKey() string {
te.mu.Lock()
defer te.mu.Unlock()
return te.baseKey
}
// bufGrowthIntervalSec is the shortest baseline the span growth is measured
// over. The hub refreshes 30 times a second and the span moves in steps as
// batches land, so a shorter baseline measures the batching, not the trend.
const bufGrowthIntervalSec = 0.5
// bufGrowthSmooth is the weight of a new growth measurement in the running
// estimate.
const bufGrowthSmooth = 0.5
// setBuffered records how far back the trigger signal's ring reaches, at wall
// clock now, and derives how fast that is growing. Pass known=false when there
// is no such ring.
func (te *triggerEngine) setBuffered(span float64, known bool, now float64) {
te.mu.Lock()
defer te.mu.Unlock()
if !known {
te.bufKnown, te.bufRateOK = false, false
return
}
if !te.bufKnown {
te.bufKnown = true
te.bufRefSpan, te.bufRefWall = span, now
}
te.bufSpan = span
dt := now - te.bufRefWall
if dt < bufGrowthIntervalSec {
return
}
g := (span - te.bufRefSpan) / dt
// A ring that is not full grows one second of span per second; one that is
// full grows by whatever its incoming samples free up. Neither can exceed 1,
// and a shrinking ring is simply not growing.
if g < 0 {
g = 0
} else if g > 1 {
g = 1
}
if te.bufRateOK {
g = te.bufGrowth + bufGrowthSmooth*(g-te.bufGrowth)
}
te.bufGrowth, te.bufRateOK = g, true
te.bufRefSpan, te.bufRefWall = span, now
}
// fillNeedLocked is how far back the buffer must reach before an edge may be
// accepted, so that the capture is still whole when it is harvested a
// post-window later.
//
// What has to hold at harvest time is that the buffer spans the whole window:
// its newest sample is then trigTime+post, so anything less has lost the front
// of the capture. The buffer keeps filling while the post-window is collected,
// though, so the shortfall it may start with is exactly what it will make up in
// that time — measured, not assumed:
//
// need = windowSec growth × postSec, floored at the pre-trigger window
//
// A ring that is still filling grows a second per second, which reduces this to
// the pre-trigger window: everything after the trigger is yet to be recorded
// anyway. A full one grows only as fast as its incoming samples free space —
// re-bucketing to a longer window replaces dense old samples with sparse new
// ones — and it is that case, growth well below 1, where firing on the
// pre-window alone delivers a capture whose front has been overwritten by the
// time it is read. In the steady state growth is 0 and need is the whole
// window, which a ring tuned for that window already exceeds, so nothing waits.
func (te *triggerEngine) fillNeedLocked() float64 {
pre := te.cfg.windowSec * te.cfg.prePercent / 100
growth := 0.0 // until measured, assume the buffer will not fill on its own
if te.bufRateOK {
growth = te.bufGrowth
}
need := te.cfg.windowSec - growth*(te.cfg.windowSec-pre)
if need < pre {
need = pre
}
return need
}
// fillLocked is how much of that requirement is met, as a fraction in [0, 1].
// It is 1 whenever the gate does not apply: nothing needed, or no ring to
// measure.
func (te *triggerEngine) fillLocked() float64 {
need := te.fillNeedLocked()
if need <= 0 || !te.bufKnown || te.bufSpan >= need*(1-shortCaptureTol) {
return 1
}
if te.bufSpan <= 0 {
return 0
}
return te.bufSpan / need
}
// latchWindowLocked freezes the pre/post split at fire time so later config
// edits do not change how the capture is rendered.
func (te *triggerEngine) latchWindowLocked(t float64) {
@@ -209,6 +365,7 @@ func (te *triggerEngine) feed(key string, nElem int, t, v []float64) {
}
te.lastT = t[len(t)-1]
te.lastTOK = true
te.lastFeedWall = float64(time.Now().UnixNano()) / 1e9
if te.state != trigArmed {
return
}
@@ -219,6 +376,17 @@ func (te *triggerEngine) feed(key string, nElem int, t, v []float64) {
}
step, start = nElem, te.elemIdx
}
// Hold off while the buffer does not reach back far enough. Firing now would
// deliver a capture whose front is simply missing — the ring never held it —
// which is what made the first shot after a window change come back short.
// Track the level meanwhile, so the first edge once the buffer is deep
// enough is still measured against the right previous sample.
if te.fillLocked() < 1 {
for i := start; i < len(v); i += step {
te.prevValue, te.prevValid = v[i], true
}
return
}
thr := te.cfg.threshold
for i := start; i < len(t); i += step {
if !te.prevValid {
@@ -247,13 +415,28 @@ func (te *triggerEngine) feed(key string, nElem int, t, v []float64) {
// dueCapture reports whether a collecting trigger's post-window has elapsed and
// returns the latched window.
//
// The window is measured on the sample clock, not the wall clock: trigTime is a
// sample timestamp, and a stream whose timestamps lag real time (a busy
// producer, a buffered link) would otherwise be cut short by exactly that lag —
// an 8 s lag turned a 60 s window into a 36 s capture. Waiting for the samples
// themselves also means the ring really holds the window by the time it is read.
func (te *triggerEngine) dueCapture(nowSec float64) (trigTime, pre, post float64, ok bool) {
te.mu.Lock()
defer te.mu.Unlock()
if te.state != trigCollecting || !te.firedValid {
return 0, 0, 0, false
}
if nowSec < te.trigTime+te.firedPost+captureMarginSec {
deadline := te.trigTime + te.firedPost + captureMarginSec
switch {
case te.lastTOK && te.lastT >= deadline:
// The samples have covered the window.
case !te.lastTOK && nowSec >= deadline:
// No sample ever seen, so trigTime came from the wall clock (Force).
case te.lastFeedWall > 0 && nowSec-te.lastFeedWall >= captureStallSec:
// The stream has dried up; deliver what was collected rather than
// leaving the client stuck in "collecting" forever.
default:
return 0, 0, 0, false
}
return te.trigTime, te.firedPre, te.firedPost, true
@@ -266,7 +449,7 @@ func (te *triggerEngine) markTriggered(nowSec float64) {
if te.state == trigCollecting {
te.state = trigTriggered
if te.cfg.mode != "single" && !te.stopped {
te.rearmAt = nowSec + autoRearmDelaySec
te.rearmAt = nowSec + te.cfg.holdoffSec
}
}
te.mu.Unlock()
@@ -283,17 +466,49 @@ func (te *triggerEngine) dueRearm(nowSec float64) bool {
return !te.stopped
}
// stateUnsent reports whether the FSM has moved since the last stateMsg was
// built, i.e. whether clients still have to be told.
func (te *triggerEngine) stateUnsent() bool {
te.mu.Lock()
defer te.mu.Unlock()
if te.state != te.sentState {
return true
}
// An armed trigger waiting for its buffer is otherwise indistinguishable
// from one that is ignoring edges, so the filling itself is news. Coarse
// steps only: this is checked 30 times a second.
if te.state == trigArmed {
f := te.fillLocked()
return math.Abs(f-te.sentFill) >= 0.02 || (f >= 1 && te.sentFill < 1)
}
return false
}
// stateMsg builds the JSON "triggerState" broadcast for the current FSM state.
func (te *triggerEngine) stateMsg() []byte {
te.mu.Lock()
te.sentState = te.state
te.sentFill = te.fillLocked()
m := map[string]any{
"type": "triggerState",
"state": te.state,
"mode": te.cfg.mode,
"stopped": te.stopped,
}
if te.state == trigArmed && te.sentFill < 1 {
// Armed but holding off: the buffer does not yet reach back far enough
// to deliver the window, so edges are being ignored on purpose.
m["bufferFill"] = te.sentFill
m["bufferNeedSec"] = te.fillNeedLocked()
}
if te.firedValid {
// The window latched at fire time. Clients draw the filling capture on
// this axis before the v2 frame arrives, and config edits between arm
// and fire would otherwise leave them inferring the wrong window from
// their own copy of the config.
m["trigTime"] = te.trigTime
m["preSec"] = te.firedPre
m["postSec"] = te.firedPost
}
te.mu.Unlock()
msg, _ := json.Marshal(m)
@@ -331,6 +546,9 @@ func (h *Hub) handleTriggerCommand(t string, env map[string]interface{}) bool {
if f, ok := env["prePercent"].(float64); ok {
cfg.prePercent = f
}
if f, ok := env["holdoffSec"].(float64); ok {
cfg.holdoffSec = f
}
h.trigger.SetConfig(cfg)
case "arm", "rearm":
h.trigger.Arm()
@@ -347,34 +565,139 @@ func (h *Hub) handleTriggerCommand(t string, env map[string]interface{}) bool {
default:
return false
}
// Measure the buffer now rather than waiting for the next tick: ingest runs
// on the source goroutine and a 1 MSps stream crosses the threshold many
// times within one 33 ms tick, so an arm serviced here would otherwise fire
// on a stale (or missing) measurement before the gate ever saw the new
// configuration.
h.refreshTriggerFill()
h.broadcastTriggerState()
return true
}
// refreshTriggerFill tells the FSM how far back the trigger signal's ring
// reaches, which is what lets an armed trigger hold off until a capture taken
// now would come back whole.
//
// The ring is the right yardstick even though a short capture is back-filled
// from the archive: the archive is sized for the same window and starts over
// whenever that window changes, so it holds no more of the stretch being waited
// for than the ring does. It can only add to what the capture finds.
//
// Called both from the push tick and from the client goroutine handling a
// trigger command; all the state it derives lives in the engine, behind the
// engine's lock.
func (h *Hub) refreshTriggerFill() {
if h.trigger == nil {
return
}
now := float64(time.Now().UnixNano()) / 1e9
var rb *sigRing
if key := h.trigger.baseSignalKey(); key != "" {
rb = h.getRing(key)
}
if rb == nil {
// Nothing to measure. Do not gate on a signal the hub does not carry:
// that would leave the trigger armed forever, which is worse than a
// short capture.
h.trigger.setBuffered(0, false, now)
return
}
_, span := rb.stats()
h.trigger.setBuffered(span, true, now)
}
// triggerTick services the trigger FSM; called from Hub.Run() on every push tick.
func (h *Hub) triggerTick() {
nowSec := float64(time.Now().UnixNano()) / 1e9
prev := h.trigger.State()
h.retuneRings(nowSec)
h.openPendingHistoryFiles(nowSec)
h.refreshTriggerFill()
if trigTime, pre, post, ok := h.trigger.dueCapture(nowSec); ok {
if msg := h.buildTriggerCapture(trigTime, pre, post); msg != nil {
dropped := 0
for c := range h.clients {
select {
case c.send <- wsMessage{websocket.BinaryMessage, msg}:
default:
dropped++
}
}
// A dropped capture is invisible to the user — the trigger fires,
// the state goes to "triggered" and no waveform ever arrives — so
// say so rather than leaving it to be guessed at.
if dropped > 0 {
log.Printf("wshub: trigger capture (%d B) dropped for %d client(s): send queue full",
len(msg), dropped)
}
}
h.trigger.markTriggered(nowSec)
// A capture is only zoomable for as long as its samples still exist at
// full resolution somewhere, and the rings roll past the window within
// seconds of it being taken. Lift the window out of the archive into a
// file of its own, where nothing overwrites it until the next trigger.
h.hist.captureRange(trigTime-pre, trigTime+post)
} else if h.trigger.dueRearm(nowSec) {
h.trigger.Arm()
}
if h.trigger.State() != prev {
if h.trigger.stateUnsent() {
h.broadcastTriggerState()
}
}
// backfillCaptureHead prepends the front of [t0, t1] that the ring no longer
// holds, read from the disk archive. It returns its input unchanged when the
// ring already reaches t0, when history is off, or when the archive has nothing
// for that range.
//
// The rings are sized for the window, but they only have to *become* that long:
// they are min/max buckets that cover the configured window once they have
// rolled over completely at the current bucket, which takes as long as the
// window itself. Widen the window and arm, and the first captures ask for more
// history than the ring has ever stored — the frame then starts late and the
// user sees a blank front half. The archive is written straight through, at the
// geometry its file was created with, so unless that file was re-sized too it
// has kept the stretch the ring is still converging on.
func (h *Hub) backfillCaptureHead(key string, t0, t1 float64, st, sv []float64) ([]float64, []float64) {
window := t1 - t0
if !h.hist.enabled() || window <= 0 {
return st, sv
}
gapEnd := t1
if len(st) > 0 {
gapEnd = st[0]
}
gap := gapEnd - t0
if gap <= shortCaptureTol*window {
return st, sv
}
// Budget the read by the share of the window being back-filled. The frame is
// decimated to trigCapturePts either way, so a bigger read would buy nothing
// but disk seeks — on the hub's own goroutine, between two push ticks.
maxOut := int(float64(trigCapturePts)*gap/window) + 2
ht, hv := h.hist.readRange(key, t0, gapEnd, maxOut)
if len(ht) == 0 {
return st, sv
}
// Drop anything at or past the ring's first sample: the two sources overlap
// around the join, and the frame's timestamps must stay ascending.
n := len(ht)
if len(st) > 0 {
n = sort.SearchFloat64s(ht, st[0])
}
if n == 0 {
return st, sv
}
outT := make([]float64, 0, n+len(st))
outV := make([]float64, 0, n+len(sv))
outT = append(append(outT, ht[:n]...), st...)
outV = append(append(outV, hv[:n]...), sv...)
return outT, outV
}
// buildTriggerCapture extracts [trigTime-pre, trigTime+post] from every ring
// buffer and encodes the version-2 binary capture frame:
//
@@ -397,18 +720,41 @@ func (h *Hub) buildTriggerCapture(trigTime, pre, post float64) []byte {
h.ringsMu.RUnlock()
slices := make([]sigSlice, 0, len(keys))
held := make(map[string]sigData, len(keys))
total := 1 + 8 + 8 + 8 + 4
for i, k := range keys {
st, sv := rings[i].slice(t0, t1)
st, sv = h.backfillCaptureHead(k, t0, t1, st, sv)
if len(st) == 0 {
continue
}
// Neither the ring nor the archive reached t0. Nothing can recover that
// data, so name it rather than leaving the user to wonder why the front
// of their window is blank.
if lost := st[0] - t0; lost > shortCaptureTol*(t1-t0) {
cnt, span := rings[i].stats()
log.Printf("wshub: capture %s is short by %.2f s of %.2f s: ring holds %.2f s (%d pts, min/max over %d)",
k, lost, t1-t0, span, cnt, rings[i].bucketSize())
}
// Take the second half of the double buffer here, before the frame is
// decimated: the client gets 20 000 points to draw, but a zoom into
// them has to come back with the underlying samples, and the rings will
// have rolled past them by the time it is asked for.
held[k] = sigData{T: st, V: sv}
// Decimate before framing: a long window at a high sample rate is
// hundreds of megabytes raw, which the send path would silently drop.
// The min/max envelope keeps every peak in the window, so a glitch is
// still on screen at the zoomed-out view that first shows it.
st, sv = minMaxDecimate(st, sv, trigCapturePts)
slices = append(slices, sigSlice{key: k, t: st, v: sv})
total += 2 + len(k) + 4 + len(st)*16
}
if len(slices) == 0 {
return nil
}
// Swap only now that the capture is known good. A shot that yielded nothing
// must leave the previous window on screen rather than blanking it.
h.capture.publish(t0, t1, held)
buf := make([]byte, total)
buf[0] = 2
@@ -0,0 +1,278 @@
package wshub
import (
"encoding/binary"
"math"
"testing"
)
// fillRing writes n samples at the given rate starting at t0.
func fillRing(rb *sigRing, t0 float64, rate float64, n int) {
ts := make([]float64, n)
vs := make([]float64, n)
for i := range ts {
ts[i] = t0 + float64(i)/rate
vs[i] = math.Sin(float64(i))
}
rb.write(ts, vs)
}
func TestRingGrowPreservesSamples(t *testing.T) {
rb := newSigRing(100)
// Overflow the ring so the retained window starts mid-buffer.
fillRing(rb, 0, 1000, 250)
beforeT, beforeV := rb.slice(-1e9, 1e9)
if len(beforeT) != 100 {
t.Fatalf("pre-grow fill = %d, want 100", len(beforeT))
}
if !rb.grow(1000) {
t.Fatal("grow(1000) returned false")
}
if rb.capacity() != 1000 {
t.Fatalf("capacity = %d, want 1000", rb.capacity())
}
afterT, afterV := rb.slice(-1e9, 1e9)
if len(afterT) != len(beforeT) {
t.Fatalf("post-grow fill = %d, want %d", len(afterT), len(beforeT))
}
for i := range beforeT {
if afterT[i] != beforeT[i] || afterV[i] != beforeV[i] {
t.Fatalf("sample %d changed across grow", i)
}
}
// Further writes must keep landing in order rather than wrapping early.
fillRing(rb, 1.0, 1000, 500)
if n, _ := rb.stats(); n != 600 {
t.Fatalf("fill after grow = %d, want 600", n)
}
// Shrinking is refused.
if rb.grow(10) {
t.Fatal("grow(10) shrank the ring")
}
}
func TestRingStatsMeasuresRate(t *testing.T) {
rb := newSigRing(10000)
fillRing(rb, 0, 1000, 1000) // 1 kHz
n, span := rb.stats()
if n != 1000 {
t.Fatalf("count = %d, want 1000", n)
}
rate := float64(n) / span
if math.Abs(rate-1001) > 5 { // n samples span (n-1) intervals
t.Fatalf("rate = %v, want ~1000", rate)
}
}
// A long trigger window must grow the rings to hold it: a fixed sample-count
// ring covers a fraction of a second at a high rate, which is what made 60 s
// captures come back with only their tail populated.
func TestRetuneRingsCoversTriggerWindow(t *testing.T) {
h := NewHub()
rb := newSigRing(6000) // 6 s at 1 kHz — far short of a 60 s window
fillRing(rb, 0, 1000, 6000)
h.rings["s1:sig"] = rb
h.trigger.SetConfig(trigConfig{signalKey: "s1:sig", edge: "rising",
windowSec: 60, prePercent: 20, mode: "normal"})
h.retuneRings(1000)
// 60 s at 1 kHz is 60 k samples: growing to the budget holds them verbatim.
if got := rb.capacity(); got < 60000 {
t.Fatalf("capacity = %d, want >= 60000 to hold a 60 s window", got)
}
if got := rb.bucketSize(); got != 1 {
t.Fatalf("bucket = %d, want 1: the window fits at full rate", got)
}
}
// Past the budget the window is kept by reducing resolution, not by dropping
// its head — the whole point of the min/max buckets.
func TestRetuneRingsBucketsWhenTheWindowExceedsTheBudget(t *testing.T) {
h := NewHub()
rb := newSigRing(1000)
fillRing(rb, 0, 1e6, 100_000) // 1 MSps
h.rings["s1:sig"] = rb
h.trigger.SetConfig(trigConfig{signalKey: "s1:sig", windowSec: 60, mode: "normal"})
h.retuneRings(1000)
if got := rb.capacity(); got != defaultRingPts {
t.Fatalf("capacity = %d, want the budget %d", got, defaultRingPts)
}
// 60 s at 1 MSps is 60 M samples in a 10 M-point buffer, so each stored
// pair must cover at least 12 source samples.
bucket := rb.bucketSize()
if bucket < 12 {
t.Fatalf("bucket = %d, too fine to fit 60 M samples in %d points", bucket, rb.capacity())
}
if covered := float64(rb.capacity()) / 2 * float64(bucket) / 1e6; covered < 60 {
t.Fatalf("buffer covers %.1f s, want the whole 60 s window", covered)
}
}
// A raised budget buys resolution back: the same window is held verbatim.
func TestRetuneRingsHonoursRaisedBudget(t *testing.T) {
h := NewHub()
h.SetRingBudget(80_000_000)
rb := newSigRing(1000)
fillRing(rb, 0, 1e6, 100_000) // 1 MSps
h.rings["s1:sig"] = rb
h.trigger.SetConfig(trigConfig{signalKey: "s1:sig", windowSec: 60, mode: "normal"})
h.retuneRings(1000)
if got := rb.bucketSize(); got != 1 {
t.Fatalf("bucket = %d, want 1: 60 M samples fit in an 80 M-point buffer", got)
}
}
func TestSetRingBudgetBounds(t *testing.T) {
h := NewHub()
h.SetRingBudget(0)
if got := h.ringBudget(); got != defaultRingPts {
t.Fatalf("ringBudget after 0 = %d, want the default %d", got, defaultRingPts)
}
// Never below the depth a freshly configured ring already has, or the
// budget would ask for a shrink the ring refuses anyway.
h.SetRingBudget(10)
if got := h.ringBudget(); got != ringCapInitial {
t.Fatalf("ringBudget after 10 = %d, want the floor %d", got, ringCapInitial)
}
}
func TestRetuneRingsIsThrottled(t *testing.T) {
h := NewHub()
h.SetRingBudget(250_000)
rb := newSigRing(250_000)
fillRing(rb, 0, 1e6, 100_000)
h.rings["s1:sig"] = rb
h.trigger.SetConfig(trigConfig{signalKey: "s1:sig", windowSec: 10, mode: "normal"})
h.retuneRings(100)
first := rb.bucketSize()
if first <= 1 {
t.Fatalf("bucket = %d, expected a reduction for 10 s at 1 MSps in 250 k points", first)
}
// Same second: the sweep must not run again even though a bigger window
// is now configured.
h.trigger.SetConfig(trigConfig{signalKey: "s1:sig", windowSec: 600, mode: "normal"})
h.retuneRings(100.5)
if rb.bucketSize() != first {
t.Fatalf("sweep ran inside the throttle window")
}
h.retuneRings(200)
if rb.bucketSize() <= first {
t.Fatalf("sweep did not run after the throttle window elapsed")
}
}
// With no trigger armed and no client saying otherwise, the rings are sized for
// the default live window — live mode needs the buffers just as much as a
// capture does.
func TestRetuneRingsSizesForTheLiveWindow(t *testing.T) {
h := NewHub()
rb := newSigRing(1000)
fillRing(rb, 0, 1e6, 100_000) // 1 MSps: 10 s does not fit in 1000 points
h.rings["s1:sig"] = rb
// No signal configured → trigger inactive, so the live window governs.
h.trigger.SetConfig(trigConfig{windowSec: 600, mode: "normal"})
h.retuneRings(100)
if got := rb.capacity(); got != defaultRingPts {
t.Fatalf("capacity = %d, want the budget %d", got, defaultRingPts)
}
// defaultLiveWindowSec at 1 MSps is exactly the budget, so no reduction.
if got := rb.bucketSize(); got != 1 {
t.Fatalf("bucket = %d, want 1 for the default live window", got)
}
}
func TestRingBucketForCoversTheWindow(t *testing.T) {
cases := []struct {
rate, window float64
capacity int
want int
}{
{1000, 10, 1_000_000, 1}, // 10 k samples in 1 M points: verbatim
{1e6, 10, 10_000_000, 1}, // exactly the budget: still verbatim
{1e6, 60, 10_000_000, 15}, // 60 M samples, 1.25x headroom
{1e6, 600, 10_000_000, 150}, // 600 s still fits, at 1/150 resolution
{0, 10, 1_000_000, 1}, // no rate measured yet
{1000, 0, 1_000_000, 1}, // no window
}
for _, c := range cases {
if got := ringBucketFor(c.rate, c.window, c.capacity); got != c.want {
t.Errorf("ringBucketFor(%v, %v, %d) = %d, want %d",
c.rate, c.window, c.capacity, got, c.want)
}
}
}
// decodeCapture pulls the per-signal point counts out of a v2 capture frame.
func decodeCapture(t *testing.T, buf []byte) map[string]int {
t.Helper()
if buf[0] != 2 {
t.Fatalf("frame version = %d, want 2", buf[0])
}
off := 1 + 8 + 8 + 8
nSig := int(binary.LittleEndian.Uint32(buf[off:]))
off += 4
out := make(map[string]int, nSig)
for i := 0; i < nSig; i++ {
kl := int(binary.LittleEndian.Uint16(buf[off:]))
off += 2
key := string(buf[off : off+kl])
off += kl
n := int(binary.LittleEndian.Uint32(buf[off:]))
off += 4
off += n * 16
out[key] = n
}
if off != len(buf) {
t.Fatalf("decoded %d of %d bytes", off, len(buf))
}
return out
}
// A 60 s window at a high rate is hundreds of megabytes raw; the capture frame
// must be decimated so it can actually reach a client.
func TestBuildTriggerCaptureDecimates(t *testing.T) {
h := NewHub()
rb := newSigRing(200000)
fillRing(rb, 0, 100000, 200000) // 2 s at 100 kSps
h.rings["s1:sig"] = rb
buf := h.buildTriggerCapture(1.0, 1.0, 1.0)
if buf == nil {
t.Fatal("no capture frame built")
}
counts := decodeCapture(t, buf)
n := counts["s1:sig"]
if n != trigCapturePts {
t.Fatalf("captured %d points, want the %d-point cap", n, trigCapturePts)
}
}
// Short captures must stay full resolution — decimation only kicks in above
// the cap.
func TestBuildTriggerCaptureKeepsSmallWindowsIntact(t *testing.T) {
h := NewHub()
rb := newSigRing(10000)
fillRing(rb, 0, 1000, 10000) // 10 s at 1 kHz
h.rings["s1:sig"] = rb
buf := h.buildTriggerCapture(1.0, 0.5, 0.5)
if buf == nil {
t.Fatal("no capture frame built")
}
counts := decodeCapture(t, buf)
if n := counts["s1:sig"]; n < 990 || n > 1010 {
t.Fatalf("captured %d points, want ~1000 undecimated", n)
}
}
+324 -11
View File
@@ -1,6 +1,11 @@
package wshub
import "testing"
import (
"encoding/json"
"math"
"testing"
"time"
)
func TestParseSignalKey(t *testing.T) {
cases := []struct {
@@ -26,7 +31,7 @@ func TestParseSignalKey(t *testing.T) {
func armed(key, edge string, thr float64) *triggerEngine {
te := newTriggerEngine()
te.SetConfig(trigConfig{signalKey: key, edge: edge, threshold: thr,
windowSec: 1, prePercent: 20, mode: "normal"})
windowSec: 1, prePercent: 20, mode: "normal", holdoffSec: autoRearmDelaySec})
te.Arm()
return te
}
@@ -96,6 +101,8 @@ func TestForceUsesLastSampleTime(t *testing.T) {
t.Fatalf("state = %q, want armed (threshold unreachable)", te.State())
}
te.Force()
// post = 1 s, so the capture waits for samples past t = 12 + 1 + 0.15.
te.feed("src:sig", 1, []float64{13.2}, []float64{0})
trigTime, pre, post, ok := te.dueCapture(1e9)
if !ok || trigTime != 12 || pre != 1 || post != 1 {
t.Fatalf("dueCapture = (%v,%v,%v,%v), want (12,1,1,true)",
@@ -116,15 +123,50 @@ func TestForceFromIdle(t *testing.T) {
func TestCaptureMarginDelaysExtraction(t *testing.T) {
te := armed("src:sig", "rising", 0.5)
te.feed("src:sig", 1, []float64{0, 1}, []float64{0, 1}) // fires at t=1
// post = 0.8 s; capture is due at 1 + 0.8 + 0.15.
if _, _, _, ok := te.dueCapture(1.9); ok {
// post = 0.8 s; capture is due once the samples reach 1 + 0.8 + 0.15.
te.feed("src:sig", 1, []float64{1.9}, []float64{0})
if _, _, _, ok := te.dueCapture(1e9); ok {
t.Error("capture extracted before the margin elapsed")
}
if _, _, _, ok := te.dueCapture(1.96); !ok {
te.feed("src:sig", 1, []float64{1.96}, []float64{0})
if _, _, _, ok := te.dueCapture(1e9); !ok {
t.Error("capture not extracted after the margin elapsed")
}
}
// A stream whose timestamps run behind real time must still yield the whole
// window: measuring the post-window on the wall clock cut the capture short by
// exactly the lag (an 8 s lag turned a 60 s window into a 36 s one).
func TestCaptureWaitsForLaggingStream(t *testing.T) {
te := armed("src:sig", "rising", 0.5)
te.feed("src:sig", 1, []float64{0, 1}, []float64{0, 1}) // fires at t=1
wallNow := float64(time.Now().UnixNano()) / 1e9
// Wall clock is far past the post-window, but the samples are not.
te.feed("src:sig", 1, []float64{1.5}, []float64{0})
if _, _, _, ok := te.dueCapture(wallNow); ok {
t.Error("capture extracted while the stream was still short of the window")
}
te.feed("src:sig", 1, []float64{2.0}, []float64{0})
if _, _, _, ok := te.dueCapture(wallNow); !ok {
t.Error("capture not extracted once the samples covered the window")
}
}
// A dead stream must not leave the client stuck in "collecting" forever.
func TestCaptureCompletesWhenStreamStalls(t *testing.T) {
te := armed("src:sig", "rising", 0.5)
te.feed("src:sig", 1, []float64{0, 1}, []float64{0, 1}) // fires at t=1
wallNow := float64(time.Now().UnixNano()) / 1e9
if _, _, _, ok := te.dueCapture(wallNow + captureStallSec/2); ok {
t.Error("capture extracted before the stall timeout")
}
if _, _, _, ok := te.dueCapture(wallNow + captureStallSec + 0.1); !ok {
t.Error("capture not extracted after the stream stalled")
}
}
func TestAutoRearmNormalMode(t *testing.T) {
te := armed("src:sig", "rising", 0.5)
te.feed("src:sig", 1, []float64{0, 1}, []float64{0, 1})
@@ -167,13 +209,34 @@ func TestStoppedSuppressesRearm(t *testing.T) {
func TestSetConfigClamps(t *testing.T) {
te := newTriggerEngine()
te.SetConfig(trigConfig{signalKey: "s:x", windowSec: 100, prePercent: 500})
if cfg := te.Config(); cfg.windowSec != 10 || cfg.prePercent != 100 {
t.Errorf("upper clamp = %v/%v, want 10/100", cfg.windowSec, cfg.prePercent)
te.SetConfig(trigConfig{signalKey: "s:x", windowSec: 1000, prePercent: 500, holdoffSec: 120})
if cfg := te.Config(); cfg.windowSec != 600 || cfg.prePercent != 100 || cfg.holdoffSec != 60 {
t.Errorf("upper clamp = %v/%v/%v, want 600/100/60", cfg.windowSec, cfg.prePercent, cfg.holdoffSec)
}
te.SetConfig(trigConfig{signalKey: "s:x", windowSec: 0, prePercent: -5})
if cfg := te.Config(); cfg.windowSec != 1e-4 || cfg.prePercent != 0 {
t.Errorf("lower clamp = %v/%v, want 1e-4/0", cfg.windowSec, cfg.prePercent)
// The web UI's longest option must survive intact — it used to be clamped
// to 60 s, so a 10 min capture silently came back one minute long.
te.SetConfig(trigConfig{signalKey: "s:x", windowSec: 600, prePercent: 20, holdoffSec: 1})
if cfg := te.Config(); cfg.windowSec != 600 {
t.Errorf("windowSec = %v, want the requested 600", cfg.windowSec)
}
te.SetConfig(trigConfig{signalKey: "s:x", windowSec: 0, prePercent: -5, holdoffSec: -1})
if cfg := te.Config(); cfg.windowSec != 1e-4 || cfg.prePercent != 0 || cfg.holdoffSec != 0 {
t.Errorf("lower clamp = %v/%v/%v, want 1e-4/0/0", cfg.windowSec, cfg.prePercent, cfg.holdoffSec)
}
}
func TestHoldoffControlsRearmDelay(t *testing.T) {
te := newTriggerEngine()
te.SetConfig(trigConfig{signalKey: "src:sig", edge: "rising", threshold: 0.5,
windowSec: 1, prePercent: 20, mode: "normal", holdoffSec: 5})
te.Arm()
te.feed("src:sig", 1, []float64{0, 1}, []float64{0, 1})
te.markTriggered(100)
if te.dueRearm(104.9) {
t.Error("rearmed before the configured holdoff elapsed")
}
if !te.dueRearm(105.1) {
t.Error("did not rearm after the configured holdoff elapsed")
}
}
@@ -192,3 +255,253 @@ func TestActiveTracksConfiguredSignal(t *testing.T) {
t.Error("engine must stay active after disarm while a signal is set")
}
}
// The armed→collecting transition happens inside feed(), on the ingest path,
// which the hub runs before triggerTick in the same loop iteration. Clients need
// that state — it carries trigTime and the latched window, without which they
// cannot draw the window filling and sit frozen until the capture arrives.
func TestCollectingIsBroadcast(t *testing.T) {
h := NewHub()
h.trigger.SetConfig(trigConfig{signalKey: "s1:sig", edge: "rising", threshold: 0,
windowSec: 10, prePercent: 20, mode: "single"})
h.trigger.Arm()
h.triggerTick()
drainStates(t, h)
// Fire, but stay well inside the post-trigger window: the capture is still
// seconds away and this is exactly when the client has nothing to draw.
h.ingest("s1:sig", 1, []float64{5.0, 5.001}, []float64{-1, 1})
h.triggerTick()
states := drainStates(t, h)
found := false
for _, m := range states {
if m["state"] == trigCollecting {
found = true
if m["trigTime"] != 5.001 {
t.Errorf("collecting broadcast has trigTime %v, want 5.001", m["trigTime"])
}
if m["preSec"] != 2.0 || m["postSec"] != 8.0 {
t.Errorf("collecting broadcast has pre=%v post=%v, want 2 and 8",
m["preSec"], m["postSec"])
}
}
}
if !found {
t.Fatalf("no collecting broadcast after the trigger fired, got %v", states)
}
}
// setFill hands the engine a buffer span and a growth rate, as the hub's
// per-tick measurements would: a reference point and a second one a second
// later. It forgets any earlier measurement first, so the rate is the one
// asked for rather than a blend with it.
func setFill(te *triggerEngine, span, growth, now float64) {
te.setBuffered(0, false, now)
te.setBuffered(span-growth, true, now)
te.setBuffered(span, true, now+1)
}
// What has to hold is that the buffer spans the whole window by the time the
// capture is read, one post-window after the trigger fires — so whatever it
// will fill in on its own during that time need not be there yet.
func TestFillNeed(t *testing.T) {
cases := []struct {
window, prePercent, growth, want float64
}{
{100, 20, 1, 20}, // still filling: only the pre-window has to exist
{100, 20, 0.5, 60}, // half speed: 40 s of the 80 s post-window fills in
{100, 20, 0, 100}, // not growing at all: it must already be all there
{100, 0, 0.9, 10}, // no pre-window, but the buffer still has to keep up
{100, 100, 1, 100}, // all pre-window: nothing fills in after the trigger
}
for _, c := range cases {
te := newTriggerEngine()
te.SetConfig(trigConfig{signalKey: "src:sig", windowSec: c.window, prePercent: c.prePercent})
setFill(te, 1e6, c.growth, 100) // span large enough not to matter
te.mu.Lock()
got := te.fillNeedLocked()
te.mu.Unlock()
if math.Abs(got-c.want) > 1e-6 {
t.Errorf("fillNeed(window %v, pre %v%%, growth %v) = %v, want %v",
c.window, c.prePercent, c.growth, got, c.want)
}
}
}
// A trigger that fires before its pre-window has been buffered can only produce
// a capture whose front half never existed. It must wait instead.
func TestFillGateHoldsFire(t *testing.T) {
te := armed("src:sig", "rising", 0.5) // window 1 s, pre 20 % → 0.2 s needed
setFill(te, 0.05, 1, 100)
te.feed("src:sig", 1, []float64{1, 2}, []float64{0, 1})
if te.State() != trigArmed {
t.Fatalf("state = %q, want armed: only 0.05 s of the 0.2 s pre-window is buffered", te.State())
}
// The level was still tracked, so the next crossing is a real edge and not a
// re-detection of the one that was held off.
setFill(te, 0.25, 1, 200)
te.feed("src:sig", 1, []float64{3, 4}, []float64{1, 1})
if te.State() != trigArmed {
t.Fatalf("state = %q, want armed: no crossing, the signal stayed high", te.State())
}
te.feed("src:sig", 1, []float64{5, 6}, []float64{0, 1})
if te.State() != trigCollecting {
t.Fatalf("state = %q, want collecting once the pre-window is buffered", te.State())
}
te.feed("src:sig", 1, []float64{7, 8}, []float64{1, 1}) // carry the sample clock past the window
if trigTime, _, _, ok := te.dueCapture(1e9); !ok || trigTime != 6 {
t.Errorf("dueCapture = (%v,%v), want trigTime 6", trigTime, ok)
}
}
// A ring that is full and re-bucketing for a longer window fills slower than
// real time — it drops dense old samples to take sparse new ones — so more of
// the window has to be there before an edge may be accepted.
func TestFillGateAccountsForSlowGrowth(t *testing.T) {
te := armed("src:sig", "rising", 0.5) // window 1 s, pre 20 % → post 0.8 s
// At half speed only 0.4 s of the post-window fills in, so 0.6 s is needed.
setFill(te, 0.5, 0.5, 100)
te.feed("src:sig", 1, []float64{1, 2}, []float64{0, 1})
if te.State() != trigArmed {
t.Fatalf("state = %q, want armed: 0.5 s buffered of the 0.6 s needed", te.State())
}
// The same 0.5 s in a ring still filling at full speed is plenty: everything
// after the trigger is yet to be recorded anyway.
te2 := armed("src:sig", "rising", 0.5)
setFill(te2, 0.5, 1, 100)
te2.feed("src:sig", 1, []float64{1, 2}, []float64{0, 1})
if te2.State() != trigCollecting {
t.Fatalf("state = %q, want collecting: the buffer keeps up with the stream", te2.State())
}
setFill(te, 0.65, 0.5, 200)
te.feed("src:sig", 1, []float64{3, 4}, []float64{0, 1})
if te.State() != trigCollecting {
t.Fatalf("state = %q, want collecting once the buffer will span the window", te.State())
}
}
func TestFillGateInactiveWithoutMeasurement(t *testing.T) {
// No ring for the configured signal: gating would leave the trigger armed
// forever, which is worse than a short capture.
te := armed("src:sig", "rising", 0.5)
te.feed("src:sig", 1, []float64{1, 2}, []float64{0, 1})
if te.State() != trigCollecting {
t.Fatalf("state = %q, want collecting: nothing measured, so nothing to gate on", te.State())
}
// Nor is there anything to wait for when the buffer keeps up and the whole
// window is still to come.
te = newTriggerEngine()
te.SetConfig(trigConfig{signalKey: "src:sig", edge: "rising", threshold: 0.5,
windowSec: 1, prePercent: 0, mode: "normal"})
te.Arm()
setFill(te, 0, 1, 100)
te.feed("src:sig", 1, []float64{1, 2}, []float64{0, 1})
if te.State() != trigCollecting {
t.Fatalf("state = %q, want collecting with a 0 %% pre-window", te.State())
}
}
// Force is the user overriding the trigger, so it overrides the gate too.
func TestForceIgnoresFillGate(t *testing.T) {
te := armed("src:sig", "rising", 0.5)
setFill(te, 0, 0, 100)
te.Force()
if te.State() != trigCollecting {
t.Fatalf("state = %q, want collecting", te.State())
}
}
// seedFillNow is setFill against the real clock, for tests that then let the
// hub take its own measurements: its ticks land inside the growth measurement
// interval, so they refresh the span and leave the seeded rate alone.
func seedFillNow(te *triggerEngine, span, growth float64) {
now := float64(time.Now().UnixNano()) / 1e9
te.setBuffered(0, false, now-1)
te.setBuffered(span-growth, true, now-1)
te.setBuffered(span, true, now)
}
// While it holds off, the trigger looks identical to one that is ignoring
// edges. The state broadcast has to say it is filling, and keep saying so.
func TestFillProgressIsBroadcast(t *testing.T) {
h := NewHub()
rb := newSigRing(1000)
h.rings["s1:sig"] = rb
h.trigger.SetConfig(trigConfig{signalKey: "s1:sig", edge: "rising", threshold: 0,
windowSec: 10, prePercent: 50, mode: "single"}) // 5 s of pre-window
h.trigger.Arm()
rb.write([]float64{0, 1}, []float64{-1, -1})
// Filling at the rate of the stream, so only the pre-window is needed.
seedFillNow(h.trigger, 1, 1)
h.triggerTick()
states := drainStates(t, h)
if len(states) == 0 {
t.Fatal("no state broadcast while the trigger was filling")
}
last := states[len(states)-1]
if last["state"] != trigArmed {
t.Fatalf("state = %v, want armed", last["state"])
}
if f, _ := last["bufferFill"].(float64); f < 0.19 || f > 0.21 {
t.Errorf("bufferFill = %v, want ~0.2 (1 s of 5 s)", last["bufferFill"])
}
if last["bufferNeedSec"] != 5.0 {
t.Errorf("bufferNeedSec = %v, want 5", last["bufferNeedSec"])
}
// An edge now is ignored: there is no 5 s of history to capture.
h.ingest("s1:sig", 1, []float64{1.5, 2.0}, []float64{-1, 1})
if h.trigger.State() != trigArmed {
t.Fatalf("state = %q, want armed: the pre-window is only 20 %% buffered", h.trigger.State())
}
// Progress is news even though the state has not moved.
rb.write([]float64{2, 3}, []float64{-1, -1})
h.triggerTick()
if states = drainStates(t, h); len(states) == 0 {
t.Fatal("no state broadcast as the pre-window filled further")
}
if f, _ := states[len(states)-1]["bufferFill"].(float64); f < 0.59 || f > 0.61 {
t.Errorf("bufferFill = %v, want ~0.6 (3 s of 5 s)", states[len(states)-1]["bufferFill"])
}
// Full: the gate opens, the fill disappears from the message and the next
// edge fires.
rb.write([]float64{4, 5.2}, []float64{-1, -1})
h.triggerTick()
states = drainStates(t, h)
if len(states) == 0 {
t.Fatal("no state broadcast when the pre-window filled")
}
if _, ok := states[len(states)-1]["bufferFill"]; ok {
t.Errorf("bufferFill still reported once the pre-window is buffered: %v", states[len(states)-1])
}
h.ingest("s1:sig", 1, []float64{5.3, 5.4}, []float64{-1, 1})
if h.trigger.State() != trigCollecting {
t.Fatalf("state = %q, want collecting once the pre-window is buffered", h.trigger.State())
}
}
// drainStates decodes every triggerState frame the hub has queued for
// broadcast. Hub.Run is what normally drains this queue, and it is not running
// in these tests.
func drainStates(t *testing.T, h *Hub) []map[string]any {
t.Helper()
var out []map[string]any
for {
select {
case msg := <-h.broadcastCh:
var m map[string]any
if err := json.Unmarshal(msg, &m); err != nil {
continue
}
if m["type"] == "triggerState" {
out = append(out, m)
}
default:
return out
}
}
}
+60 -1
View File
@@ -1,6 +1,65 @@
package wshub
import "testing"
import (
"math"
"testing"
)
// A scope's envelope must not lose a spike, however narrow, and must stay in
// time order so it can be plotted as a single trace.
func TestMinMaxDecimateKeepsExtremes(t *testing.T) {
const n = 10000
ts := make([]float64, n)
vs := make([]float64, n)
for i := range ts {
ts[i] = float64(i) * 1e-6
vs[i] = math.Sin(float64(i) * 0.01)
}
// A one-sample spike in each direction: exactly what plain decimation drops.
vs[4321] = 12.5
vs[6789] = -9.75
dt, dv := minMaxDecimate(ts, vs, 200)
if len(dt) > 200 || len(dt) != len(dv) {
t.Fatalf("got %d t / %d v points, want <= 200 of each", len(dt), len(dv))
}
hiSeen, loSeen := false, false
for i := range dv {
switch dv[i] {
case 12.5:
hiSeen = true
if dt[i] != ts[4321] {
t.Errorf("spike kept at t=%v, want %v: timestamps must be the real ones", dt[i], ts[4321])
}
case -9.75:
loSeen = true
}
if i > 0 && dt[i] < dt[i-1] {
t.Fatalf("output is not time-ordered at %d: %v after %v", i, dt[i], dt[i-1])
}
}
if !hiSeen || !loSeen {
t.Errorf("envelope lost a spike (max kept=%v, min kept=%v)", hiSeen, loSeen)
}
}
func TestMinMaxDecimatePassesShortInputThrough(t *testing.T) {
ts := []float64{1, 2, 3}
vs := []float64{4, 5, 6}
dt, dv := minMaxDecimate(ts, vs, 200)
if len(dt) != 3 || dv[2] != 6 {
t.Errorf("input below the budget was altered: %v / %v", dt, dv)
}
// A flat bucket contributes one point, not two: nothing is invented.
flatT := make([]float64, 100)
flatV := make([]float64, 100)
for i := range flatT {
flatT[i] = float64(i)
}
if ft, _ := minMaxDecimate(flatT, flatV, 10); len(ft) != 5 {
t.Errorf("flat input decimated to %d points, want 5 (one per bucket)", len(ft))
}
}
func TestZoomPoints(t *testing.T) {
cases := []struct {