fix(rigctld): never leave the rig keyed when the client goes away

The Kenwood/Elecraft backend deliberately suspends its wire poll while PTT is
held — a K3 answers "?;" to IF; during transmit, and treating that as a fault
used to drop the whole CAT link. The consequence was that nothing watched the
transmitter: a client that crashed, was closed, or simply had its socket shut
under it left the rig on air.

And shutting the socket is routine. reloadCATShare tears the sharing server
down and rebuilds it on every settings save, so a Save while WSJT-X held PTT
was enough. A K3 operator's log shows exactly that: "TX;" at 17:53:09, no "RX;"
ever, the poll silent, and the rig still keyed 29 s later when the CAT link
happened to be rebuilt.

The server now drops PTT when a connection ends and when Stop() is called.
Stop() runs before reloadCAT restarts the backend, so the unkey still reaches
the radio. An atomic Swap keeps it once-only across the two paths.
This commit is contained in:
2026-08-08 20:19:00 +02:00
parent 18f44a5aa3
commit 90c6458af0
3 changed files with 92 additions and 2 deletions
+27
View File
@@ -118,7 +118,32 @@ func (s *Server) Start() error {
return nil
}
// releasePTT drops the transmitter when whoever was holding it goes away.
//
// Nothing else will. The Kenwood/Elecraft backend deliberately suspends its
// wire poll while PTT is held (a K3 answers "?;" to IF; during transmit), so a
// client that crashes, is killed, or simply has its socket closed under it
// leaves the rig keyed with nobody watching — and closing the socket is exactly
// what Stop() does on every settings save. Seen in the field: a K3 sat in
// transmit for 29 s, until the CAT link happened to be rebuilt.
//
// Swap makes this once-only, so the Stop() path and the per-connection defer it
// triggers can both call it without double-unkeying.
func (s *Server) releasePTT(why string) {
if !s.ptt.Swap(false) {
return
}
s.log("rigctld: %s while the rig was keyed — dropping PTT", why)
if err := s.rig.SetPTT(false); err != nil {
s.log("rigctld: emergency unkey failed: %v", err)
}
}
func (s *Server) Stop() {
// Before anything is torn down: reloadCAT calls us BEFORE it restarts the CAT
// backend, so the rig is still reachable here and an unkey still lands.
s.releasePTT("CAT sharing stopped")
s.mu.Lock()
s.closed = true
ln := s.ln
@@ -146,6 +171,8 @@ func (s *Server) serve(c net.Conn) {
delete(s.conns, c)
s.mu.Unlock()
_ = c.Close()
// A client that walks away mid-over must not leave the rig transmitting.
s.releasePTT(fmt.Sprintf("client %s left", c.RemoteAddr()))
}()
s.log("rigctld: client connected from %s", c.RemoteAddr())
r := bufio.NewReader(c)
+61
View File
@@ -7,6 +7,7 @@ import (
"strings"
"sync"
"testing"
"time"
)
// fakeRig stands in for the CAT manager.
@@ -234,6 +235,66 @@ func TestServerOverTCP(t *testing.T) {
}
}
// A client that vanishes mid-over must not leave the transmitter keyed. Nothing
// else would notice: the Kenwood/Elecraft backend stops polling while PTT is
// held, so a K3 was seen sitting in transmit for 29 s after WSJT-X's socket was
// closed under it by a settings save.
func TestPTTIsDroppedWhenTheClientDisappears(t *testing.T) {
rig := &fakeRig{freq: 14074000, mode: "FT8"}
s := New(0, rig, nil)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
s.mu.Lock()
s.ln = ln
s.mu.Unlock()
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go s.serve(c)
}
}()
defer s.Stop()
c, err := net.DialTimeout("tcp", ln.Addr().String(), dialTimeout)
if err != nil {
t.Fatalf("dial: %v", err)
}
r := bufio.NewReader(c)
// "T 3" is what WSJT-X sends for PTT_ON_DATA — the form seen in the field.
if _, err := c.Write([]byte("T 3\n")); err != nil {
t.Fatalf("write: %v", err)
}
line, _ := r.ReadString('\n')
if strings.TrimSpace(line) != "RPRT 0" {
t.Fatalf("set_ptt = %q, want RPRT 0", strings.TrimSpace(line))
}
rig.mu.Lock()
keyed := rig.ptt
rig.mu.Unlock()
if !keyed {
t.Fatalf("rig is not keyed after T 3 — the test proves nothing")
}
_ = c.Close()
// The unkey happens on the serve goroutine's defer, so poll briefly.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
rig.mu.Lock()
keyed = rig.ptt
rig.mu.Unlock()
if !keyed {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Error("rig still keyed after the client disconnected — the transmitter was left on air")
}
func TestModeMapping(t *testing.T) {
for _, c := range []struct{ adif, hamlib string }{
{"SSB", "USB"}, {"LSB", "LSB"}, {"CW", "CW"}, {"RTTY", "RTTY"},