fix(ultrabeam): match replies to requests by seq (stop stream desync)

sendCommand discarded the reply's sequence number and accepted whatever frame
came back. On a slow remote link a command that timed out left its late reply in
the stream, and the NEXT command read it as its own — crossing STATUS with
READ_BANDS/PROGRESS. That surfaced as phantom frequency jumps (a spurious
follow-loop re-tune), intermittent "reply too short" disconnects, and wrong
element-length readings (READ_BANDS getting the status frame).

Now every reply is matched to its request seq and stale/malformed frames are
drained (bounded), with readPacket resyncing to the next STX. Tested with a
net.Pipe that injects a stale reply ahead of the real one.
This commit is contained in:
2026-08-04 23:56:42 +02:00
parent 058f164ab6
commit 98f11ee3d0
3 changed files with 150 additions and 45 deletions
+66 -43
View File
@@ -301,10 +301,9 @@ func quoteByte(b byte) []byte {
return []byte{b}
}
// buildPacket creates a complete packet with checksum and escaping
func (c *Client) buildPacket(cmd byte, data []byte) []byte {
seq := c.getNextSeq()
// buildPacket creates a complete packet with checksum and escaping. The seq is
// supplied by the caller so sendCommand can match the reply against it.
func (c *Client) buildPacket(seq, cmd byte, data []byte) []byte {
// Calculate checksum on unquoted data
payload := append([]byte{seq, cmd}, data...)
chk := calculateChecksum(payload)
@@ -389,65 +388,89 @@ func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) {
return nil, fmt.Errorf("not connected")
}
// Build and send packet
packet := c.buildPacket(cmd, data)
// Build and send packet, remembering the seq so the reply can be matched to it.
seq := c.getNextSeq()
packet := c.buildPacket(seq, cmd, data)
_, err := c.conn.Write(packet)
if err != nil {
return nil, fmt.Errorf("failed to write: %w", err)
}
// Read reply with timeout (generous — tolerates remote-link latency).
// Read reply with timeout (generous — tolerates remote-link latency). The
// deadline bounds the WHOLE exchange, including draining any stale replies.
c.conn.SetReadDeadline(time.Now().Add(ubReadTimeout))
// Read until we get a complete packet
// Match the reply to our seq. A command that timed out earlier can leave its
// late reply sitting in the stream; without this check THIS command would read
// that stale reply as its own — crossing STATUS with READ_BANDS/PROGRESS, which
// showed up as phantom frequencies (a spurious follow-loop re-tune) and dropped
// connections. So read frames until one carries our seq, discarding the rest.
// Bounded, so a stream that never yields our reply errors instead of looping.
const maxDrain = 16
for attempts := 0; attempts < maxDrain; attempts++ {
buffer, rerr := c.readPacket()
if rerr != nil {
return nil, rerr
}
replySeq, replyCmd, payload, perr := parsePacket(buffer)
if perr != nil {
log.Printf("Ultrabeam: discarding malformed reply: %v", perr)
continue
}
if replySeq != seq {
log.Printf("Ultrabeam: draining stale reply (seq %d, want %d) — likely a prior timed-out command", replySeq, seq)
continue
}
// Log for debugging unknown codes
if replyCmd != UB_OK && replyCmd != UB_BAD && replyCmd != UB_PAR && replyCmd != UB_ERR {
log.Printf("Ultrabeam: Unknown reply code %d (0x%02X), raw packet: %v", replyCmd, replyCmd, buffer)
}
// Check for errors
switch replyCmd {
case UB_BAD:
return nil, fmt.Errorf("invalid command")
case UB_PAR:
return nil, fmt.Errorf("bad parameters")
case UB_ERR:
return nil, fmt.Errorf("execution error")
case UB_OK:
return payload, nil
default:
// Unknown codes might indicate "busy" or "in progress"
// Treat as non-fatal, return empty payload
log.Printf("Ultrabeam: Unusual reply code %d, treating as busy/in-progress", replyCmd)
return []byte{}, nil
}
}
return nil, fmt.Errorf("no reply matching seq %d after draining %d frames", seq, maxDrain)
}
// readPacket reads one complete STX…ETX frame, skipping any leading bytes until
// an STX so a partial/garbage remnant left in the stream can't derail the parse.
// STX/ETX/DLE are all high-bit (0xF5/0xFA/0xF6) and the escaping clears the MSB,
// so a raw ETX only ever appears as the real terminator. Caller holds connMu and
// has set a read deadline.
func (c *Client) readPacket() ([]byte, error) {
var buffer []byte
for {
b, err := c.reader.ReadByte()
if err != nil {
return nil, fmt.Errorf("failed to read: %w", err)
}
if len(buffer) == 0 && b != STX {
continue // resync to the start of a frame
}
buffer = append(buffer, b)
// Check if we have a complete packet
if b == ETX && len(buffer) > 0 && buffer[0] == STX {
break
if b == ETX {
return buffer, nil
}
// Prevent infinite loop
if len(buffer) > 256 {
return nil, fmt.Errorf("packet too long")
}
}
// Parse reply
_, replyCmd, payload, err := parsePacket(buffer)
if err != nil {
return nil, fmt.Errorf("failed to parse reply: %w", err)
}
// Log for debugging unknown codes
if replyCmd != UB_OK && replyCmd != UB_BAD && replyCmd != UB_PAR && replyCmd != UB_ERR {
log.Printf("Ultrabeam: Unknown reply code %d (0x%02X), raw packet: %v", replyCmd, replyCmd, buffer)
}
// Check for errors
switch replyCmd {
case UB_BAD:
return nil, fmt.Errorf("invalid command")
case UB_PAR:
return nil, fmt.Errorf("bad parameters")
case UB_ERR:
return nil, fmt.Errorf("execution error")
case UB_OK:
return payload, nil
default:
// Unknown codes might indicate "busy" or "in progress"
// Treat as non-fatal, return empty payload
log.Printf("Ultrabeam: Unusual reply code %d, treating as busy/in-progress", replyCmd)
return []byte{}, nil
}
}
// queryStatus queries general status (command 1)