feat(rigctld): say so when another program owns the CAT port

Listen() succeeding is not the same as being reachable. OpsLog binds 0.0.0.0,
and Windows lets a second program bind the SAME port on the specific address
127.0.0.1. Connections to localhost then go to the more specific listener, so
every client reaches the other program while ours sits there having logged
"sharing CAT on port 4532" and never seeing a single connection.

Found with Nexus, which starts its own rigctld on 127.0.0.1:4532 and talks to
it. Neither side reports anything wrong: the operator gets a CAT timeout from a
daemon with no radio behind it, and OpsLog's log is silent because nothing ever
arrived. Three exchanges went into establishing that the connection simply never
reached us — the port table was what settled it, not the code.

So the server now dials its own port at startup and checks the connection lands
on its own accept loop. If it does not, it says which program will be receiving
the CAT connections and what to do. The counter it compares can only be raised
by our own accept loop, so a real client arriving during the probe makes the
check pass, never fail wrongly.
This commit is contained in:
2026-08-11 10:15:19 +02:00
parent 796a1d8e7d
commit 0c8d79e2fa
3 changed files with 146 additions and 2 deletions
+48
View File
@@ -61,6 +61,11 @@ type Server struct {
conns map[net.Conn]struct{}
closed bool
// accepted counts every connection this listener has taken. Only selfTest
// reads it, to tell "a client reached us" from "a client reached someone
// else on our port".
accepted atomic.Int64
// ptt mirrors the last PTT state a client commanded via set_ptt. WSJT-X/JTDX
// poll get_ptt DURING transmit to confirm the rig is keyed; if get_ptt reads
// RX they conclude PTT failed and abort the over after a second or two. We
@@ -109,15 +114,58 @@ func (s *Server) Start() error {
}
return
}
s.accepted.Add(1)
s.mu.Lock()
s.conns[c] = struct{}{}
s.mu.Unlock()
go s.serve(c)
}
}()
go s.selfTest()
return nil
}
// selfTest checks that a client connecting to 127.0.0.1:<port> actually reaches
// THIS listener, and says so in the log when it does not.
//
// Binding successfully is not the same as being reachable. OpsLog listens on
// 0.0.0.0, and Windows lets a second program bind the SAME port on the specific
// address 127.0.0.1. Connections to localhost then go to the MORE SPECIFIC
// listener — the other program — while ours sits there having logged "sharing
// CAT on port 4532" and never seeing a single client.
//
// Seen in the field with Nexus, which starts its own rigctld on 127.0.0.1:4532
// and connects to it. Neither program reports anything wrong; the operator gets
// a CAT timeout from a daemon with no radio behind it, and OpsLog's log is
// silent because nothing ever arrived. Three exchanges went into finding that,
// so it is worth one line at startup.
//
// The counter can only be raised by our own accept loop, so a real client
// arriving during the probe makes this pass, never fail wrongly.
func (s *Server) selfTest() {
before := s.accepted.Load()
addr := fmt.Sprintf("127.0.0.1:%d", s.port)
c, err := net.DialTimeout("tcp", addr, 2*time.Second)
if err != nil {
s.log("rigctld: WARNING — could not reach our own CAT port at %s (%v); "+
"clients on this PC will not find OpsLog", addr, err)
return
}
defer c.Close()
// Give the accept loop a moment: the dial returns as soon as the handshake
// completes, which can be marginally before Accept hands the connection over.
for i := 0; i < 20; i++ {
if s.accepted.Load() > before {
return // it reached us — nothing to say
}
time.Sleep(50 * time.Millisecond)
}
s.log("rigctld: WARNING — another program is already answering on %s. "+
"It will receive the CAT connections meant for OpsLog, which will look "+
"like a timeout in that program and silence here. Close it, or move "+
"OpsLog's shared CAT to a different port.", addr)
}
// releasePTT drops the transmitter when whoever was holding it goes away.
//
// Nothing else will. The Kenwood/Elecraft backend deliberately suspends its
+94
View File
@@ -0,0 +1,94 @@
package rigctld
import (
"fmt"
"net"
"strings"
"sync"
"testing"
"time"
)
// A listener that binds successfully is not necessarily the one clients reach.
// Windows lets a second program bind the same port on the specific address
// 127.0.0.1 while ours holds 0.0.0.0, and localhost connections then go to the
// more specific one. Seen with Nexus, which starts its own rigctld on 4532.
func TestSelfTestWarnsWhenAnotherProgramHoldsLocalhost(t *testing.T) {
// Squat 127.0.0.1 first, the way the other program does.
squat, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Skipf("cannot bind localhost here: %v", err)
}
defer squat.Close()
port := squat.Addr().(*net.TCPAddr).Port
go func() {
for {
c, err := squat.Accept()
if err != nil {
return
}
c.Close()
}
}()
var mu sync.Mutex
var lines []string
s := New(port, nil, func(f string, a ...any) {
mu.Lock()
lines = append(lines, fmt.Sprintf(f, a...))
mu.Unlock()
})
if err := s.Start(); err != nil {
// The wildcard bind is refused on some setups; nothing to prove then.
t.Skipf("wildcard bind refused: %v", err)
}
defer s.Stop()
deadline := time.Now().Add(4 * time.Second)
for time.Now().Before(deadline) {
mu.Lock()
got := strings.Join(lines, "\n")
mu.Unlock()
if strings.Contains(got, "another program is already answering") {
return
}
time.Sleep(50 * time.Millisecond)
}
mu.Lock()
defer mu.Unlock()
t.Errorf("no warning was logged while another listener owned localhost:%d.\nlog was:\n%s",
port, strings.Join(lines, "\n"))
}
// The healthy case must stay silent: a warning on every clean start would be
// noise, and noise in a log is what makes the real line easy to miss.
func TestSelfTestIsSilentWhenReachable(t *testing.T) {
// A real, free port: with 0 the OS picks one but s.port stays 0, so the probe
// would dial 127.0.0.1:0 and prove nothing.
probe, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Skipf("cannot bind: %v", err)
}
port := probe.Addr().(*net.TCPAddr).Port
probe.Close()
var mu sync.Mutex
var lines []string
s := New(port, nil, func(f string, a ...any) {
mu.Lock()
lines = append(lines, fmt.Sprintf(f, a...))
mu.Unlock()
})
if err := s.Start(); err != nil {
t.Skipf("start: %v", err)
}
defer s.Stop()
time.Sleep(1500 * time.Millisecond)
mu.Lock()
defer mu.Unlock()
for _, l := range lines {
if strings.Contains(l, "WARNING") {
t.Errorf("a reachable port still warned: %q", l)
}
}
}