This commit is contained in:
Martino Ferrari
2026-06-24 01:39:15 +02:00
parent 11120bedca
commit c0f7e662be
76 changed files with 4368 additions and 210 deletions
+2 -2
View File
@@ -264,8 +264,8 @@ type CtrlInfo struct {
Access uint32 // proto.AccessRead | proto.AccessWrite bitmask
Double *proto.CtrlDouble // non-nil for DBFDouble / DBFFloat
Long *proto.CtrlLong // non-nil for DBFLong / DBFShort / DBFChar
Enum *proto.CtrlEnum // non-nil for DBFEnum
Long *proto.CtrlLong // non-nil for DBFLong / DBFShort / DBFChar
Enum *proto.CtrlEnum // non-nil for DBFEnum
Str *proto.CtrlString // non-nil for DBFString
}
+1 -1
View File
@@ -7,8 +7,8 @@ import (
"time"
"github.com/uopi/goca"
"github.com/uopi/goca/testca"
"github.com/uopi/goca/proto"
"github.com/uopi/goca/testca"
)
// newTestClient creates a Client pointing only at the fake server's addresses.
+10 -10
View File
@@ -57,15 +57,15 @@ type chanState struct {
cid uint32
pvName string
mu sync.RWMutex
sid uint32 // server-assigned channel ID (0 until CREATE_CHAN reply)
dbfType int // native DBF field type
count uint32 // element count
access uint32 // AccessRead | AccessWrite bitmask
gotChan bool // CREATE_CHAN reply received for current connection
gotAccess bool // ACCESS_RIGHTS received for current connection
readyC chan struct{} // closed once both CREATE_CHAN and ACCESS_RIGHTS received; replaced on reconnect
monitors []*monState // active subscriptions
mu sync.RWMutex
sid uint32 // server-assigned channel ID (0 until CREATE_CHAN reply)
dbfType int // native DBF field type
count uint32 // element count
access uint32 // AccessRead | AccessWrite bitmask
gotChan bool // CREATE_CHAN reply received for current connection
gotAccess bool // ACCESS_RIGHTS received for current connection
readyC chan struct{} // closed once both CREATE_CHAN and ACCESS_RIGHTS received; replaced on reconnect
monitors []*monState // active subscriptions
}
func newChanState(cid uint32, pvName string) *chanState {
@@ -151,7 +151,7 @@ type circuit struct {
bySubID map[uint32]*monState // subID → monState (fast event dispatch)
byIOID map[uint32]chan reply // ioid → GET/PUT callback (circuit-wide)
writeQ chan []byte // serialised outbound message queue
writeQ chan []byte // serialised outbound message queue
seq atomic.Uint32 // shared ID sequence (cid, ioid, subID)
}
+109
View File
@@ -0,0 +1,109 @@
package ca
import (
"context"
"net"
"testing"
"github.com/uopi/goca/proto"
)
// ---- encodePut: every DBF branch + error paths -----------------------
func TestEncodePut(t *testing.T) {
cases := []struct {
name string
dbf int
v any
wantDBR uint16
}{
{"double", proto.DBFDouble, 1.5, proto.DBRDouble},
{"float", proto.DBFFloat, float32(2.0), proto.DBRDouble},
{"long", proto.DBFLong, int(3), proto.DBRLong},
{"short", proto.DBFShort, int16(4), proto.DBRShort},
{"char", proto.DBFChar, int(5), proto.DBRShort},
{"enum", proto.DBFEnum, int(1), proto.DBRShort},
{"string", proto.DBFString, "hi", proto.DBRString},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
dbr, payload, err := encodePut(c.dbf, c.v)
if err != nil {
t.Fatalf("encodePut: %v", err)
}
if dbr != c.wantDBR {
t.Errorf("dbr = %d, want %d", dbr, c.wantDBR)
}
if len(payload) == 0 {
t.Error("empty payload")
}
})
}
// Coercion failures propagate.
if _, _, err := encodePut(proto.DBFDouble, "nope"); err == nil {
t.Error("double from string: want error")
}
if _, _, err := encodePut(proto.DBFLong, "nope"); err == nil {
t.Error("long from string: want error")
}
if _, _, err := encodePut(proto.DBFShort, "nope"); err == nil {
t.Error("short from string: want error")
}
// Unsupported field type.
if _, _, err := encodePut(9999, 1); err == nil {
t.Error("unsupported DBF: want error")
}
}
// ---- NewClient: error path + default name/host -----------------------
func TestNewClientNoAddrs(t *testing.T) {
if _, err := NewClient(context.Background(), Config{AutoAddrList: false}); err == nil {
t.Error("NewClient with no addresses: want error")
}
}
func TestNewClientDefaultsNameHost(t *testing.T) {
// AddrList present but ClientName/HostName empty → default branches run.
cli, err := NewClient(context.Background(), Config{
AddrList: []string{"127.0.0.1:5064"},
AutoAddrList: false,
})
if err != nil {
t.Fatalf("NewClient: %v", err)
}
defer cli.Close()
if cli.cfg.ClientName == "" {
t.Error("ClientName should be defaulted")
}
}
// ---- EncodeSearchReply with an explicit server IP --------------------
func TestEncodeSearchReplyExplicitIP(t *testing.T) {
pkt := EncodeSearchReply(7, net.ParseIP("10.1.2.3"), 5064)
h, _, err := proto.DecodeHeader(newBytesReader(pkt))
if err != nil {
t.Fatalf("DecodeHeader: %v", err)
}
if h.Command != proto.CmdSearch {
t.Errorf("command = %d, want CmdSearch", h.Command)
}
}
// ---- parseReply robustness -------------------------------------------
func TestParseReplyTruncated(t *testing.T) {
se := &searchEngine{waiters: make(map[uint32]*searchWaiter)}
src := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 5064}
// Must not panic on a too-short datagram.
se.parseReply([]byte{0x00, 0x01}, src)
}
func TestParseReplyUnknownSearchID(t *testing.T) {
se := &searchEngine{waiters: make(map[uint32]*searchWaiter)}
src := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 5064}
// Valid reply but no waiter is registered for this search ID → dropped.
se.parseReply(EncodeSearchReply(12345, nil, 5064), src)
}
+47
View File
@@ -0,0 +1,47 @@
package ca_test
import (
"context"
"testing"
"time"
)
// TestGetCtrlNotFound covers the resolve-failure branch of GetCtrl.
func TestGetCtrlNotFound(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
if _, err := cli.GetCtrl(ctx, "NO:SUCH:PV"); err == nil {
t.Fatal("GetCtrl on missing PV: want error")
}
}
// TestPutNotFound covers the resolve-failure branch of Put.
func TestPutNotFound(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
if err := cli.Put(ctx, "NO:SUCH:PV", 1.0); err == nil {
t.Fatal("Put on missing PV: want error")
}
}
// TestPutUncoercibleValue covers the encodePut-failure branch of Put: the PV
// resolves but the supplied value cannot be coerced to the native type.
func TestPutUncoercibleValue(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := cli.Put(ctx, "TEST:DOUBLE", []int{1, 2, 3}); err == nil {
t.Fatal("Put with uncoercible value: want error")
}
}
+104
View File
@@ -0,0 +1,104 @@
package ca_test
import (
"context"
"math"
"testing"
"time"
"github.com/uopi/goca/proto"
"github.com/uopi/goca/testca"
)
// TestGetDisconnectMidRequest covers the "circuit disconnected during GET" path
// in conn.go: the server drops the connection while a READ_NOTIFY is in flight,
// so the in-flight reply channel is closed by the reconnect loop and the waiter
// unblocks with ok=false.
func TestGetDisconnectMidRequest(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// First GET establishes the channel/circuit successfully.
if _, err := cli.Get(ctx, "TEST:DOUBLE"); err != nil {
t.Fatalf("initial Get: %v", err)
}
// Arm a mid-request disconnect for the next READ_NOTIFY.
srv.SetGetFault(testca.GetFaultDisconnect)
if _, err := cli.Get(ctx, "TEST:DOUBLE"); err == nil {
t.Fatal("Get with mid-request disconnect: want error")
}
}
// TestGetCorruptReply covers the "failed to decode GET reply" path: the server
// answers READ_NOTIFY with a payload too short for DecodeTimeValue.
func TestGetCorruptReply(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Establish the channel first so resolution succeeds.
if _, err := cli.Get(ctx, "TEST:DOUBLE"); err != nil {
t.Fatalf("initial Get: %v", err)
}
srv.SetGetFault(testca.GetFaultCorrupt)
if _, err := cli.Get(ctx, "TEST:DOUBLE"); err == nil {
t.Fatal("Get with corrupt reply: want error")
}
}
// TestServerDisconnectReconnects covers the CmdServerDisc dispatch branch and the
// reconnect loop: after an orderly server disconnect the client transparently
// reconnects, re-creates its channel, re-subscribes the monitor, and continues to
// receive updates.
func TestServerDisconnectReconnects(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
ch := make(chan proto.TimeValue, 16)
unsub, err := cli.Subscribe(ctx, "TEST:DOUBLE", ch)
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
defer unsub()
// Drain the initial value.
select {
case <-ch:
case <-ctx.Done():
t.Fatal("timeout waiting for initial value")
}
// Force an orderly disconnect; the client must reconnect on its own.
srv.Disconnect()
// After the reconnect settles, push a fresh value and expect to see it.
// The reconnect back-off is ~1s, so poll generously and re-arm the value
// until it is observed (the re-subscribe also delivers an initial value).
deadline := time.After(8 * time.Second)
tick := time.NewTicker(300 * time.Millisecond)
defer tick.Stop()
for {
select {
case tv := <-ch:
if math.Abs(tv.Double-88.8) < 1e-6 {
return // reconnect succeeded and the update flowed through
}
case <-tick.C:
_ = srv.SetValue("TEST:DOUBLE", 88.8)
case <-deadline:
t.Fatal("timeout waiting for post-reconnect update")
}
}
}
+57
View File
@@ -0,0 +1,57 @@
package ca_test
import (
"context"
"testing"
"time"
)
// TestGetCtrl exercises GetCtrl across the native DBF types served by the mock,
// covering NativeCtrlType dispatch and the DBR_CTRL_* decoders.
func TestGetCtrl(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
t.Run("double", func(t *testing.T) {
ci, err := cli.GetCtrl(ctx, "TEST:DOUBLE")
if err != nil {
t.Fatalf("GetCtrl: %v", err)
}
if ci.Double == nil {
t.Fatal("expected Double ctrl info")
}
})
t.Run("long", func(t *testing.T) {
ci, err := cli.GetCtrl(ctx, "TEST:LONG")
if err != nil {
t.Fatalf("GetCtrl: %v", err)
}
if ci.Long == nil {
t.Fatal("expected Long ctrl info")
}
})
t.Run("enum", func(t *testing.T) {
ci, err := cli.GetCtrl(ctx, "TEST:ENUM")
if err != nil {
t.Fatalf("GetCtrl: %v", err)
}
if ci.Enum == nil {
t.Fatal("expected Enum ctrl info")
}
})
t.Run("string", func(t *testing.T) {
ci, err := cli.GetCtrl(ctx, "TEST:STRING")
if err != nil {
t.Fatalf("GetCtrl: %v", err)
}
if ci.Str == nil {
t.Fatal("expected Str ctrl info")
}
})
}
+139
View File
@@ -0,0 +1,139 @@
package ca
import (
"testing"
"github.com/uopi/goca/proto"
)
// ---- value coercion helpers ------------------------------------------
func TestToFloat64(t *testing.T) {
cases := []struct {
in any
want float64
}{
{float64(1.5), 1.5},
{float32(2.5), 2.5},
{int(3), 3},
{int64(4), 4},
{int32(5), 5},
{int16(6), 6},
{uint64(7), 7},
{uint32(8), 8},
{true, 1},
{false, 0},
}
for _, c := range cases {
got, err := toFloat64(c.in)
if err != nil || got != c.want {
t.Errorf("toFloat64(%v) = %v, %v; want %v", c.in, got, err, c.want)
}
}
if _, err := toFloat64("nope"); err == nil {
t.Error("toFloat64(string): want error")
}
}
func TestToInt32(t *testing.T) {
cases := []struct {
in any
want int32
}{
{int(3), 3},
{int64(4), 4},
{int32(5), 5},
{int16(6), 6},
{float64(7.9), 7},
{float32(8.9), 8},
{true, 1},
{false, 0},
}
for _, c := range cases {
got, err := toInt32(c.in)
if err != nil || got != c.want {
t.Errorf("toInt32(%v) = %v, %v; want %v", c.in, got, err, c.want)
}
}
if _, err := toInt32("nope"); err == nil {
t.Error("toInt32(string): want error")
}
}
func TestToString(t *testing.T) {
if s, _ := toString("hi"); s != "hi" {
t.Errorf("toString(string) = %q", s)
}
if s, _ := toString([]byte("bytes")); s != "bytes" {
t.Errorf("toString([]byte) = %q", s)
}
if s, _ := toString(42); s != "42" {
t.Errorf("toString(int) = %q, want 42", s)
}
}
// ---- ConfigFromEnv ---------------------------------------------------
func TestConfigFromEnv(t *testing.T) {
t.Setenv("EPICS_CA_ADDR_LIST", "1.2.3.4 5.6.7.8")
t.Setenv("EPICS_CA_AUTO_ADDR_LIST", "no")
cfg := ConfigFromEnv()
if len(cfg.AddrList) != 2 || cfg.AddrList[0] != "1.2.3.4" {
t.Errorf("AddrList = %v", cfg.AddrList)
}
if cfg.AutoAddrList {
t.Error("AutoAddrList should be false when env = no")
}
if cfg.ClientName == "" {
t.Error("ClientName should be populated")
}
t.Setenv("EPICS_CA_ADDR_LIST", "")
t.Setenv("EPICS_CA_AUTO_ADDR_LIST", "yes")
cfg = ConfigFromEnv()
if !cfg.AutoAddrList {
t.Error("AutoAddrList should default to true")
}
if len(cfg.AddrList) != 0 {
t.Errorf("AddrList should be empty, got %v", cfg.AddrList)
}
}
// ---- address helpers -------------------------------------------------
func TestResolveAddrs(t *testing.T) {
got := resolveAddrs([]string{"host", "host2:9999", ""}, 5064)
if len(got) != 2 || got[0] != "host:5064" || got[1] != "host2:9999" {
t.Errorf("resolveAddrs = %v", got)
}
}
func TestLocalBroadcastAddrs(t *testing.T) {
// Should not panic; the result is environment-dependent.
_ = localBroadcastAddrs(5064)
}
// ---- isAllFF ---------------------------------------------------------
func TestIsAllFF(t *testing.T) {
if !isAllFF([]byte{0xFF, 0xFF, 0xFF}) {
t.Error("all-FF should be true")
}
if isAllFF([]byte{0xFF, 0x00}) {
t.Error("mixed should be false")
}
}
// ---- EncodedSearchPair -----------------------------------------------
func TestEncodedSearchPair(t *testing.T) {
pkt := EncodedSearchPair("TEST:PV", 7)
// First header must be a VERSION message.
h, _, err := proto.DecodeHeader(newBytesReader(pkt))
if err != nil {
t.Fatalf("DecodeHeader: %v", err)
}
if h.Command != proto.CmdVersion {
t.Errorf("first command = %d, want CmdVersion", h.Command)
}
}
+13 -13
View File
@@ -218,19 +218,19 @@ func DecodeTimeValue(dbrType uint16, count uint32, payload []byte) (TimeValue, b
// CtrlDouble is the decoded form of a DBR_CTRL_DOUBLE payload.
type CtrlDouble struct {
Status int16
Severity AlarmSeverity
Precision int16
Units string
UpperDispLimit float64
LowerDispLimit float64
UpperAlarmLimit float64
UpperWarnLimit float64
LowerWarnLimit float64
LowerAlarmLimit float64
UpperCtrlLimit float64
LowerCtrlLimit float64
Value float64
Status int16
Severity AlarmSeverity
Precision int16
Units string
UpperDispLimit float64
LowerDispLimit float64
UpperAlarmLimit float64
UpperWarnLimit float64
LowerWarnLimit float64
LowerAlarmLimit float64
UpperCtrlLimit float64
LowerCtrlLimit float64
Value float64
}
// DecodeCtrlDouble decodes a DBR_CTRL_DOUBLE payload (minimum 88 bytes).
+2 -2
View File
@@ -31,8 +31,8 @@ const DefaultPort = 5064
// Search reply data_type values.
const (
SearchReply = 10 // DO_REPLY: ask server to respond
SearchNoReply = 5 // DONT_REPLY: suppress response (used by repeater)
SearchReply = 10 // DO_REPLY: ask server to respond
SearchNoReply = 5 // DONT_REPLY: suppress response (used by repeater)
)
// AccessRights bitmask values (parameter2 of CmdAccessRights message).
+163
View File
@@ -0,0 +1,163 @@
package proto_test
import (
"bytes"
"encoding/binary"
"math"
"testing"
"github.com/uopi/goca/proto"
)
// -------------------------------------------------------------------------- //
// DBR_TIME_* variants not covered by proto_test.go //
// -------------------------------------------------------------------------- //
func TestDecodeTimeFloat(t *testing.T) {
hdr := buildTimeHeader(0, 0, 0, 0)
val := make([]byte, 4)
binary.BigEndian.PutUint32(val, math.Float32bits(2.5))
tv, ok := proto.DecodeTimeValue(proto.DBRTimeFloat, 1, append(hdr, val...))
if !ok || math.Abs(float64(tv.Float)-2.5) > 1e-6 {
t.Errorf("float: ok=%v Float=%g", ok, tv.Float)
}
}
func TestDecodeTimeShort(t *testing.T) {
hdr := buildTimeHeader(0, 0, 0, 0)
val := []byte{0x00, 0x00, 0xFF, 0xD6} // 2-byte pad + int16(-42)
tv, ok := proto.DecodeTimeValue(proto.DBRTimeShort, 1, append(hdr, val...))
if !ok || tv.Short != -42 {
t.Errorf("short: ok=%v Short=%d", ok, tv.Short)
}
}
func TestDecodeTimeChar(t *testing.T) {
hdr := buildTimeHeader(0, 0, 0, 0)
val := []byte{0x00, 0x00, 0x00, 0x41} // pad0 + pad1 + value 'A'
tv, ok := proto.DecodeTimeValue(proto.DBRTimeChar, 1, append(hdr, val...))
if !ok || tv.Char != 0x41 {
t.Errorf("char: ok=%v Char=%d", ok, tv.Char)
}
}
func TestDecodeTimeUnknownType(t *testing.T) {
hdr := buildTimeHeader(0, 0, 0, 0)
if _, ok := proto.DecodeTimeValue(9999, 1, append(hdr, make([]byte, 16)...)); ok {
t.Error("unknown DBR type should return false")
}
}
// -------------------------------------------------------------------------- //
// DBR_CTRL_LONG / DBR_CTRL_STRING //
// -------------------------------------------------------------------------- //
func TestDecodeCtrlLong(t *testing.T) {
p := make([]byte, 48)
binary.BigEndian.PutUint16(p[2:], 2) // severity = Major
copy(p[4:], "counts")
i32 := func(off int, v int32) { binary.BigEndian.PutUint32(p[off:], uint32(v)) }
i32(12, 1000) // upper_disp
i32(16, -10) // lower_disp
i32(44, 777) // value
cl, ok := proto.DecodeCtrlLong(p)
if !ok {
t.Fatal("DecodeCtrlLong returned false")
}
if cl.Units != "counts" || cl.Value != 777 || cl.UpperDispLimit != 1000 || cl.LowerDispLimit != -10 {
t.Errorf("got %+v", cl)
}
if cl.Severity != proto.SeverityMajor {
t.Errorf("severity = %v, want Major", cl.Severity)
}
if _, ok := proto.DecodeCtrlLong(make([]byte, 10)); ok {
t.Error("short CtrlLong payload should return false")
}
}
func TestDecodeCtrlString(t *testing.T) {
p := make([]byte, 44)
binary.BigEndian.PutUint16(p[2:], 1) // severity = Minor
copy(p[4:], "READY")
cs, ok := proto.DecodeCtrlString(p)
if !ok {
t.Fatal("DecodeCtrlString returned false")
}
if cs.Value != "READY" || cs.Severity != proto.SeverityMinor {
t.Errorf("got %+v", cs)
}
if _, ok := proto.DecodeCtrlString(make([]byte, 10)); ok {
t.Error("short CtrlString payload should return false")
}
}
// -------------------------------------------------------------------------- //
// EncodeShort //
// -------------------------------------------------------------------------- //
func TestEncodeShort(t *testing.T) {
b := proto.EncodeShort(-7)
if len(b) != 8 {
t.Fatalf("len = %d, want 8", len(b))
}
if got := int16(binary.BigEndian.Uint16(b)); got != -7 {
t.Errorf("decoded = %d, want -7", got)
}
}
// -------------------------------------------------------------------------- //
// NativeCtrlType //
// -------------------------------------------------------------------------- //
func TestNativeCtrlType(t *testing.T) {
cases := []struct {
dbf int
want uint16
}{
{proto.DBFDouble, proto.DBRCtrlDouble},
{proto.DBFFloat, proto.DBRCtrlDouble},
{proto.DBFLong, proto.DBRCtrlLong},
{proto.DBFShort, proto.DBRCtrlLong},
{proto.DBFChar, proto.DBRCtrlLong},
{proto.DBFEnum, proto.DBRCtrlEnum},
{proto.DBFString, proto.DBRCtrlString},
{9999, proto.DBRCtrlDouble}, // default
}
for _, c := range cases {
if got := proto.NativeCtrlType(c.dbf); got != c.want {
t.Errorf("NativeCtrlType(%d) = %d, want %d", c.dbf, got, c.want)
}
}
}
// -------------------------------------------------------------------------- //
// DecodeHeader: extended encoding + error path //
// -------------------------------------------------------------------------- //
func TestDecodeHeaderExtended(t *testing.T) {
raw := make([]byte, 24)
binary.BigEndian.PutUint16(raw[0:], proto.CmdReadNotify)
binary.BigEndian.PutUint16(raw[2:], 0xFFFF) // extended marker
binary.BigEndian.PutUint16(raw[6:], 0x0000)
binary.BigEndian.PutUint32(raw[16:], 70000) // real payload size
binary.BigEndian.PutUint32(raw[20:], 5) // real count
h, n, err := proto.DecodeHeader(bytes.NewReader(raw))
if err != nil {
t.Fatal(err)
}
if n != 24 {
t.Errorf("bytes read = %d, want 24", n)
}
if h.PayloadSize != 70000 || h.DataCount != 5 {
t.Errorf("extended header = %+v", h)
}
}
func TestDecodeHeaderTruncated(t *testing.T) {
if _, _, err := proto.DecodeHeader(bytes.NewReader([]byte{0x00, 0x01})); err == nil {
t.Error("truncated header: want error")
}
}
+1 -1
View File
@@ -102,7 +102,7 @@ func TestDecodeTimeDouble(t *testing.T) {
const want = 3.14159
hdr := buildTimeHeader(0, 0, sec, nsec)
pad := make([]byte, 4) // RISC pad
pad := make([]byte, 4) // RISC pad
val := make([]byte, 8)
binary.BigEndian.PutUint64(val, math.Float64bits(want))
payload := append(append(hdr, pad...), val...)
+68 -9
View File
@@ -29,13 +29,21 @@ type Server struct {
tcpLn net.Listener
udpCn *net.UDPConn
mu sync.RWMutex
pvs map[string]*serverPV
subs map[uint32]*serverSub // subID → sub
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
@@ -43,11 +51,11 @@ type serverPV struct {
}
type serverSub struct {
pvName string
subID uint32
cid uint32
sid uint32
conn net.Conn
pvName string
subID uint32
cid uint32
sid uint32
conn net.Conn
dbrType uint16
count uint32
}
@@ -93,6 +101,29 @@ func (s *Server) 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()
@@ -350,6 +381,31 @@ func (s *Server) handleConn(conn net.Conn) {
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()
@@ -591,7 +647,10 @@ func nullStr(b []byte) string {
// Minimal io.Reader for proto.DecodeHeader //
// -------------------------------------------------------------------------- //
type bytesReader struct{ b []byte; i int }
type bytesReader struct {
b []byte
i int
}
func newBytesReader(b []byte) *bytesReader { return &bytesReader{b: b} }