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
+65 -2
View File
@@ -1545,6 +1545,22 @@ func (a *App) GetStartupStatus() StartupStatus {
}
}
// armExitWatchdog guarantees the process dies.
//
// A graceful shutdown is preferable and is what everything below attempts; this
// only fires when that has already failed. By then the log says which step never
// returned, and the databases are either closed or safe to leave — SQLite's WAL
// survives a hard exit, which a zombie process does not.
func (a *App) armExitWatchdog(after time.Duration) {
go func() {
time.Sleep(after)
applog.Printf("shutdown: still not finished after %s — forcing exit "+
"(the last 'shutdown:' line above names the step that hung)", after)
applog.Close()
os.Exit(0)
}()
}
// beforeClose intercepts the window-close event so we can run shutdown
// tasks (backup, future LoTW upload, ...) while showing a progress modal
// to the user. Returns true the first time to block the close; the
@@ -1555,6 +1571,13 @@ func (a *App) beforeClose(ctx context.Context) bool {
return false
}
a.shuttingDown = true
applog.Printf("shutdown: close requested")
// From here the application MUST end. Everything below waits on hardware and
// databases, and any one of them hanging used to leave a window that would
// not close and a process the operator had to kill from the task manager —
// which then blocked the next launch (single-instance guard) and the restart
// after an update. The watchdog is the promise that it ends anyway.
a.armExitWatchdog(30 * time.Second)
// Capture geometry now, before any shutdown UI can resize the window, so the
// next launch reopens exactly here.
@@ -1636,6 +1659,7 @@ func (a *App) runShutdownTasks(ctx context.Context, steps []shutdownStep) {
steps[i].Status = "done"
}
a.emitShutdownEvent("shutdown:update", steps)
applog.Printf("shutdown: step %q %s %s", steps[i].ID, steps[i].Status, steps[i].Detail)
}
a.emitShutdownEvent("shutdown:done", steps)
// Cleanly tear down hardware links before quitting. Especially the CAT
@@ -1643,11 +1667,14 @@ func (a *App) runShutdownTasks(ctx context.Context, steps []shutdownStep) {
// TCP is gone, and just letting the process exit left the slot stale — the
// NEXT launch then spent ~30s retrying the connect before the radio freed it.
// cat.Stop() closes the socket with a proper FIN so the radio drops us at once.
applog.Printf("shutdown: stopping CAT")
if a.cat != nil {
a.cat.Stop()
}
applog.Printf("shutdown: releasing relay drivers")
a.closeRelayDrivers() // release FTDI/serial relay handles for the next launch
time.Sleep(600 * time.Millisecond)
applog.Printf("shutdown: calling Quit")
wruntime.Quit(ctx)
}
@@ -1706,12 +1733,18 @@ func (a *App) shutdown(ctx context.Context) {
// If the user managed to skip beforeClose (force kill, OS shutdown,
// crash recovery) we still try the backup here as a best-effort
// safety net. HasBackupToday makes a double-run a no-op.
applog.Printf("shutdown: teardown starting")
// Covers the paths beforeClose never saw (force kill of the window, OS
// shutdown) — and re-arms a shorter fuse for the teardown itself.
a.armExitWatchdog(15 * time.Second)
if !a.shuttingDown {
a.maybeShutdownBackup()
}
applog.Printf("shutdown: stopping UDP")
if a.udp != nil {
a.udp.StopAll()
}
applog.Printf("shutdown: stopping solar")
if a.solar != nil {
a.solar.Stop()
}
@@ -1719,6 +1752,7 @@ func (a *App) shutdown(ctx context.Context) {
// backend: without this the rig never gets a disconnect and holds its single
// control session for minutes, refusing every new login (even from the Icom
// Remote Utility) until it times out on its own.
applog.Printf("shutdown: stopping CAT sharing")
if a.catShare != nil {
// Before the CAT stop, so no client is mid-command against a backend that
// is disconnecting — and so the port is free for the next launch.
@@ -1729,29 +1763,35 @@ func (a *App) shutdown(ctx context.Context) {
a.catShareTCI.Stop()
a.catShareTCI = nil
}
applog.Printf("shutdown: stopping CAT")
if a.cat != nil {
a.cat.Stop()
}
applog.Printf("shutdown: stopping WinKeyer")
if a.winkeyer != nil {
a.winkeyer.Disconnect()
}
applog.Printf("shutdown: stopping QSO recorder")
if a.qsoRec != nil {
a.qsoRec.Stop()
}
// Before the databases: Close flushes what the last minute learnt, and a
// restart is exactly when the grid cache is worth the most.
applog.Printf("shutdown: closing grid cache")
if a.gridStore != nil {
if err := a.gridStore.Close(); err != nil {
applog.Printf("gridcache: close: %v", err)
}
a.gridStore = nil
}
applog.Printf("shutdown: closing databases")
if a.logDb != nil && a.logDb != a.db {
_ = a.logDb.Close() // shared MySQL logbook (separate from the local config DB)
}
if a.db != nil {
_ = a.db.Close()
}
applog.Printf("shutdown: teardown done")
}
// userDataDir returns the OpsLog data directory: always "<exe dir>/data".
@@ -12861,6 +12901,23 @@ func (a *App) consumeUDPEvents() {
// 15 s. Single-goroutine loop → the map needs no lock.
const decodeSpotWindow = 20 * time.Second
lastDecodeSpot := map[string]time.Time{}
// Panadapter spots go out on their OWN goroutine.
//
// SendSpot writes to the radio's TCP socket, and this loop is the only
// consumer of the UDP event channel — which drops on backpressure. A period
// hands over twenty-odd decodes at once, so one slow write to the Flex was
// enough to fill that queue and lose decodes the panel was meant to show.
// The spot is an ornament; the decode is the point. Buffered and
// non-blocking, so a radio that stops reading costs spots and nothing else.
spotQ := make(chan cat.SpotInfo, 64)
go func() {
for si := range spotQ {
if a.cat != nil {
a.cat.SendSpot(si)
}
}
}()
defer close(spotQ)
for ev := range a.udp.Events() {
if a.ctx == nil {
continue
@@ -12954,14 +13011,20 @@ func (a *App) consumeUDPEvents() {
if secs <= 0 {
secs = 120
}
a.cat.SendSpot(cat.SpotInfo{
select {
case spotQ <- cat.SpotInfo{
FreqHz: ev.DecodeFreqHz,
Callsign: ev.DecodeCall,
Mode: ev.Mode,
Color: "#FF34C759", // green — distinct from cluster orange
Comment: fmt.Sprintf("%s %+ddB", ev.Mode, ev.DecodeSNR),
LifetimeSec: secs,
})
}:
default:
// Radio not keeping up: skip the spot rather than stall the
// decode stream behind it.
delete(lastDecodeSpot, key) // not spotted after all — let the next one try
}
}
case ev.LoggedADIF != "":
// The RECORD, not just its size. "Was the grid in what WSJT-X sent, or