Files
uopi/pkg/ca/search.go
2026-04-28 00:09:22 +02:00

396 lines
10 KiB
Go

package ca
import (
"context"
"encoding/binary"
"fmt"
"net"
"sync"
"sync/atomic"
"time"
"github.com/uopi/goca/proto"
)
// searchResult is returned to a waiter when a search succeeds.
type searchResult struct {
addr string // "ip:port" TCP address of the IOC
}
// searchWaiter tracks an outstanding PV search request.
type searchWaiter struct {
pvName string
ch chan searchResult // closed or sent to exactly once
}
// searchEngine performs UDP-based PV name → IOC address resolution.
// It sends CA_PROTO_SEARCH datagrams to all configured server addresses and
// listens for replies, matching them to pending waiters via a searchID.
type searchEngine struct {
addrs []string // "ip:port" strings to search
mu sync.Mutex
waiters map[uint32]*searchWaiter // searchID → waiter
idSeq atomic.Uint32
conn *net.UDPConn // shared UDP socket (nil if not started)
}
func newSearchEngine(addrs []string) *searchEngine {
return &searchEngine{
addrs: addrs,
waiters: make(map[uint32]*searchWaiter),
}
}
// start opens the shared UDP socket and launches the receive goroutine.
// ctx cancellation stops the receive loop and closes the socket.
func (se *searchEngine) start(ctx context.Context) error {
conn, err := net.ListenUDP("udp4", &net.UDPAddr{})
if err != nil {
return fmt.Errorf("ca search: open UDP socket: %w", err)
}
se.conn = conn
go se.recvLoop(ctx)
return nil
}
// lookup resolves pvName to an IOC TCP address.
// It retries with exponential back-off until ctx is cancelled or done.
func (se *searchEngine) lookup(ctx context.Context, pvName string) (string, error) {
id := se.idSeq.Add(1)
w := &searchWaiter{
pvName: pvName,
ch: make(chan searchResult, 1),
}
se.mu.Lock()
se.waiters[id] = w
se.mu.Unlock()
defer func() {
se.mu.Lock()
delete(se.waiters, id)
se.mu.Unlock()
}()
delay := 100 * time.Millisecond
const maxDelay = 30 * time.Second
for {
if err := se.sendSearch(id, pvName); err != nil {
return "", err
}
select {
case res := <-w.ch:
return res.addr, nil
case <-time.After(delay):
delay *= 2
if delay > maxDelay {
delay = maxDelay
}
case <-ctx.Done():
return "", fmt.Errorf("ca search %q: %w", pvName, ctx.Err())
}
}
}
// sendSearch sends a CA_PROTO_VERSION + CA_PROTO_SEARCH burst to all addresses.
// The CA protocol requires both messages to be in the same UDP datagram.
func (se *searchEngine) sendSearch(id uint32, pvName string) error {
payload := proto.BuildStringPayload(pvName)
// VERSION message (no payload).
verHdr := proto.Header{
Command: proto.CmdVersion,
DataCount: proto.MinorVersion,
}
// SEARCH message.
searchHdr := proto.Header{
Command: proto.CmdSearch,
DataType: proto.SearchReply,
DataCount: proto.MinorVersion,
Parameter1: id,
Parameter2: id,
}
searchMsg := proto.BuildMessage(searchHdr, payload)
// Bundle into a single datagram — softIoc (and the CA Repeater) require
// VERSION and SEARCH to arrive together in one UDP packet.
pkt := make([]byte, 0, proto.HeaderSize+len(searchMsg))
pkt = append(pkt, verHdr.Bytes()...)
pkt = append(pkt, searchMsg...)
for _, addr := range se.addrs {
udpAddr, err := net.ResolveUDPAddr("udp4", addr)
if err != nil {
continue
}
_, _ = se.conn.WriteTo(pkt, udpAddr)
dbg("CA search sent", "pv", pvName, "id", id, "dst", udpAddr)
}
return nil
}
// recvLoop reads UDP datagrams and dispatches search replies.
func (se *searchEngine) recvLoop(ctx context.Context) {
buf := make([]byte, 65536)
for {
// Use a short read deadline so we can check ctx.Done() periodically.
_ = se.conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
n, srcAddr, err := se.conn.ReadFromUDP(buf)
if err != nil {
if ctx.Err() != nil {
return
}
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
continue
}
continue
}
dbg("CA UDP datagram received", "src", srcAddr, "bytes", n)
se.parseReply(buf[:n], srcAddr)
}
}
// parseReply scans a UDP datagram for CA_PROTO_SEARCH response messages and
// wakes the matching waiter.
func (se *searchEngine) parseReply(data []byte, src *net.UDPAddr) {
for len(data) >= proto.HeaderSize {
h, n, err := proto.DecodeHeader(newBytesReader(data))
if err != nil {
return
}
payloadEnd := n + int(h.PayloadSize)
if payloadEnd > len(data) {
return
}
payload := data[n:payloadEnd]
data = data[payloadEnd:]
if h.Command != proto.CmdSearch {
continue
}
// data_type field = TCP port of the CA server.
tcpPort := int(h.DataType)
searchID := h.Parameter2
// Resolve server IP.
//
// Two payload formats exist in the wild:
//
// New (≥ CA 4.9): [IP(4)][pad(4)]
// Old (< CA 4.9): [minor_version(2)][pad(2)][IP(4)]
//
// The old format is identified by payload[0] == 0x00 (the high byte of a
// uint16 minor version is always zero for CA versions ≤ 255, whereas a
// real IPv4 address never starts with 0x00).
//
// In both formats, 0xFFFFFFFF in the IP position means "use sender IP".
var serverIP net.IP
if len(payload) >= 8 && payload[0] == 0x00 {
// Old format: minor version at [0:2], IP at [4:8].
ipSlice := payload[4:8]
if useSenderIP(ipSlice) {
serverIP = src.IP
} else {
ip := make(net.IP, 4)
copy(ip, ipSlice)
serverIP = ip
}
} else if len(payload) >= 4 {
// New format: IP at [0:4].
if useSenderIP(payload[:4]) {
serverIP = src.IP
} else {
ip := make(net.IP, 4)
copy(ip, payload[:4])
serverIP = ip
}
} else {
serverIP = src.IP
}
tcpAddr := fmt.Sprintf("%s:%d", serverIP.String(), tcpPort)
dbg("CA search reply", "searchID", searchID, "tcpAddr", tcpAddr, "src", src)
se.mu.Lock()
w, ok := se.waiters[searchID]
if ok {
delete(se.waiters, searchID)
}
se.mu.Unlock()
if ok {
dbg("CA search matched", "pv", w.pvName, "ioc", tcpAddr)
select {
case w.ch <- searchResult{addr: tcpAddr}:
default:
}
} else {
dbg("CA search reply unmatched", "searchID", searchID, "known_waiters", func() int {
se.mu.Lock()
defer se.mu.Unlock()
return len(se.waiters)
}())
}
}
}
// useSenderIP reports whether an IP field in a CA search reply means
// "use the sender's address". Both 0.0.0.0 (all-zeros) and 255.255.255.255
// (all-ones / 0xFFFFFFFF) are used by different EPICS Base versions.
func useSenderIP(b []byte) bool {
allFF := true
allZero := true
for _, v := range b {
if v != 0xFF {
allFF = false
}
if v != 0x00 {
allZero = false
}
}
return allFF || allZero
}
func isAllFF(b []byte) bool {
for _, v := range b {
if v != 0xFF {
return false
}
}
return true
}
// -------------------------------------------------------------------------- //
// Address list helpers //
// -------------------------------------------------------------------------- //
// resolveAddrs converts a list of "host" or "host:port" strings to
// "host:port" strings using defaultPort when no port is specified.
func resolveAddrs(addrs []string, defaultPort int) []string {
out := make([]string, 0, len(addrs))
for _, a := range addrs {
if a == "" {
continue
}
_, _, err := net.SplitHostPort(a)
if err != nil {
// No port specified; append default.
a = fmt.Sprintf("%s:%d", a, defaultPort)
}
out = append(out, a)
}
return out
}
// localBroadcastAddrs returns broadcast addresses for all local network
// interfaces, used when AutoAddrList is true.
func localBroadcastAddrs(port int) []string {
ifaces, err := net.Interfaces()
if err != nil {
return nil
}
var out []string
for _, iface := range ifaces {
if iface.Flags&net.FlagBroadcast == 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, a := range addrs {
ipNet, ok := a.(*net.IPNet)
if !ok {
continue
}
ip4 := ipNet.IP.To4()
if ip4 == nil {
continue
}
// Compute broadcast: IP | ^mask
mask := ipNet.Mask
bcast := make(net.IP, 4)
for i := range bcast {
bcast[i] = ip4[i] | ^mask[i]
}
out = append(out, fmt.Sprintf("%s:%d", bcast.String(), port))
}
}
return out
}
// -------------------------------------------------------------------------- //
// Minimal bytes.Reader-like for proto.DecodeHeader //
// -------------------------------------------------------------------------- //
type bytesReader struct {
b []byte
i int
}
func newBytesReader(b []byte) *bytesReader { return &bytesReader{b: b} }
func (r *bytesReader) Read(p []byte) (int, error) {
if r.i >= len(r.b) {
return 0, fmt.Errorf("EOF")
}
n := copy(p, r.b[r.i:])
r.i += n
return n, nil
}
// -------------------------------------------------------------------------- //
// searchAddrFromReply extracts server IP from a CA_PROTO_SEARCH response. //
// Exported for testability. //
// -------------------------------------------------------------------------- //
// EncodedSearchPair builds the VERSION + SEARCH UDP datagram pair for pvName
// with the given searchID. Exported for use in tests.
func EncodedSearchPair(pvName string, searchID uint32) []byte {
verHdr := proto.Header{
Command: proto.CmdVersion,
DataCount: proto.MinorVersion,
}
payload := proto.BuildStringPayload(pvName)
searchHdr := proto.Header{
Command: proto.CmdSearch,
DataType: proto.SearchReply,
DataCount: proto.MinorVersion,
Parameter1: searchID,
Parameter2: searchID,
}
searchMsg := proto.BuildMessage(searchHdr, payload)
out := make([]byte, 0, proto.HeaderSize+len(searchMsg))
out = append(out, verHdr.Bytes()...)
out = append(out, searchMsg...)
return out
}
// EncodeSearchReply builds a CA_PROTO_SEARCH UDP reply.
// serverIP may be nil to use 0xFFFFFFFF (sender IP). port is the CA TCP port.
func EncodeSearchReply(searchID uint32, serverIP net.IP, port int) []byte {
var payload []byte
if serverIP != nil {
payload = make([]byte, 8)
copy(payload[:4], serverIP.To4())
binary.BigEndian.PutUint32(payload[4:], 0)
} else {
payload = []byte{0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00}
}
h := proto.Header{
Command: proto.CmdSearch,
DataType: uint16(port),
Parameter1: proto.MinorVersion,
Parameter2: searchID,
}
return proto.BuildMessage(h, payload)
}