Implemented and fixed many issues
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user