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:
Martino Ferrari
2026-06-25 13:11:35 +02:00
parent d4fa84d482
commit f256a4def5
6 changed files with 400 additions and 40 deletions
+104 -22
View File
@@ -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 {