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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user