fix: a quiet cluster was treated as a dead one and reconnected

Reported by an operator whose node sends little: nothing for two minutes, then a
reconnect — losing the login, the filters, and any spot that arrived during the
gap.

The read had a 120 s deadline and ANY error ended the session, a timeout
included. That reads a silence as a verdict on the link. It is not: a cluster on
a dead band legitimately says nothing for minutes.

Only a real error ends a session now. A genuinely dead peer is still caught, and
by the mechanism meant for it — TCP keepalive probes an idle connection and its
failure surfaces as an error on Read, not as a timeout. The dialer sets it
explicitly rather than inheriting a default, since it is now what the design
depends on.

One trap the advice this came from did not mention: ReadString returns the bytes
it HAD read along with the timeout. Discarding them would lose the first half of
any spot line straddling a deadline, so the partial is carried over.

Tested: a listener that goes silent past the deadline and then sends a spot —
the spot arrives on the ORIGINAL connection, and the listener never accepts a
second one. The tick is a var so that test runs in four seconds instead of
sixty; a slow test is a test that gets skipped.
This commit is contained in:
2026-07-31 21:48:05 +02:00
parent ed67ed7fe3
commit 72fee7a090
3 changed files with 142 additions and 5 deletions
+89
View File
@@ -0,0 +1,89 @@
package cluster
import (
"net"
"strconv"
"testing"
"time"
)
// A quiet cluster must not be treated as a broken one.
//
// Reported by an operator whose node sends little: nothing for two minutes and
// OpsLog reconnected — losing the login, the filters, and any spot that landed
// during the gap. The read deadline was being read as a verdict on the link
// rather than as a way to stay responsive to a stop request.
//
// The test holds the connection open, silent for longer than one deadline, then
// sends a spot. A session that survives receives it; one that reconnects does
// not, because this listener never accepts twice.
func TestSilenceDoesNotEndTheSession(t *testing.T) {
// Shorten the tick so the test exercises the real code path in seconds.
defer func(orig time.Duration) { idleTick = orig }(idleTick)
idleTick = time.Second
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
accepted := make(chan net.Conn, 2)
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
accepted <- c
}
}()
host, portStr, _ := net.SplitHostPort(ln.Addr().String())
port, err := strconv.Atoi(portStr)
if err != nil {
t.Fatal(err)
}
spots := make(chan Spot, 4)
s := &session{
cfg: ServerConfig{Name: "quiet", Host: host, Port: port},
onSpot: func(sp Spot) { spots <- sp },
onLine: func(Line) {},
onStatus: func() {},
stopCh: make(chan struct{}),
}
done := make(chan error, 1)
go func() { _, err := s.runOnce(); done <- err }()
conn := <-accepted
defer conn.Close()
// Silent for longer than one deadline: the loop must ride through it.
time.Sleep(3 * idleTick)
select {
case c := <-accepted:
c.Close()
t.Fatal("the session reconnected during the silence")
case err := <-done:
t.Fatalf("the session ended during the silence: %v", err)
default:
}
// Now a spot: it proves the ORIGINAL connection is still the live one.
if _, err := conn.Write([]byte("DX de F4BPO: 14074.0 OY1CT FT8 1234Z\r\n")); err != nil {
t.Fatal(err)
}
select {
case sp := <-spots:
if sp.DXCall != "OY1CT" {
t.Errorf("spot from the wrong station: %+v", sp)
}
case <-time.After(5 * time.Second):
t.Fatal("the spot sent after the silence never arrived")
}
close(s.stopCh)
<-done
}