refactor(debugger): extract MarteController sink so it can run headless

Add a sink func(v any) field so MarteController's event stream can be
routed somewhere other than the browser WebSocket hub. NewMarteController
now sets sink to broadcast through the hub as before; a new
NewHeadlessMarteController(sink) constructor builds an instance with
hub == nil for the upcoming debugclient E2E tool. Direct m.hub.* calls
(SetSourceState/UpdateConfigForSource/PushDataForSource) are now guarded
with nil checks so a headless controller doesn't panic.
This commit is contained in:
Martino Ferrari
2026-07-01 19:12:41 +02:00
parent 1d78b45963
commit 269b2c4d97
+46 -17
View File
@@ -82,6 +82,7 @@ func broadcastHub(hub *wshub.Hub, v any) {
type MarteController struct { type MarteController struct {
hub *wshub.Hub hub *wshub.Hub
sink func(v any)
mu sync.Mutex mu sync.Mutex
tcpConn net.Conn tcpConn net.Conn
@@ -135,6 +136,7 @@ func NewMarteController(hub *wshub.Hub) *MarteController {
forcedState: make(map[string]string), forcedState: make(map[string]string),
stopCh: make(chan struct{}), stopCh: make(chan struct{}),
} }
mc.sink = func(v any) { broadcastHub(mc.hub, v) }
// Register the new-client hook so connection + forced/traced state is // Register the new-client hook so connection + forced/traced state is
// replayed to any browser that connects (or reconnects) while the server // replayed to any browser that connects (or reconnects) while the server
// already holds a live MARTe2 TCP session. // already holds a live MARTe2 TCP session.
@@ -142,6 +144,20 @@ func NewMarteController(hub *wshub.Hub) *MarteController {
return mc return mc
} }
// NewHeadlessMarteController creates a MarteController with no WebSocket hub,
// routing all events through sink instead (used by the debugclient E2E test tool).
func NewHeadlessMarteController(sink func(v any)) *MarteController {
mc := &MarteController{
hub: nil,
signals: make(map[uint32]*SignalMeta),
tracedNames: make(map[string]bool),
forcedState: make(map[string]string),
stopCh: make(chan struct{}),
}
mc.sink = sink
return mc
}
func (m *MarteController) IsConnected() bool { func (m *MarteController) IsConnected() bool {
return atomic.LoadInt32(&m.connected) == 1 return atomic.LoadInt32(&m.connected) == 1
} }
@@ -200,10 +216,13 @@ func (m *MarteController) Connect(host string, cmdPort, udpPort, logPort int) {
m.stopCh = make(chan struct{}) m.stopCh = make(chan struct{})
m.mu.Unlock() m.mu.Unlock()
// Update source state so the browser shows "connecting". // Update source state so the browser shows "connecting". No-op headless
// (m.hub == nil for NewHeadlessMarteController instances).
if m.hub != nil {
m.hub.SetSourceState("debug", "connecting") m.hub.SetSourceState("debug", "connecting")
}
broadcastHub(m.hub, map[string]any{ m.sink(map[string]any{
"type": "log", "time": time.Now().Format("15:04:05.000"), "type": "log", "time": time.Now().Format("15:04:05.000"),
"level": "INFO", "message": fmt.Sprintf("Connecting to %s cmd=%d udp=%d log=%d", host, cmdPort, udpPort, logPort), "level": "INFO", "message": fmt.Sprintf("Connecting to %s cmd=%d udp=%d log=%d", host, cmdPort, udpPort, logPort),
}) })
@@ -232,7 +251,9 @@ func (m *MarteController) Disconnect() {
m.baseTsSet = false m.baseTsSet = false
m.basesMu.Unlock() m.basesMu.Unlock()
m.discoverAcc = nil m.discoverAcc = nil
if m.hub != nil {
m.hub.SetSourceState("debug", "disconnected") m.hub.SetSourceState("debug", "disconnected")
}
} }
func (m *MarteController) stopped() bool { func (m *MarteController) stopped() bool {
@@ -299,7 +320,7 @@ func (m *MarteController) HandleBrowserCommand(msg []byte) {
// forwarded to the MARTe2 TCP control connection. // forwarded to the MARTe2 TCP control connection.
if isDangerousCommand(cmd) { if isDangerousCommand(cmd) {
if !dangerousCommandsEnabled { if !dangerousCommandsEnabled {
broadcastHub(m.hub, map[string]any{ m.sink(map[string]any{
"type": "log", "time": time.Now().Format("15:04:05.000"), "type": "log", "time": time.Now().Format("15:04:05.000"),
"level": "WARNING", "level": "WARNING",
"message": fmt.Sprintf("Blocked dangerous command (requires --enable-dangerous-commands): %s", cmd), "message": fmt.Sprintf("Blocked dangerous command (requires --enable-dangerous-commands): %s", cmd),
@@ -321,7 +342,7 @@ func (m *MarteController) runTCP(host string, port int) {
for !m.stopped() { for !m.stopped() {
conn, err := net.DialTimeout("tcp", addr, 5*time.Second) conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
if err != nil { if err != nil {
broadcastHub(m.hub, map[string]any{ m.sink(map[string]any{
"type": "log", "time": time.Now().Format("15:04:05.000"), "type": "log", "time": time.Now().Format("15:04:05.000"),
"level": "WARNING", "message": fmt.Sprintf("TCP %s: %v — retrying…", addr, err), "level": "WARNING", "message": fmt.Sprintf("TCP %s: %v — retrying…", addr, err),
}) })
@@ -336,7 +357,7 @@ func (m *MarteController) runTCP(host string, port int) {
m.mu.Unlock() m.mu.Unlock()
atomic.StoreInt32(&m.connected, 1) atomic.StoreInt32(&m.connected, 1)
broadcastHub(m.hub, map[string]any{"type": "connected"}) m.sink(map[string]any{"type": "connected"})
// Send SERVICE_INFO to auto-discover ports // Send SERVICE_INFO to auto-discover ports
m.writeCmd("SERVICE_INFO") m.writeCmd("SERVICE_INFO")
@@ -346,7 +367,7 @@ func (m *MarteController) runTCP(host string, port int) {
m.readLoop(conn) m.readLoop(conn)
atomic.StoreInt32(&m.connected, 0) atomic.StoreInt32(&m.connected, 0)
broadcastHub(m.hub, map[string]any{"type": "disconnected"}) m.sink(map[string]any{"type": "disconnected"})
m.mu.Lock() m.mu.Lock()
m.tcpConn = nil m.tcpConn = nil
@@ -372,7 +393,7 @@ func (m *MarteController) writeCmd(cmd string) {
silent := cmd == "STEP_STATUS" || cmd == "INFO" silent := cmd == "STEP_STATUS" || cmd == "INFO"
if !silent { if !silent {
log.Printf("[→MARTe] %s", cmd) log.Printf("[→MARTe] %s", cmd)
broadcastHub(m.hub, map[string]any{ m.sink(map[string]any{
"type": "log", "time": time.Now().Format("15:04:05.000"), "type": "log", "time": time.Now().Format("15:04:05.000"),
"level": "CMD", "message": fmt.Sprintf("→ %s", cmd), "level": "CMD", "message": fmt.Sprintf("→ %s", cmd),
}) })
@@ -514,7 +535,7 @@ func (m *MarteController) handleJSONResponse(tag, data string) {
silent := tag == "STEP_STATUS" || tag == "INFO" silent := tag == "STEP_STATUS" || tag == "INFO"
if !silent { if !silent {
log.Printf("[←MARTe] %s %d bytes", tag, len(data)) log.Printf("[←MARTe] %s %d bytes", tag, len(data))
broadcastHub(m.hub, map[string]any{ m.sink(map[string]any{
"type": "log", "time": time.Now().Format("15:04:05.000"), "type": "log", "time": time.Now().Format("15:04:05.000"),
"level": "RESP", "message": fmt.Sprintf("← %s (%d B)", tag, len(data)), "level": "RESP", "message": fmt.Sprintf("← %s (%d B)", tag, len(data)),
}) })
@@ -549,25 +570,27 @@ func (m *MarteController) handleJSONResponse(tag, data string) {
raw := m.rawSigs raw := m.rawSigs
m.rawSigsMu.RUnlock() m.rawSigsMu.RUnlock()
if len(raw) > 0 { if len(raw) > 0 {
if m.hub != nil {
m.hub.UpdateConfigForSource("debug", m.translateSignalNames(raw)) m.hub.UpdateConfigForSource("debug", m.translateSignalNames(raw))
}
} else { } else {
m.synthesizeHubConfig(all) m.synthesizeHubConfig(all)
} }
// Re-marshal the merged list so the browser gets a single consistent blob. // Re-marshal the merged list so the browser gets a single consistent blob.
merged, _ := json.Marshal(discoverResp{Signals: all}) merged, _ := json.Marshal(discoverResp{Signals: all})
broadcastHub(m.hub, map[string]any{ m.sink(map[string]any{
"type": "response", "tag": "DISCOVER", "data": string(merged), "type": "response", "tag": "DISCOVER", "data": string(merged),
}) })
return return
case "TREE": case "TREE":
broadcastHub(m.hub, map[string]any{ m.sink(map[string]any{
"type": "tree_node", "type": "tree_node",
"data": data, "data": data,
}) })
return return
} }
broadcastHub(m.hub, map[string]any{ m.sink(map[string]any{
"type": "response", "type": "response",
"tag": tag, "tag": tag,
"data": data, "data": data,
@@ -586,13 +609,13 @@ func (m *MarteController) handleTextLine(line string) {
fmt.Sscanf(p[8:], "%d", &newLog) fmt.Sscanf(p[8:], "%d", &newLog)
} }
} }
broadcastHub(m.hub, map[string]any{ m.sink(map[string]any{
"type": "response", "type": "response",
"tag": "SERVICE_INFO", "tag": "SERVICE_INFO",
"data": line[len("OK SERVICE_INFO "):], "data": line[len("OK SERVICE_INFO "):],
}) })
if newUDP > 0 || newLog > 0 { if newUDP > 0 || newLog > 0 {
broadcastHub(m.hub, map[string]any{ m.sink(map[string]any{
"type": "service_config", "type": "service_config",
"udp_port": newUDP, "udp_port": newUDP,
"log_port": newLog, "log_port": newLog,
@@ -616,7 +639,7 @@ func (m *MarteController) handleTextLine(line string) {
} }
} }
} }
broadcastHub(m.hub, map[string]any{ m.sink(map[string]any{
"type": "text_line", "type": "text_line",
"data": line, "data": line,
}) })
@@ -823,7 +846,9 @@ func (m *MarteController) synthesizeHubConfig(sigs []discoverSignalJSON) {
// buffer and limiting live streaming to the fraction of a second that // buffer and limiting live streaming to the fraction of a second that
// accumulated before the DISCOVER response arrived. // accumulated before the DISCOVER response arrived.
translated := m.translateSignalNames(sigInfos) translated := m.translateSignalNames(sigInfos)
if m.hub != nil {
m.hub.UpdateConfigForSource("debug", translated) m.hub.UpdateConfigForSource("debug", translated)
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -850,7 +875,7 @@ func (m *MarteController) runDebugUDP(host string, port int) {
if err != nil { if err != nil {
msg := fmt.Sprintf("UDP bind on %s failed: %v — rebuild DebugService C++ and restart", addr, err) msg := fmt.Sprintf("UDP bind on %s failed: %v — rebuild DebugService C++ and restart", addr, err)
log.Printf("[debug-udp] %s", msg) log.Printf("[debug-udp] %s", msg)
broadcastHub(m.hub, map[string]any{ m.sink(map[string]any{
"type": "log", "time": time.Now().Format("15:04:05.000"), "type": "log", "time": time.Now().Format("15:04:05.000"),
"level": "ERROR", "message": msg, "level": "ERROR", "message": msg,
}) })
@@ -861,7 +886,7 @@ func (m *MarteController) runDebugUDP(host string, port int) {
conn.SetReadBuffer(10 * 1024 * 1024) conn.SetReadBuffer(10 * 1024 * 1024)
log.Printf("[debug-udp] listening on %s for UDPS packets", addr) log.Printf("[debug-udp] listening on %s for UDPS packets", addr)
broadcastHub(m.hub, map[string]any{ m.sink(map[string]any{
"type": "log", "time": time.Now().Format("15:04:05.000"), "type": "log", "time": time.Now().Format("15:04:05.000"),
"level": "INFO", "message": fmt.Sprintf("UDP listener bound on %s", addr), "level": "INFO", "message": fmt.Sprintf("UDP listener bound on %s", addr),
}) })
@@ -914,8 +939,10 @@ func (m *MarteController) runDebugUDP(host string, port int) {
sigs = m.translateSignalNames(sigs) sigs = m.translateSignalNames(sigs)
currentSigs = sigs currentSigs = sigs
currentPublishMode = pm currentPublishMode = pm
if m.hub != nil {
m.hub.UpdateConfigForSource("debug", sigs) m.hub.UpdateConfigForSource("debug", sigs)
m.hub.SetSourceState("debug", "connected") m.hub.SetSourceState("debug", "connected")
}
case udpsprotocol.PktData: case udpsprotocol.PktData:
if len(currentSigs) == 0 { if len(currentSigs) == 0 {
@@ -937,11 +964,13 @@ func (m *MarteController) runDebugUDP(host string, port int) {
log.Printf("[debug-udp] parse data: %v", err) log.Printf("[debug-udp] parse data: %v", err)
continue continue
} }
if m.hub != nil {
for _, s := range samples { for _, s := range samples {
m.hub.PushDataForSource("debug", s) m.hub.PushDataForSource("debug", s)
} }
} }
} }
}
log.Printf("[debug-udp] stopped") log.Printf("[debug-udp] stopped")
} }
@@ -972,7 +1001,7 @@ func (m *MarteController) runLog(host string, port int) {
} }
level := rest[:idx] level := rest[:idx]
msg := rest[idx+1:] msg := rest[idx+1:]
broadcastHub(m.hub, map[string]any{ m.sink(map[string]any{
"type": "log", "type": "log",
"time": time.Now().Format("15:04:05.000"), "time": time.Now().Format("15:04:05.000"),
"level": level, "level": level,