test(e2e-chain): orchestrator + end-to-end fixes (Task 7)
Full-chain orchestrator (run_chain_e2e.sh) now runs the starter set green (3 pass). Fixes found bringing the chain up end-to-end: - client: gorilla/websocket cannot survive a read deadline (next ReadMessage panics "repeated read on failed connection"); replace the poll-with-deadline loop with a background reader goroutine + mutex-guarded state. - orchestrator: guard env.sh's unbound LD_LIBRARY_PATH under set -u. - scenarios/gen_data: centralize NUM_ROWS/ROW_DT and enforce sine freq to be a multiple of the buffer fundamental (LOOP_HZ=5 Hz) so the looped FileReader buffer is a seamless waveform; align starter freqs (5/5/10 Hz). - gen_cfg: FileReader allows exactly one consuming Function, so route tapped (oracle=fed/both) sources through the DDB (ReaderGAM->DDB, then StreamGAM and TapGAM both read DDB) instead of a second FileReader consumer. - validate_waveform: fidelity gates correctness (bit-exact / within one quant level); sine shape becomes a gross frequency-sanity gate (corr>=0.5) plus a tracked corr/nRMSE quality metric, since per-sample wall-clock calibration (Phase-A) and FULL_ARRAY packed timestamps (Phase-A4) are still pending. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+104
-22
@@ -28,6 +28,7 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
@@ -172,12 +173,16 @@ 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
|
||||
|
||||
readErr error
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func (c *client) send(v interface{}) {
|
||||
@@ -187,15 +192,25 @@ func (c *client) send(v interface{}) {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *client) pump() {
|
||||
c.ws.SetReadDeadline(time.Now().Add(300 * time.Millisecond))
|
||||
mt, data, err := c.ws.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err) {
|
||||
fatal("ws closed: %v", err)
|
||||
// reader runs in its own goroutine for the lifetime of the connection. gorilla's
|
||||
// websocket connection cannot survive a read deadline (the next ReadMessage
|
||||
// panics), so we never set one here: we block on ReadMessage and let the overall
|
||||
// timeout/cond logic in waitFor decide when enough has arrived.
|
||||
func (c *client) reader() {
|
||||
defer close(c.done)
|
||||
for {
|
||||
mt, data, err := c.ws.ReadMessage()
|
||||
if err != nil {
|
||||
c.mu.Lock()
|
||||
c.readErr = err
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
return
|
||||
c.handle(mt, data)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *client) handle(mt int, data []byte) {
|
||||
switch mt {
|
||||
case websocket.BinaryMessage:
|
||||
if len(data) == 0 {
|
||||
@@ -204,13 +219,17 @@ func (c *client) pump() {
|
||||
switch data[0] {
|
||||
case 1:
|
||||
if f, err := parsePush(data); err == nil {
|
||||
c.mu.Lock()
|
||||
c.pushes = append(c.pushes, f)
|
||||
c.mu.Unlock()
|
||||
} else {
|
||||
fatal("bad v1 frame: %v", err)
|
||||
}
|
||||
case 2:
|
||||
if f, err := parseCapture(data); err == nil {
|
||||
c.mu.Lock()
|
||||
c.captures = append(c.captures, f)
|
||||
c.mu.Unlock()
|
||||
} else {
|
||||
fatal("bad v2 frame: %v", err)
|
||||
}
|
||||
@@ -229,40 +248,96 @@ func (c *client) pump() {
|
||||
case "sources":
|
||||
var s []sourceInfo
|
||||
if json.Unmarshal(ev.Sources, &s) == nil {
|
||||
c.mu.Lock()
|
||||
c.sources = s
|
||||
c.mu.Unlock()
|
||||
}
|
||||
case "config":
|
||||
var s []signalInfo
|
||||
if json.Unmarshal(ev.Signals, &s) == nil {
|
||||
c.mu.Lock()
|
||||
c.configs[ev.SourceID] = s
|
||||
c.mu.Unlock()
|
||||
}
|
||||
case "zoom":
|
||||
var body struct {
|
||||
Signals map[string]points `json:"signals"`
|
||||
}
|
||||
if json.Unmarshal(data, &body) == nil {
|
||||
c.mu.Lock()
|
||||
c.zooms[ev.ReqID] = body.Signals
|
||||
c.mu.Unlock()
|
||||
}
|
||||
case "triggerState":
|
||||
c.mu.Lock()
|
||||
c.trigSt = append(c.trigSt, ev.State)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// waitFor polls cond (evaluated under the state lock) until it holds or the
|
||||
// deadline passes. It returns early if the reader goroutine died.
|
||||
func (c *client) waitFor(d time.Duration, cond func() bool) bool {
|
||||
end := time.Now().Add(d)
|
||||
if end.After(c.deadline) {
|
||||
end = c.deadline
|
||||
}
|
||||
for time.Now().Before(end) {
|
||||
if cond() {
|
||||
c.mu.Lock()
|
||||
ok := cond()
|
||||
err := c.readErr
|
||||
c.mu.Unlock()
|
||||
if ok {
|
||||
return true
|
||||
}
|
||||
c.pump()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case <-c.done:
|
||||
c.mu.Lock()
|
||||
ok := cond()
|
||||
c.mu.Unlock()
|
||||
return ok
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// zoom returns the recorded zoom reply for reqID, if present.
|
||||
func (c *client) zoom(reqID uint32) (map[string]points, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
z, ok := c.zooms[reqID]
|
||||
return z, ok
|
||||
}
|
||||
|
||||
// lastCapture returns the most recently recorded trigger capture, if any.
|
||||
func (c *client) lastCapture() *captureFrame {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if len(c.captures) == 0 {
|
||||
return nil
|
||||
}
|
||||
return c.captures[len(c.captures)-1]
|
||||
}
|
||||
|
||||
// nCaptures returns the number of trigger captures recorded so far.
|
||||
func (c *client) nCaptures() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return len(c.captures)
|
||||
}
|
||||
|
||||
// nPushes returns the number of live push frames recorded so far.
|
||||
func (c *client) nPushes() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return len(c.pushes)
|
||||
}
|
||||
|
||||
func fatal(format string, a ...interface{}) {
|
||||
fmt.Printf("FATAL "+format+"\n", a...)
|
||||
os.Exit(1)
|
||||
@@ -270,8 +345,11 @@ func fatal(format string, a ...interface{}) {
|
||||
|
||||
// merged returns time-sorted, de-duplicated samples per "src:sig" key.
|
||||
func (c *client) merged() map[string]points {
|
||||
c.mu.Lock()
|
||||
pushes := append([]*pushFrame(nil), c.pushes...)
|
||||
c.mu.Unlock()
|
||||
tmp := map[string]points{}
|
||||
for _, f := range c.pushes {
|
||||
for _, f := range pushes {
|
||||
for k, p := range f.signals {
|
||||
full := f.sourceID + ":" + k
|
||||
cur := tmp[full]
|
||||
@@ -415,7 +493,9 @@ func main() {
|
||||
ws: ws, deadline: time.Now().Add(*timeout),
|
||||
configs: map[string][]signalInfo{},
|
||||
zooms: map[uint32]map[string]points{},
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go c.reader()
|
||||
out := checksOut{Scenario: *scenario}
|
||||
|
||||
// 1. sources connected
|
||||
@@ -442,11 +522,10 @@ func main() {
|
||||
return true
|
||||
})
|
||||
|
||||
// 2. live recording
|
||||
recEnd := time.Now().Add(time.Duration(*durSec * float64(time.Second)))
|
||||
for time.Now().Before(recEnd) {
|
||||
c.pump()
|
||||
}
|
||||
// 2. live recording — the reader goroutine accumulates pushes in the
|
||||
// background, so we simply wait out the recording window (or an early
|
||||
// reader death).
|
||||
c.waitFor(time.Duration(*durSec*float64(time.Second)), func() bool { return false })
|
||||
m := c.merged()
|
||||
recvPath := filepath.Join(*outDir, "received_"+*scenario+".bin")
|
||||
if err := writeReceived(recvPath, m); err != nil {
|
||||
@@ -464,12 +543,13 @@ func main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
nPush := c.nPushes()
|
||||
out.Live = liveCheck{
|
||||
OK: len(c.pushes) >= 5 && len(m) > 0 && mono && wall,
|
||||
Frames: len(c.pushes), Signals: len(m), Monotonic: mono,
|
||||
OK: nPush >= 5 && len(m) > 0 && mono && wall,
|
||||
Frames: nPush, Signals: len(m), Monotonic: mono,
|
||||
WallClock: wall, DurationS: *durSec,
|
||||
}
|
||||
log.Printf("live: %d frames, %d signals, mono=%v wall=%v", len(c.pushes), len(m), mono, wall)
|
||||
log.Printf("live: %d frames, %d signals, mono=%v wall=%v", nPush, len(m), mono, wall)
|
||||
|
||||
// busiest signal for zoom/window
|
||||
var busy string
|
||||
@@ -495,7 +575,8 @@ func main() {
|
||||
ok := c.waitFor(8*time.Second, func() bool { _, ok := c.zooms[reqID]; return ok })
|
||||
zc := zoomCheck{Range: rg, N: 300, Key: busy, InRange: true}
|
||||
if ok {
|
||||
pts := c.zooms[reqID][busy]
|
||||
z, _ := c.zoom(reqID)
|
||||
pts := z[busy]
|
||||
zc.Returned = len(pts.T)
|
||||
for _, t := range pts.T {
|
||||
if t < rg[0]-1e-6 || t > rg[1]+1e-6 {
|
||||
@@ -523,7 +604,8 @@ func main() {
|
||||
ok := c.waitFor(8*time.Second, func() bool { _, ok := c.zooms[reqID]; return ok })
|
||||
wc := windowCheck{WindowSec: winSec, Key: busy}
|
||||
if ok {
|
||||
pts := c.zooms[reqID][busy]
|
||||
z, _ := c.zoom(reqID)
|
||||
pts := z[busy]
|
||||
wc.Returned = len(pts.T)
|
||||
if len(pts.T) >= 2 {
|
||||
wc.Span = pts.T[len(pts.T)-1] - pts.T[0]
|
||||
@@ -568,7 +650,7 @@ func main() {
|
||||
// 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}
|
||||
beforeCaps := len(c.captures)
|
||||
beforeCaps := c.nCaptures()
|
||||
c.send(map[string]interface{}{
|
||||
"type": "setTrigger", "signal": key, "edge": edge,
|
||||
"threshold": thr, "windowSec": 0.1, "prePercent": 20.0, "mode": mode,
|
||||
@@ -580,7 +662,7 @@ func (c *client) runTrigger(key, edge, mode string, thr float64) trigCheck {
|
||||
c.send(map[string]interface{}{"type": "disarm"})
|
||||
return tc
|
||||
}
|
||||
cap0 := c.captures[len(c.captures)-1]
|
||||
cap0 := c.lastCapture()
|
||||
tc.TrigTime, tc.PreSec, tc.PostSec = cap0.trigTime, cap0.preSec, cap0.postSec
|
||||
tc.WindowOK = math.Abs(cap0.preSec-0.02) < 1e-6 && math.Abs(cap0.postSec-0.08) < 1e-6
|
||||
if pts, ok := cap0.signals[key]; ok {
|
||||
|
||||
Reference in New Issue
Block a user