Implemented qt port + e2e
This commit is contained in:
Binary file not shown.
+177
-10
@@ -44,6 +44,9 @@ var (
|
||||
durSec = flag.Float64("dur", 4.0, "live recording duration (s)")
|
||||
timeout = flag.Duration("timeout", 90*time.Second, "overall timeout")
|
||||
verbose = flag.Bool("v", false, "log every event")
|
||||
mode = flag.String("mode", "checks", "checks | stress")
|
||||
reqrate = flag.Float64("reqrate", 0, "stress: sustained zoom requests/sec (0 = liveness only)")
|
||||
clientID = flag.Int("clientid", 0, "stress: parallel client index (output suffix)")
|
||||
)
|
||||
|
||||
// ── wire types ────────────────────────────────────────────────────────────
|
||||
@@ -173,13 +176,14 @@ type client struct {
|
||||
ws *websocket.Conn
|
||||
deadline time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
sources []sourceInfo
|
||||
configs map[string][]signalInfo
|
||||
pushes []*pushFrame
|
||||
zooms map[uint32]map[string]points
|
||||
trigSt []string
|
||||
captures []*captureFrame
|
||||
mu sync.Mutex
|
||||
sources []sourceInfo
|
||||
configs map[string][]signalInfo
|
||||
pushes []*pushFrame
|
||||
zooms map[uint32]map[string]points
|
||||
zoomArrival map[uint32]time.Time
|
||||
trigSt []string
|
||||
captures []*captureFrame
|
||||
|
||||
readErr error
|
||||
done chan struct{}
|
||||
@@ -266,6 +270,7 @@ func (c *client) handle(mt int, data []byte) {
|
||||
if json.Unmarshal(data, &body) == nil {
|
||||
c.mu.Lock()
|
||||
c.zooms[ev.ReqID] = body.Signals
|
||||
c.zoomArrival[ev.ReqID] = time.Now()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
case "triggerState":
|
||||
@@ -491,9 +496,10 @@ func main() {
|
||||
|
||||
c := &client{
|
||||
ws: ws, deadline: time.Now().Add(*timeout),
|
||||
configs: map[string][]signalInfo{},
|
||||
zooms: map[uint32]map[string]points{},
|
||||
done: make(chan struct{}),
|
||||
configs: map[string][]signalInfo{},
|
||||
zooms: map[uint32]map[string]points{},
|
||||
zoomArrival: map[uint32]time.Time{},
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go c.reader()
|
||||
out := checksOut{Scenario: *scenario}
|
||||
@@ -522,6 +528,13 @@ func main() {
|
||||
return true
|
||||
})
|
||||
|
||||
// stress mode: record liveness + sustained zoom latency, then exit. The
|
||||
// correctness checks below are skipped (a separate gate framework).
|
||||
if *mode == "stress" {
|
||||
c.runStress(*scenario, *clientID, *durSec, *reqrate, *outDir)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. live recording — the reader goroutine accumulates pushes in the
|
||||
// background, so we simply wait out the recording window (or an early
|
||||
// reader death).
|
||||
@@ -647,6 +660,160 @@ func main() {
|
||||
fmt.Printf("OK chain-client %s: %s + %s\n", *scenario, filepath.Base(recvPath), filepath.Base(cj))
|
||||
}
|
||||
|
||||
// ── stress mode ──────────────────────────────────────────────────────────────
|
||||
|
||||
type stressOut struct {
|
||||
Scenario string `json:"scenario"`
|
||||
ClientID int `json:"clientId"`
|
||||
Frames int `json:"frames"`
|
||||
Signals int `json:"signals"`
|
||||
Monotonic bool `json:"monotonic"`
|
||||
WallClock bool `json:"wallclock"`
|
||||
DurationS float64 `json:"duration"`
|
||||
ReqRate float64 `json:"reqRate"`
|
||||
ZoomCount int `json:"zoomCount"`
|
||||
ZoomFail int `json:"zoomFail"`
|
||||
ZoomP50ms float64 `json:"zoomP50ms"`
|
||||
ZoomP95ms float64 `json:"zoomP95ms"`
|
||||
ZoomMaxms float64 `json:"zoomMaxms"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
// busiestKey returns the full "src:sig" key carrying the most samples so far.
|
||||
func (c *client) busiestKey() string {
|
||||
m := c.merged()
|
||||
var busy string
|
||||
bn := 0
|
||||
for k, p := range m {
|
||||
if len(p.T) > bn {
|
||||
bn, busy = len(p.T), k
|
||||
}
|
||||
}
|
||||
return busy
|
||||
}
|
||||
|
||||
// maxTimeFull returns the latest timestamp seen for a full "src:sig" key.
|
||||
func (c *client) maxTimeFull(full string) (float64, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
var mx float64
|
||||
found := false
|
||||
for _, f := range c.pushes {
|
||||
if !strings.HasPrefix(full, f.sourceID+":") {
|
||||
continue
|
||||
}
|
||||
name := full[len(f.sourceID)+1:]
|
||||
p, ok := f.signals[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, t := range p.T {
|
||||
if !found || t > mx {
|
||||
mx, found = t, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return mx, found
|
||||
}
|
||||
|
||||
// runStress records liveness for the duration while (optionally) issuing zoom
|
||||
// queries at a sustained rate, measuring round-trip latency. One request is in
|
||||
// flight at a time, so latency reflects the hub's serialised zoom service time
|
||||
// under whatever concurrent live/zoom load the matrix imposes.
|
||||
func (c *client) runStress(scenario string, clientID int, dur, reqrate float64, outDir string) {
|
||||
if !c.waitFor(20*time.Second, func() bool { return len(c.pushes) > 0 }) {
|
||||
fatal("stress: no live push within timeout")
|
||||
}
|
||||
// brief warmup so the busiest signal and a usable time window exist.
|
||||
c.waitFor(500*time.Millisecond, func() bool { return false })
|
||||
key := c.busiestKey()
|
||||
|
||||
start := time.Now()
|
||||
end := start.Add(time.Duration(dur * float64(time.Second)))
|
||||
var lat []float64
|
||||
zoomFail := 0
|
||||
var reqID uint32 = 5000
|
||||
var interval time.Duration
|
||||
if reqrate > 0 {
|
||||
interval = time.Duration(float64(time.Second) / reqrate)
|
||||
}
|
||||
|
||||
for reqrate > 0 && key != "" && time.Now().Before(end) {
|
||||
tick := time.Now()
|
||||
t1, ok := c.maxTimeFull(key)
|
||||
if !ok {
|
||||
c.waitFor(50*time.Millisecond, func() bool { return false })
|
||||
continue
|
||||
}
|
||||
reqID++
|
||||
c.send(map[string]interface{}{
|
||||
"type": "zoom", "reqId": reqID, "t0": t1 - 0.5, "t1": t1,
|
||||
"n": 300, "signals": key,
|
||||
})
|
||||
sendT := time.Now()
|
||||
got := c.waitFor(3*time.Second, func() bool {
|
||||
_, ok := c.zoomArrival[reqID]
|
||||
return ok
|
||||
})
|
||||
if got {
|
||||
c.mu.Lock()
|
||||
at := c.zoomArrival[reqID]
|
||||
c.mu.Unlock()
|
||||
lat = append(lat, at.Sub(sendT).Seconds()*1000.0)
|
||||
} else {
|
||||
zoomFail++
|
||||
}
|
||||
if rem := interval - time.Since(tick); rem > 0 {
|
||||
c.waitFor(rem, func() bool { return false })
|
||||
}
|
||||
}
|
||||
// if no reqrate, simply wait out the remaining liveness window.
|
||||
if reqrate <= 0 {
|
||||
c.waitFor(time.Until(end), func() bool { return false })
|
||||
}
|
||||
|
||||
m := c.merged()
|
||||
now := float64(time.Now().UnixNano()) / 1e9
|
||||
mono, wall := true, true
|
||||
for _, p := range m {
|
||||
for i, t := range p.T {
|
||||
if math.Abs(t-now) > 60.0 {
|
||||
wall = false
|
||||
}
|
||||
if i > 0 && t < p.T[i-1]-1e-9 {
|
||||
mono = false
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Float64s(lat)
|
||||
pct := func(q float64) float64 {
|
||||
if len(lat) == 0 {
|
||||
return 0
|
||||
}
|
||||
idx := int(q * float64(len(lat)-1))
|
||||
return lat[idx]
|
||||
}
|
||||
so := stressOut{
|
||||
Scenario: scenario, ClientID: clientID,
|
||||
Frames: c.nPushes(), Signals: len(m), Monotonic: mono,
|
||||
WallClock: wall, DurationS: dur, ReqRate: reqrate, Key: key,
|
||||
ZoomCount: len(lat), ZoomFail: zoomFail,
|
||||
ZoomP50ms: pct(0.50), ZoomP95ms: pct(0.95),
|
||||
}
|
||||
if len(lat) > 0 {
|
||||
so.ZoomMaxms = lat[len(lat)-1]
|
||||
}
|
||||
path := filepath.Join(outDir,
|
||||
fmt.Sprintf("stress_%s_c%d.json", scenario, clientID))
|
||||
b, _ := json.MarshalIndent(so, "", " ")
|
||||
if err := os.WriteFile(path, b, 0o644); err != nil {
|
||||
fatal("write stress: %v", err)
|
||||
}
|
||||
log.Printf("stress: frames=%d signals=%d zoom n=%d fail=%d p50=%.1fms p95=%.1fms max=%.1fms",
|
||||
so.Frames, so.Signals, so.ZoomCount, so.ZoomFail, so.ZoomP50ms, so.ZoomP95ms, so.ZoomMaxms)
|
||||
fmt.Printf("OK stress %s c%d: %s\n", scenario, clientID, filepath.Base(path))
|
||||
}
|
||||
|
||||
// runTrigger configures one edge/mode trigger, arms it, and records the result.
|
||||
func (c *client) runTrigger(key, edge, mode string, thr float64) trigCheck {
|
||||
tc := trigCheck{Edge: edge, Mode: mode, Key: key}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── frame builders (mirror the StreamHub wire format) ───────────────────────
|
||||
|
||||
func putf(b []byte, v float64) []byte {
|
||||
var t [8]byte
|
||||
binary.LittleEndian.PutUint64(t[:], math.Float64bits(v))
|
||||
return append(b, t[:]...)
|
||||
}
|
||||
|
||||
func putSignals(b []byte, sigs map[string]points) []byte {
|
||||
var n [4]byte
|
||||
binary.LittleEndian.PutUint32(n[:], uint32(len(sigs)))
|
||||
b = append(b, n[:]...)
|
||||
for k, p := range sigs {
|
||||
var kl [2]byte
|
||||
binary.LittleEndian.PutUint16(kl[:], uint16(len(k)))
|
||||
b = append(b, kl[:]...)
|
||||
b = append(b, []byte(k)...)
|
||||
var cnt [4]byte
|
||||
binary.LittleEndian.PutUint32(cnt[:], uint32(len(p.T)))
|
||||
b = append(b, cnt[:]...)
|
||||
for _, t := range p.T {
|
||||
b = putf(b, t)
|
||||
}
|
||||
for _, v := range p.V {
|
||||
b = putf(b, v)
|
||||
}
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func buildPush(srcID string, sigs map[string]points) []byte {
|
||||
b := []byte{1, byte(len(srcID))}
|
||||
b = append(b, []byte(srcID)...)
|
||||
return putSignals(b, sigs)
|
||||
}
|
||||
|
||||
func buildCapture(trigTime, preSec, postSec float64, sigs map[string]points) []byte {
|
||||
b := []byte{2}
|
||||
b = putf(b, trigTime)
|
||||
b = putf(b, preSec)
|
||||
b = putf(b, postSec)
|
||||
return putSignals(b, sigs)
|
||||
}
|
||||
|
||||
// ── tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestParsePushRoundTrip(t *testing.T) {
|
||||
in := map[string]points{"Sine": {T: []float64{0, 0.1, 0.2}, V: []float64{1, 2, 3}}}
|
||||
f, err := parsePush(buildPush("src", in))
|
||||
if err != nil {
|
||||
t.Fatalf("parsePush: %v", err)
|
||||
}
|
||||
if f.sourceID != "src" {
|
||||
t.Errorf("sourceID = %q, want src", f.sourceID)
|
||||
}
|
||||
got := f.signals["Sine"]
|
||||
if len(got.T) != 3 || got.T[2] != 0.2 || got.V[1] != 2 {
|
||||
t.Errorf("bad payload: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePushRejectsWrongVersion(t *testing.T) {
|
||||
if _, err := parsePush([]byte{2, 0, 0}); err == nil {
|
||||
t.Error("expected error on non-v1 frame")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePushTruncated(t *testing.T) {
|
||||
full := buildPush("src", map[string]points{"A": {T: []float64{0}, V: []float64{1}}})
|
||||
if _, err := parsePush(full[:len(full)-4]); err == nil {
|
||||
t.Error("expected truncation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCaptureRoundTrip(t *testing.T) {
|
||||
in := map[string]points{"Sig": {T: []float64{1, 2}, V: []float64{9, 8}}}
|
||||
f, err := parseCapture(buildCapture(100.5, 0.02, 0.08, in))
|
||||
if err != nil {
|
||||
t.Fatalf("parseCapture: %v", err)
|
||||
}
|
||||
if f.trigTime != 100.5 || f.preSec != 0.02 || f.postSec != 0.08 {
|
||||
t.Errorf("header = %v/%v/%v", f.trigTime, f.preSec, f.postSec)
|
||||
}
|
||||
if g := f.signals["Sig"]; len(g.T) != 2 || g.V[0] != 9 {
|
||||
t.Errorf("bad capture payload: %+v", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCaptureRejectsWrongVersion(t *testing.T) {
|
||||
if _, err := parseCapture([]byte{1, 0, 0, 0}); err == nil {
|
||||
t.Error("expected error on non-v2 frame")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergedSortsAndDedups(t *testing.T) {
|
||||
c := &client{
|
||||
configs: map[string][]signalInfo{},
|
||||
zooms: map[uint32]map[string]points{},
|
||||
}
|
||||
// two pushes, out of order, with one duplicate timestamp
|
||||
c.pushes = []*pushFrame{
|
||||
{sourceID: "s", signals: map[string]points{"A": {T: []float64{0.2, 0.1}, V: []float64{2, 1}}}},
|
||||
{sourceID: "s", signals: map[string]points{"A": {T: []float64{0.1, 0.3}, V: []float64{1, 3}}}},
|
||||
}
|
||||
m := c.merged()
|
||||
got := m["s:A"]
|
||||
want := []float64{0.1, 0.2, 0.3}
|
||||
if len(got.T) != len(want) {
|
||||
t.Fatalf("len = %d, want %d (%+v)", len(got.T), len(want), got)
|
||||
}
|
||||
for i := range want {
|
||||
if got.T[i] != want[i] {
|
||||
t.Errorf("t[%d] = %v, want %v", i, got.T[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEdgeCrosses(t *testing.T) {
|
||||
rising := points{T: []float64{0, 1, 2}, V: []float64{-1, 1, 2}}
|
||||
if !edgeCrosses(rising, 0.5, "rising", 0.0) {
|
||||
t.Error("rising cross not detected")
|
||||
}
|
||||
if edgeCrosses(rising, 0.5, "falling", 0.0) {
|
||||
t.Error("false falling detection on rising data")
|
||||
}
|
||||
falling := points{T: []float64{0, 1}, V: []float64{1, -1}}
|
||||
if !edgeCrosses(falling, 0.5, "falling", 0.0) {
|
||||
t.Error("falling cross not detected")
|
||||
}
|
||||
if !edgeCrosses(falling, 0.5, "both", 0.0) {
|
||||
t.Error("both should match a falling edge")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHas(t *testing.T) {
|
||||
if !has("live,zoom,trigger", "zoom") {
|
||||
t.Error("has should find zoom")
|
||||
}
|
||||
if has("live, window", "trigger") {
|
||||
t.Error("has should not find trigger")
|
||||
}
|
||||
if !has("live, window", "window") {
|
||||
t.Error("has should trim spaces")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user