From 5b37397a647259fde34e16e8515ccbd998fd4962 Mon Sep 17 00:00:00 2001 From: rouggy Date: Tue, 7 Jul 2026 11:07:16 +0200 Subject: [PATCH] fix: Save and close taking time when Flex or Icom is not connected --- internal/cat/flex.go | 39 ++++++++++++++++++++++++++++++++++---- internal/cat/icomserial.go | 10 +++++++++- internal/cat/tci.go | 36 +++++++++++++++++++++++++++++++---- 3 files changed, 76 insertions(+), 9 deletions(-) diff --git a/internal/cat/flex.go b/internal/cat/flex.go index 7021f02..9b55eae 100644 --- a/internal/cat/flex.go +++ b/internal/cat/flex.go @@ -4,6 +4,7 @@ package cat import ( "bufio" + "context" "encoding/binary" "fmt" "math" @@ -25,9 +26,10 @@ type Flex struct { host string port int - mu sync.Mutex - conn net.Conn - wmu sync.Mutex // serialises writes to conn + mu sync.Mutex + conn net.Conn + dialCancel context.CancelFunc // cancels an in-flight Connect dial (Interrupt/Stop); nil when not dialing + wmu sync.Mutex // serialises writes to conn seq int handle string model string @@ -171,7 +173,19 @@ func (f *Flex) Connect() error { if host == "" { return fmt.Errorf("flex: no radio IP configured") } - conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(port)), 5*time.Second) + // Cancellable dial: Interrupt() (called by Stop/Start) aborts it at once so a + // dead radio's 5 s dial timeout doesn't make Stop / Settings "Save & Close" + // wait several seconds for the poll goroutine to give up. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + f.mu.Lock() + f.dialCancel = cancel + f.mu.Unlock() + var d net.Dialer + conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port))) + cancel() + f.mu.Lock() + f.dialCancel = nil + f.mu.Unlock() if err != nil { return fmt.Errorf("flex: connect %s:%d: %w", host, port, err) } @@ -224,6 +238,23 @@ func (f *Flex) Disconnect() { } } +// Interrupt aborts an in-flight Connect dial so Stop()/Start() (Settings +// "Save & Close", CAT backend switch) don't block on a dead radio's 5 s dial +// timeout. Satisfies the Manager's optional interruptible interface. Safe to call +// anytime and from another goroutine; a no-op when not dialing. +func (f *Flex) Interrupt() { + f.mu.Lock() + cancel := f.dialCancel + c := f.conn + f.mu.Unlock() + if cancel != nil { + cancel() + } + if c != nil { + _ = c.Close() // unblock the reader if we're already past the dial + } +} + // send writes a sequenced command (C|) to the radio and returns the // sequence number (so the caller can match the R response, e.g. to learn a // new spot's index). Returns 0 when not connected. Best effort. diff --git a/internal/cat/icomserial.go b/internal/cat/icomserial.go index eb5f344..056c726 100644 --- a/internal/cat/icomserial.go +++ b/internal/cat/icomserial.go @@ -444,8 +444,14 @@ func (b *IcomSerial) write(payload ...byte) error { // recv waits for a frame the reader routed to respCh that satisfies match, or // times out. The reader has already discarded echoes and split off scope frames, -// so recv only ever sees candidate control replies. +// so recv only ever sees candidate control replies. It also bails out at once if +// Interrupt() fires (Stop) so an in-flight ReadState — which can be a dozen reads, +// each up to icomReadTimeout when the rig is slow — doesn't make Stop/Save-&-Close +// wait several seconds for the poll goroutine to finish. func (b *IcomSerial) recv(timeout time.Duration, match func(civ.Decoded) bool) (civ.Decoded, error) { + b.dialMu.Lock() + cancel := b.dialCancel + b.dialMu.Unlock() deadline := time.After(timeout) for { select { @@ -453,6 +459,8 @@ func (b *IcomSerial) recv(timeout time.Duration, match func(civ.Decoded) bool) ( if match(f) { return f, nil } + case <-cancel: + return civ.Decoded{}, fmt.Errorf("icom: interrupted") case <-deadline: return civ.Decoded{}, fmt.Errorf("icom: timeout waiting for response") } diff --git a/internal/cat/tci.go b/internal/cat/tci.go index 0a29f06..8ba221c 100644 --- a/internal/cat/tci.go +++ b/internal/cat/tci.go @@ -3,6 +3,7 @@ package cat import ( + "context" "fmt" "net" "strconv" @@ -27,9 +28,10 @@ type TCI struct { digitalDefault string // surfaced when the rig reports a digital mode (FT8/…) spotsEnabled bool // mirror cluster spots onto the TCI panorama - mu sync.Mutex // guards conn + writes + state - conn *websocket.Conn - ready bool + mu sync.Mutex // guards conn + writes + state + conn *websocket.Conn + dialCancel context.CancelFunc // cancels an in-flight Connect dial (Interrupt/Stop) + ready bool // Cached state pushed by the radio. device string @@ -70,8 +72,18 @@ func (t *TCI) Connect() error { return fmt.Errorf("tci: no host configured") } url := fmt.Sprintf("ws://%s", net.JoinHostPort(host, strconv.Itoa(port))) + // Cancellable dial so Interrupt() (Stop / Settings "Save & Close") aborts it at + // once instead of waiting out a dead server's 5 s handshake timeout. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + t.mu.Lock() + t.dialCancel = cancel + t.mu.Unlock() dialer := websocket.Dialer{HandshakeTimeout: 5 * time.Second} - conn, _, err := dialer.Dial(url, nil) + conn, _, err := dialer.DialContext(ctx, url, nil) + cancel() + t.mu.Lock() + t.dialCancel = nil + t.mu.Unlock() if err != nil { return fmt.Errorf("tci: connect %s: %w", url, err) } @@ -122,6 +134,22 @@ func (t *TCI) Disconnect() { } } +// Interrupt aborts an in-flight Connect dial so Stop()/Start() don't block on a +// dead server's handshake timeout. Satisfies the Manager's interruptible +// interface. Safe from another goroutine; a no-op when not dialing. +func (t *TCI) Interrupt() { + t.mu.Lock() + cancel := t.dialCancel + c := t.conn + t.mu.Unlock() + if cancel != nil { + cancel() + } + if c != nil { + _ = c.Close() + } +} + // ReadState returns the cached state pushed by the radio. func (t *TCI) ReadState() (RigState, error) { t.mu.Lock()