// Package testca provides an in-process fake CA server for use in tests. // It supports a small, fixed set of PVs and is compatible with the goca Client. package testca import ( "encoding/binary" "fmt" "io" "math" "net" "sync" "time" "github.com/uopi/goca/proto" ) // PVSpec describes one test PV hosted by the fake server. type PVSpec struct { Name string DBFType int // proto.DBF* constant Count uint32 // element count (1 for scalars) Value any // initial value Access uint32 // proto.AccessRead | proto.AccessWrite } // Server is an in-process fake CA server. It listens on a random TCP port and // an ephemeral UDP port. Use Addr() to get the addresses for client config. type Server struct { tcpLn net.Listener udpCn *net.UDPConn mu sync.RWMutex pvs map[string]*serverPV subs map[uint32]*serverSub // subID → sub getFault int // fault to inject on the next READ_NOTIFY done chan struct{} } // Fault modes for SetGetFault, used to exercise the client's error paths. const ( GetFaultNone = iota // serve replies normally GetFaultDisconnect // drop the connection mid-request GetFaultCorrupt // reply with an undecodable (too-short) payload ) type serverPV struct { spec PVSpec mu sync.RWMutex val any } type serverSub struct { pvName string subID uint32 cid uint32 sid uint32 conn net.Conn dbrType uint16 count uint32 } // New creates and starts a fake CA server hosting the given PVs. func New(pvs []PVSpec) (*Server, error) { tcpLn, err := net.Listen("tcp4", "127.0.0.1:0") if err != nil { return nil, fmt.Errorf("testca: tcp listen: %w", err) } udpCn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")}) if err != nil { tcpLn.Close() return nil, fmt.Errorf("testca: udp listen: %w", err) } s := &Server{ tcpLn: tcpLn, udpCn: udpCn, pvs: make(map[string]*serverPV, len(pvs)), subs: make(map[uint32]*serverSub), done: make(chan struct{}), } for _, spec := range pvs { s.pvs[spec.Name] = &serverPV{spec: spec, val: spec.Value} } go s.acceptLoop() go s.udpLoop() return s, nil } // TCPAddr returns the TCP "host:port" address clients should connect to. func (s *Server) TCPAddr() string { return s.tcpLn.Addr().String() } // UDPAddr returns the UDP "host:port" address clients should search against. func (s *Server) UDPAddr() string { return s.udpCn.LocalAddr().String() } // Close shuts down the server. func (s *Server) Close() { close(s.done) s.tcpLn.Close() s.udpCn.Close() } // SetGetFault arms a fault to be injected on the next READ_NOTIFY request. // It is reset to GetFaultNone after a single request is faulted. func (s *Server) SetGetFault(mode int) { s.mu.Lock() s.getFault = mode s.mu.Unlock() } // Disconnect pushes a SERVER_DISCONN message to every connected subscriber, // simulating an orderly server shutdown that forces clients to reconnect. func (s *Server) Disconnect() { msg := proto.BuildMessage(proto.Header{Command: proto.CmdServerDisc}, nil) seen := make(map[net.Conn]bool) s.mu.RLock() for _, sub := range s.subs { if sub.conn != nil && !seen[sub.conn] { seen[sub.conn] = true _, _ = sub.conn.Write(msg) } } s.mu.RUnlock() } // SetValue updates a PV's value and pushes a monitor event to all subscribers. func (s *Server) SetValue(pvName string, val any) error { s.mu.RLock() pv, ok := s.pvs[pvName] s.mu.RUnlock() if !ok { return fmt.Errorf("testca: unknown PV %q", pvName) } pv.mu.Lock() pv.val = val pv.mu.Unlock() // Push to all subscribers of this PV. s.mu.RLock() for _, sub := range s.subs { if sub.pvName == pvName { if msg := s.buildEventMsg(sub, pv); msg != nil { _, _ = sub.conn.Write(msg) } } } s.mu.RUnlock() return nil } // -------------------------------------------------------------------------- // // UDP search loop // // -------------------------------------------------------------------------- // func (s *Server) udpLoop() { buf := make([]byte, 65536) for { _ = s.udpCn.SetReadDeadline(time.Now().Add(200 * time.Millisecond)) n, src, err := s.udpCn.ReadFromUDP(buf) if err != nil { select { case <-s.done: return default: continue } } s.handleUDP(buf[:n], src) } } func (s *Server) handleUDP(data []byte, src *net.UDPAddr) { for len(data) >= proto.HeaderSize { hdr, n, err := proto.DecodeHeader(newBytesReader(data)) if err != nil { return } payEnd := n + int(hdr.PayloadSize) if payEnd > len(data) { return } payload := data[n:payEnd] data = data[payEnd:] if hdr.Command != proto.CmdSearch { continue } // Extract PV name from payload (null-terminated). pvName := nullStr(payload) s.mu.RLock() _, ok := s.pvs[pvName] s.mu.RUnlock() if !ok { continue } // Reply: data_type = TCP port, parameter2 = searchID. tcpPort := s.tcpLn.Addr().(*net.TCPAddr).Port // Use 0xFFFFFFFF IP to tell client to use sender's IP. reply := buildSearchReply(hdr.Parameter2, tcpPort) _, _ = s.udpCn.WriteToUDP(reply, src) } } func buildSearchReply(searchID uint32, port int) []byte { 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) } // -------------------------------------------------------------------------- // // TCP accept + per-connection handler // // -------------------------------------------------------------------------- // func (s *Server) acceptLoop() { for { conn, err := s.tcpLn.Accept() if err != nil { select { case <-s.done: return default: continue } } go s.handleConn(conn) } } func (s *Server) handleConn(conn net.Conn) { defer conn.Close() // Track CID → (pvName, SID). type chanInfo struct { pvName string sid uint32 } sidSeq := uint32(1000) channels := make(map[uint32]*chanInfo) // cid → info for { hdr, _, err := proto.DecodeHeader(conn) if err != nil { return } var payload []byte if hdr.PayloadSize > 0 { payload = make([]byte, hdr.PayloadSize) if _, err = io.ReadFull(conn, payload); err != nil { return } } switch hdr.Command { case proto.CmdVersion: // Respond with VERSION. reply := proto.BuildMessage(proto.Header{ Command: proto.CmdVersion, DataCount: proto.MinorVersion, }, nil) conn.Write(reply) case proto.CmdHostName, proto.CmdClientName: // Ignore client identity. case proto.CmdCreateChan: cid := hdr.Parameter1 pvName := nullStr(payload) s.mu.RLock() pv, ok := s.pvs[pvName] s.mu.RUnlock() if !ok { // CREATE_FAIL fail := proto.BuildMessage(proto.Header{ Command: proto.CmdCreateFail, Parameter1: cid, }, nil) conn.Write(fail) continue } sid := sidSeq sidSeq++ channels[cid] = &chanInfo{pvName: pvName, sid: sid} // CREATE_CHAN reply: DataType=dbfType, DataCount=count, p1=cid, p2=sid. pv.mu.RLock() dbfType := pv.spec.DBFType count := pv.spec.Count pv.mu.RUnlock() reply := proto.BuildMessage(proto.Header{ Command: proto.CmdCreateChan, DataType: uint16(dbfType), DataCount: count, Parameter1: cid, Parameter2: sid, }, nil) conn.Write(reply) // ACCESS_RIGHTS: p1=cid, p2=access. access := pv.spec.Access if access == 0 { access = proto.AccessRead | proto.AccessWrite } ar := proto.BuildMessage(proto.Header{ Command: proto.CmdAccessRights, Parameter1: cid, Parameter2: access, }, nil) conn.Write(ar) case proto.CmdEventAdd: // p1=SID (channel), p2=subscriptionID (client-assigned). sid := hdr.Parameter1 subID := hdr.Parameter2 // Find channel by SID. var pvName string for _, info := range channels { if info.sid == sid { pvName = info.pvName break } } if pvName == "" { continue } s.mu.Lock() sub := &serverSub{ pvName: pvName, subID: subID, sid: sid, conn: conn, dbrType: hdr.DataType, count: hdr.DataCount, } s.subs[subID] = sub s.mu.Unlock() // Send initial value. s.mu.RLock() pv := s.pvs[pvName] s.mu.RUnlock() if msg := s.buildEventMsg(sub, pv); msg != nil { conn.Write(msg) } case proto.CmdEventCancel: // p1=SID, p2=subscriptionID. subID := hdr.Parameter2 s.mu.Lock() delete(s.subs, subID) s.mu.Unlock() case proto.CmdReadNotify: // p1=SID, p2=ioid (per CA spec). sid := hdr.Parameter1 ioid := hdr.Parameter2 var pvName string var cid uint32 for c, info := range channels { if info.sid == sid { pvName = info.pvName cid = c break } } if pvName == "" { continue } // Fault injection: consume a one-shot armed fault, if any. s.mu.Lock() fault := s.getFault s.getFault = GetFaultNone s.mu.Unlock() switch fault { case GetFaultDisconnect: // Drop the connection mid-request: the client's in-flight GET // unblocks with ok=false ("circuit disconnected during GET"). conn.Close() return case GetFaultCorrupt: // Reply with a too-short payload so DecodeTimeValue fails // ("failed to decode GET reply"). reply := proto.BuildMessage(proto.Header{ Command: proto.CmdReadNotify, DataType: hdr.DataType, DataCount: hdr.DataCount, Parameter1: cid, Parameter2: ioid, }, []byte{0, 0, 0, 0}) conn.Write(reply) continue } s.mu.RLock() pv := s.pvs[pvName] s.mu.RUnlock() pv.mu.RLock() val := pv.val pv.mu.RUnlock() var replyPayload []byte switch hdr.DataType { case proto.DBRCtrlDouble, proto.DBRCtrlLong, proto.DBRCtrlEnum, proto.DBRCtrlString, proto.DBRCtrlFloat, proto.DBRCtrlShort, proto.DBRCtrlChar: replyPayload = s.encodeCtrlValue(hdr.DataType, pv) default: replyPayload = s.encodeTimeValue(hdr.DataType, hdr.DataCount, val) } reply := proto.BuildMessage(proto.Header{ Command: proto.CmdReadNotify, DataType: hdr.DataType, DataCount: hdr.DataCount, Parameter1: cid, // echo client channel ID (matches real EPICS IOC) Parameter2: ioid, // echo ioid so client can match the reply }, replyPayload) conn.Write(reply) case proto.CmdWrite: // p1=SID, p2=0. sid := hdr.Parameter1 var pvName string for _, info := range channels { if info.sid == sid { pvName = info.pvName break } } if pvName == "" { continue } s.mu.RLock() pv := s.pvs[pvName] s.mu.RUnlock() val := decodeWritePayload(hdr.DataType, payload) if val == nil { continue } pv.mu.Lock() pv.val = val pv.mu.Unlock() // Push update to all subscribers. s.mu.RLock() for _, sub := range s.subs { if sub.pvName == pvName { if msg := s.buildEventMsg(sub, pv); msg != nil { sub.conn.Write(msg) } } } s.mu.RUnlock() case proto.CmdEcho: // No response needed (server echoes are optional). } } } // -------------------------------------------------------------------------- // // Value encoding helpers // // -------------------------------------------------------------------------- // func (s *Server) buildEventMsg(sub *serverSub, pv *serverPV) []byte { pv.mu.RLock() val := pv.val pv.mu.RUnlock() payload := s.encodeTimeValue(sub.dbrType, sub.count, val) if payload == nil { return nil } return proto.BuildMessage(proto.Header{ Command: proto.CmdEventAdd, DataType: sub.dbrType, DataCount: sub.count, Parameter1: 1, // ECA_NORMAL Parameter2: sub.subID, }, payload) } // encodeTimeValue builds a DBR_TIME_* payload for val. func (s *Server) encodeTimeValue(dbrType uint16, _ uint32, val any) []byte { now := time.Now().UTC() sec := uint32(now.Unix() - 631152000) // EPICS epoch offset nsec := uint32(now.Nanosecond()) hdr := make([]byte, 12) // status=0, severity=0 binary.BigEndian.PutUint32(hdr[4:], sec) binary.BigEndian.PutUint32(hdr[8:], nsec) var body []byte switch dbrType { case proto.DBRTimeDouble: body = make([]byte, 12) // 4 pad + 8 value f := toF64(val) binary.BigEndian.PutUint64(body[4:], math.Float64bits(f)) case proto.DBRTimeFloat: body = make([]byte, 4) binary.BigEndian.PutUint32(body, math.Float32bits(float32(toF64(val)))) case proto.DBRTimeLong: body = make([]byte, 4) binary.BigEndian.PutUint32(body, uint32(int32(toF64(val)))) case proto.DBRTimeShort, proto.DBRTimeEnum: body = make([]byte, 4) // 2-byte RISC pad + 2-byte value binary.BigEndian.PutUint16(body[2:], uint16(int16(toF64(val)))) case proto.DBRTimeChar: body = make([]byte, 4) // RISC_pad0[0:2] + RISC_pad1[2] + value[3] body[3] = byte(uint8(toF64(val))) case proto.DBRTimeString: body = make([]byte, 40) if str, ok := val.(string); ok { copy(body, str) } default: return nil } return append(hdr, body...) } // encodeCtrlValue builds a DBR_CTRL_* payload for a GET reply. // The fake server returns minimal but structurally correct payloads so that // the client-side decode functions succeed. func (s *Server) encodeCtrlValue(dbrType uint16, pv *serverPV) []byte { pv.mu.RLock() val := pv.val access := pv.spec.Access pv.mu.RUnlock() switch dbrType { case proto.DBRCtrlDouble: // 88 bytes p := make([]byte, 88) if access == proto.AccessRead { // status=0, severity=0 (read-only signalled via ACCESS_RIGHTS, not here) } // units at [8:16] (empty string = "") // value at [80:88] binary.BigEndian.PutUint64(p[80:], math.Float64bits(toF64(val))) return p case proto.DBRCtrlFloat, proto.DBRCtrlShort, proto.DBRCtrlChar: // map to Long-style // Treat as CtrlLong (48 bytes); caller asked for these types but our // NativeCtrlType maps them to Double/Long anyway. Return a valid stub. p := make([]byte, 48) binary.BigEndian.PutUint32(p[44:], uint32(int32(toF64(val)))) return p case proto.DBRCtrlLong: // 48 bytes p := make([]byte, 48) binary.BigEndian.PutUint32(p[44:], uint32(int32(toF64(val)))) return p case proto.DBRCtrlEnum: // 424 bytes p := make([]byte, 424) // no_str at [4:6] = 0 (no enum strings in fake server) binary.BigEndian.PutUint16(p[422:], uint16(int16(toF64(val)))) return p case proto.DBRCtrlString: // 44 bytes: status(2)+severity(2)+value[40] p := make([]byte, 44) if str, ok := val.(string); ok { copy(p[4:], str) } return p default: return nil } } func decodeWritePayload(dbrType uint16, payload []byte) any { switch dbrType { case proto.DBRDouble: if len(payload) < 8 { return nil } return math.Float64frombits(binary.BigEndian.Uint64(payload)) case proto.DBRLong: if len(payload) < 4 { return nil } return int32(binary.BigEndian.Uint32(payload)) case proto.DBRShort, proto.DBREnum: if len(payload) < 2 { return nil } return int16(binary.BigEndian.Uint16(payload)) case proto.DBRString: return nullStr(payload) default: return nil } } func toF64(v any) float64 { switch x := v.(type) { case float64: return x case float32: return float64(x) case int: return float64(x) case int32: return float64(x) case int16: return float64(x) case int64: return float64(x) default: return 0 } } func nullStr(b []byte) string { for i, c := range b { if c == 0 { return string(b[:i]) } } return string(b) } // -------------------------------------------------------------------------- // // Minimal io.Reader 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, io.EOF } n := copy(p, r.b[r.i:]) r.i += n return n, nil }