fix: stop losing decodes, hanging on exit, and wedging the rig link

Three faults an operator's log finally made visible, plus the interface
work that came out of the same session.

Reliability:

- UDP events were dropped on backpressure without a word. A period hands
  over twenty-odd decodes at once, and one slow write to the radio was
  enough to fill the queue — so a decode simply never appeared, and the
  only detector was the operator comparing the panel with JTDX. The drop
  is now counted and logged, panadapter spots went to their own goroutine
  so the radio can no longer hold the decode stream up, and the queue is
  deep enough for a full period.

- The CAT manager waited for its poll loop with a bare <-done. A loop
  wedged in a serial read then blocked every later restart inside Start,
  before it could even try to connect: the rig stayed dead, no line was
  written anywhere, and only killing the process recovered it. The wait
  is bounded at ten seconds and says what it abandoned and why the next
  connect may fail.

- Shutdown had no logging at all, so a hang left nothing to go on and a
  process the operator had to kill — which then blocked the restart after
  an update. Every step is logged, and a watchdog forces the exit if one
  of them never returns.

Auto-call:

- A QSO in progress is now held by OpsLog itself rather than inferred
  from the sender's Status. The moment WSJT-X/JTDX dropped the DX call or
  the Enable-Tx flag between overs, the exchange looked finished and the
  next CQ was answered, interleaving two and then three QSOs on one
  slice. Released on log, on halt, on taking over, and by a watchdog.

Cluster console:

- Replies to a command were buried under the spot flood; a Replies
  toggle hides the DX spots, which the list above already shows.
- Twelve named command buttons beside the input, configured in
  Settings -> Cluster; a button with no command is not drawn.
- Following the tail is now an explicit switch, and sending a command
  re-arms it. It used to measure "am I at the bottom" AFTER committing
  the new lines, so a ten-line reply looked like the operator had
  scrolled up and was never followed — the one case it exists for.

Awards:

- An award can name NO field. The matching controls disappear with it
  and only hand-assigned references count, which is the only thing that
  can feed a reference like WWBOTA. A test pins that nothing else is
  scanned.
- WWBOTA added to the catalogue with its 31 342 references.

Elsewhere: the rotor widget's Stop button acknowledges the press like
the direction presets already did, and the docked band map can be
switched to fit-to-band from its own header.
This commit is contained in:
2026-08-21 01:07:10 +02:00
parent 1e507225dd
commit e3b7a35e2c
14 changed files with 251306 additions and 32 deletions
+23
View File
@@ -684,3 +684,26 @@ func TestCatalogForcesBuiltin(t *testing.T) {
t.Fatal("empty catalog")
}
}
// An award with NO QSO field at all counts only what the operator assigned by
// hand. WWBOTA is the case: bunker references have no ADIF field anywhere, so
// the definition names no field and the matcher must scan nothing rather than
// fall back on some default. The manual override still applies — it is the only
// thing that can feed such an award.
func TestComputeNoFieldManualOnly(t *testing.T) {
def := Def{Code: "WWBOTA", Type: TypeReference, Field: "", Dynamic: true, Valid: true}
qsos := []qso.QSO{
// Assigned by hand → counts.
{Callsign: "F4ABC", Band: "40m", Extras: map[string]string{ManualRefsKey: "WWBOTA@B/F-0123"}},
// Nothing assigned. The other fields are deliberately full: none of them
// may be scanned just because no field was named.
{Callsign: "F4DEF", Band: "20m", POTARef: "FF-0001", QTH: "B/F-0999", Comment: "B/F-0888"},
}
r := Compute([]Def{def}, qsos, nil, nil)[0]
if r.Worked != 1 {
t.Errorf("WWBOTA worked = %d, want 1 (%v)", r.Worked, refCodes(r))
}
if got := refCodes(r); len(got) != 1 || got[0] != "B/F-0123" {
t.Errorf("WWBOTA refs = %v, want [B/F-0123]", got)
}
}
File diff suppressed because it is too large Load Diff
+23 -1
View File
@@ -184,7 +184,29 @@ func (m *Manager) stopLocked() {
iv.Interrupt()
}
if done != nil {
<-done
// Bounded, and loud when it expires.
//
// This used to be a bare <-done. A poll goroutine wedged in a serial read
// then held every caller for ever: each restart attempt blocked inside
// Start before it could even try to connect, so the rig stayed dead and
// NOTHING was written to the log — the operator saw a CAT link that would
// not come back and no reason anywhere. Shutdown blocked on the same wait.
//
// Ten seconds is far longer than any honest disconnect. Past that the old
// poller is not coming back, and carrying on without it — noisily — beats
// freezing the application around it. The abandoned goroutine still holds
// its port, which is exactly what the log line has to say, because the
// next connect will fail because of it.
select {
case <-done:
case <-time.After(10 * time.Second):
name := "cat"
if b != nil {
name = b.Name()
}
debugLog.Printf("%s: poll loop did not stop within 10s — abandoning it; "+
"its port stays open, so the next connect may fail until OpsLog is restarted", name)
}
}
}
+49 -3
View File
@@ -223,6 +223,15 @@ type Server struct {
// which fills the whole rotating log with the same line and buries the
// evidence of anything else.
badPkts int
// drops counts events discarded because the consumer could not keep up —
// see the select at the foot of handle(). They used to vanish in silence,
// which made a missing decode indistinguishable from one the sender never
// broadcast: an operator comparing the panel against JTDX side by side was
// the only detector we had. A period's decodes arrive as one burst, so this
// is exactly when it happens.
drops int
lastDropL time.Time
}
// maxBadPktDumps is how many unparseable datagrams a listener describes in full
@@ -663,10 +672,43 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
select {
case s.out <- ev:
default:
// Drop on backpressure rather than block the read loop.
// Drop on backpressure rather than block the read loop — but say so.
s.noteDrop(ev)
}
}
// noteDrop reports events lost to a full queue, first one immediately and then
// at most one line a minute with the running total.
//
// Rate-limited because the condition is self-sustaining: a consumer that fell
// behind on one period is behind for the next, and a line per lost decode would
// bury the rest of the log under the symptom. The event's own description goes
// in, because "a decode was lost" and "a Status was lost" are different faults.
func (s *Server) noteDrop(ev Event) {
what := "event"
switch {
case ev.DecodeCall != "":
what = "decode from " + ev.DecodeCall
case ev.LoggedADIF != "":
what = "logged QSO"
case ev.TxMessage != "" || ev.DECall != "":
what = "transmit status"
}
s.mu.Lock()
s.drops++
n := s.drops
quiet := n > 1 && time.Since(s.lastDropL) < time.Minute
if !quiet {
s.lastDropL = time.Now()
}
s.mu.Unlock()
if quiet {
return
}
applog.Printf("udp: [%s] queue full — dropped %s (%d lost this session); "+
"the decodes panel is missing what the sender broadcast", s.cfg.Name, what, n)
}
func (s *Server) close() {
s.mu.Lock()
if s.stopped {
@@ -734,8 +776,12 @@ type Manager struct {
func NewManager(repo *Repo) *Manager {
return &Manager{
repo: repo,
out: make(chan Event, 64),
repo: repo,
// 256, not 64: a period delivers twenty-odd decodes in one burst while
// Status keeps arriving, and the consumer does real work per event. The
// depth is headroom for that burst — the drop counter above is what says
// whether it was enough.
out: make(chan Event, 256),
inbound: map[int64]*Server{},
}
}