The DCU-1 client opened a connection per command and closed it again, "mirroring the gs232/pst/rotgenius idiom". That idiom is right for the UDP backends beside it and wrong over TCP to an embedded serial server, which is what an RT-21's Ethernet option is: the heading is polled every 500 ms while the antenna turns, GoTo sends two commands (AP1 then AM1), Stop sends two more — each its own connect and close. Modules of that class commonly accept a SINGLE session and need a moment to release it, so the churn on its own looks like a controller ignoring half of what it is told. The socket is now kept between calls, one mutex serialises every exchange — which also stops the poll and an operator command from holding two sessions at once — and a write or read error drops it so the next call redials. One retry after a redial, because a kept socket's first write succeeds long after the far end has gone. Serial keeps open-per-call: a COM port has one owner, and holding it would lock out the controller's own software. Keeping a session only helps if the client survives the call, and dcu1Client built a fresh one every time, so it is cached per controller identity. Two rotors on the same box share one client, which is the point when one session is all there is. SaveRotators drops the cache: a client left over from the previous host would hold the very session its replacement needs. Three tests against a fake controller that accepts one session at a time: four commands share one session, a dropped session is redialled exactly once, and a failed dial leaves nothing behind.
163 lines
4.2 KiB
Go
163 lines
4.2 KiB
Go
package dcu1
|
|
|
|
import (
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fakeRT21 is a controller that accepts ONE session at a time and counts how
|
|
// many it was asked for, which is the thing under test.
|
|
type fakeRT21 struct {
|
|
ln net.Listener
|
|
|
|
mu sync.Mutex
|
|
sessions int
|
|
cmds []string
|
|
}
|
|
|
|
func newFakeRT21(t *testing.T) *fakeRT21 {
|
|
t.Helper()
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
f := &fakeRT21{ln: ln}
|
|
go f.serve()
|
|
t.Cleanup(func() { _ = ln.Close() })
|
|
return f
|
|
}
|
|
|
|
func (f *fakeRT21) serve() {
|
|
for {
|
|
conn, err := f.ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
f.mu.Lock()
|
|
f.sessions++
|
|
f.mu.Unlock()
|
|
// Served one at a time, on purpose: a second caller waits in the accept
|
|
// queue rather than being talked to, which is how these modules behave.
|
|
f.handle(conn)
|
|
}
|
|
}
|
|
|
|
func (f *fakeRT21) handle(conn net.Conn) {
|
|
defer conn.Close()
|
|
buf := make([]byte, 64)
|
|
for {
|
|
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
|
n, err := conn.Read(buf)
|
|
if n > 0 {
|
|
for _, cmd := range strings.Split(string(buf[:n]), ";") {
|
|
if cmd = strings.TrimSpace(cmd); cmd == "" {
|
|
continue
|
|
}
|
|
f.mu.Lock()
|
|
f.cmds = append(f.cmds, cmd)
|
|
f.mu.Unlock()
|
|
if cmd == "AI1" {
|
|
_, _ = conn.Write([]byte(";123"))
|
|
}
|
|
}
|
|
}
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (f *fakeRT21) port() int {
|
|
return f.ln.Addr().(*net.TCPAddr).Port
|
|
}
|
|
|
|
func (f *fakeRT21) seen() (int, []string) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.sessions, append([]string(nil), f.cmds...)
|
|
}
|
|
|
|
// One session for the whole conversation.
|
|
//
|
|
// It used to be one per command: the heading is polled twice a second while the
|
|
// antenna turns and GoTo sends two commands (AP1 then AM1), each with its own
|
|
// connect and close. An RT-21's Ethernet option — like most embedded serial
|
|
// servers — commonly accepts a single session and needs a moment to release it,
|
|
// so the churn alone looked like a controller ignoring half of what it was told.
|
|
func TestOneSessionServesEveryCommand(t *testing.T) {
|
|
f := newFakeRT21(t)
|
|
c := New("127.0.0.1", f.port())
|
|
defer c.Close()
|
|
|
|
if az, _, err := c.Heading(); err != nil || az != 123 {
|
|
t.Fatalf("Heading() = %d, %v; want 123", az, err)
|
|
}
|
|
if err := c.GoTo(240); err != nil {
|
|
t.Fatalf("GoTo: %v", err)
|
|
}
|
|
if az, _, err := c.Heading(); err != nil || az != 123 {
|
|
t.Fatalf("second Heading() = %d, %v", az, err)
|
|
}
|
|
|
|
sessions, cmds := f.seen()
|
|
if sessions != 1 {
|
|
t.Errorf("the controller was asked for %d sessions; four commands must share one", sessions)
|
|
}
|
|
want := []string{"AI1", "AP1240", "AM1", "AI1"}
|
|
if strings.Join(cmds, ",") != strings.Join(want, ",") {
|
|
t.Errorf("commands %v, want %v", cmds, want)
|
|
}
|
|
}
|
|
|
|
// A session the controller has dropped is redialled, and the command that found
|
|
// it dead is retried rather than reported as a failure — a kept socket's first
|
|
// write succeeds long after the far end has gone.
|
|
func TestADroppedSessionIsRedialled(t *testing.T) {
|
|
f := newFakeRT21(t)
|
|
c := New("127.0.0.1", f.port())
|
|
defer c.Close()
|
|
|
|
if _, _, err := c.Heading(); err != nil {
|
|
t.Fatalf("first Heading: %v", err)
|
|
}
|
|
// The controller power-cycles: close our end the way a dropped session
|
|
// leaves it, then ask again.
|
|
c.mu.Lock()
|
|
if c.conn != nil {
|
|
_ = c.conn.Close() // closed underneath, but still held — a half-dead socket
|
|
}
|
|
c.mu.Unlock()
|
|
|
|
if az, _, err := c.Heading(); err != nil || az != 123 {
|
|
t.Fatalf("Heading after the session dropped = %d, %v; want 123", az, err)
|
|
}
|
|
if sessions, _ := f.seen(); sessions != 2 {
|
|
t.Errorf("%d sessions; the dropped one should have been redialled exactly once", sessions)
|
|
}
|
|
}
|
|
|
|
// Nothing is kept open for a controller that is not there, and the error names
|
|
// the address so it can be checked.
|
|
func TestADeadControllerReportsWhereItLooked(t *testing.T) {
|
|
// Port 1 on loopback: nothing listens, and the refusal is immediate.
|
|
c := New("127.0.0.1", 1)
|
|
defer c.Close()
|
|
_, _, err := c.Heading()
|
|
if err == nil {
|
|
t.Fatal("no error from a controller that is not there")
|
|
}
|
|
if !strings.Contains(err.Error(), "127.0.0.1:"+strconv.Itoa(1)) {
|
|
t.Errorf("error %q does not name the address it tried", err)
|
|
}
|
|
c.mu.Lock()
|
|
held := c.conn != nil
|
|
c.mu.Unlock()
|
|
if held {
|
|
t.Error("a failed dial left a connection behind")
|
|
}
|
|
}
|