diff --git a/changelog.json b/changelog.json index 4652ff0..c144646 100644 --- a/changelog.json +++ b/changelog.json @@ -6,13 +6,15 @@ "Digital decodes: a station's grid square could be logged as its callsign. An unrecognised word after CQ made the parser skip a slot, and a four-character grid passes every shape test a callsign does, so \"CQ FOO JN36\" was read as a contact with JN36 — spotted, coloured and counted like any other station. A grid in the callsign position is now refused.", "Shared CAT no longer drops when the rig is busy. A Kenwood answers \"?;\" for a moment after coming out of transmit, which is exactly when polling resumes — and that single refused poll tore down the whole CAT link. WSJT-X, which keys through it, lost the radio mid-sequence and Hamlib then sent an uninitialised frequency, so the operator got an alarming frequency error whose real cause was three lines earlier. A busy rig is now ridden out for up to three polls; a genuine serial fault still drops immediately.", "CW over CAT now works on a Kenwood. The KY command was written against Elecraft's, which takes a string of any length; a Kenwood requires exactly 24 characters, so every message was refused and OpsLog reported that the radio would not do CW over CAT at all — sending operators to buy a serial keyer they did not need. A semicolon is also stripped from CW text now: it ends a CAT frame, so one in a macro cut the command short.", - "Shared CAT now says so when a client is misconfigured. A program set to a Kenwood or Yaesu RIG MODEL instead of Hamlib NET rigctl sends raw rig commands to the CAT-sharing port; the reply it gets back has no terminator it recognises, so it reports a timeout with no data and the operator concludes CAT sharing is broken. The log now names the mistake and the setting that fixes it." + "Shared CAT now says so when a client is misconfigured. A program set to a Kenwood or Yaesu RIG MODEL instead of Hamlib NET rigctl sends raw rig commands to the CAT-sharing port; the reply it gets back has no terminator it recognises, so it reports a timeout with no data and the operator concludes CAT sharing is broken. The log now names the mistake and the setting that fixes it.", + "Shared CAT now warns when another program has taken its port. Binding successfully is not the same as being reachable: OpsLog listens on every interface, and Windows lets a second program bind the SAME port on 127.0.0.1 alone — after which every local connection goes to that one, while OpsLog waits for clients that never arrive and reports nothing wrong. Seen with Nexus, which starts its own rigctld. The log now says so at startup and tells you what to do about it." ], "fr": [ "Décodes digitaux : le carré locator d une station pouvait être enregistré comme son indicatif. Un mot non reconnu après CQ faisait sauter un cran au parseur, et un grid de quatre caractères passe tous les tests de forme d un indicatif — « CQ FOO JN36 » était donc lu comme un contact avec JN36, spotté, coloré et compté comme n importe quelle autre station. Un grid à la place de l indicatif est maintenant refusé.", "Le CAT partagé ne tombe plus quand le rig est occupé. Un Kenwood répond « ?; » un court instant en sortie d émission, c est-à-dire précisément au moment où le poll reprend — et ce seul refus faisait tomber tout le lien CAT. WSJT-X, qui émet à travers lui, perdait la radio en pleine séquence et Hamlib envoyait ensuite une fréquence non initialisée : l opérateur voyait une erreur de fréquence inquiétante dont la vraie cause était trois lignes plus haut. Un rig occupé est désormais encaissé sur trois polls au plus ; une vraie panne série tombe toujours immédiatement.", "Le CW par CAT fonctionne enfin sur un Kenwood. La commande KY avait été écrite d après celle d Elecraft, qui accepte une chaîne de longueur libre ; un Kenwood en exige exactement 24 caractères, donc chaque message était refusé et OpsLog annonçait que la radio ne savait pas faire de CW par CAT — envoyant des opérateurs acheter un keyer série dont ils n avaient pas besoin. Le point-virgule est aussi retiré du texte CW : il termine une trame CAT, donc un seul dans une macro coupait la commande.", - "Le CAT partagé signale désormais un client mal configuré. Un logiciel réglé sur un MODÈLE de rig Kenwood ou Yaesu au lieu de Hamlib NET rigctl envoie des commandes rig brutes sur le port de partage ; la réponse qu il reçoit ne porte pas le terminateur qu il attend, il annonce donc un délai dépassé sans données et l opérateur en conclut que le partage CAT est cassé. Le log nomme maintenant l erreur et le réglage qui la corrige." + "Le CAT partagé signale désormais un client mal configuré. Un logiciel réglé sur un MODÈLE de rig Kenwood ou Yaesu au lieu de Hamlib NET rigctl envoie des commandes rig brutes sur le port de partage ; la réponse qu il reçoit ne porte pas le terminateur qu il attend, il annonce donc un délai dépassé sans données et l opérateur en conclut que le partage CAT est cassé. Le log nomme maintenant l erreur et le réglage qui la corrige.", + "Le CAT partagé signale désormais qu un autre programme lui a pris son port. Réussir à ouvrir le port n est pas la même chose qu être joignable : OpsLog écoute sur toutes les interfaces, et Windows autorise un second programme à ouvrir le MÊME port sur la seule adresse 127.0.0.1 — dès lors toutes les connexions locales vont à celui-là, pendant qu OpsLog attend des clients qui n arrivent jamais sans rien signaler. Constaté avec Nexus, qui lance son propre rigctld. Le log le dit maintenant au démarrage, et indique quoi faire." ] }, { diff --git a/internal/rigctld/rigctld.go b/internal/rigctld/rigctld.go index db1d43d..bb54eb8 100644 --- a/internal/rigctld/rigctld.go +++ b/internal/rigctld/rigctld.go @@ -61,6 +61,11 @@ type Server struct { conns map[net.Conn]struct{} closed bool + // accepted counts every connection this listener has taken. Only selfTest + // reads it, to tell "a client reached us" from "a client reached someone + // else on our port". + accepted atomic.Int64 + // ptt mirrors the last PTT state a client commanded via set_ptt. WSJT-X/JTDX // poll get_ptt DURING transmit to confirm the rig is keyed; if get_ptt reads // RX they conclude PTT failed and abort the over after a second or two. We @@ -109,15 +114,58 @@ func (s *Server) Start() error { } return } + s.accepted.Add(1) s.mu.Lock() s.conns[c] = struct{}{} s.mu.Unlock() go s.serve(c) } }() + go s.selfTest() return nil } +// selfTest checks that a client connecting to 127.0.0.1: actually reaches +// THIS listener, and says so in the log when it does not. +// +// Binding successfully is not the same as being reachable. OpsLog listens on +// 0.0.0.0, and Windows lets a second program bind the SAME port on the specific +// address 127.0.0.1. Connections to localhost then go to the MORE SPECIFIC +// listener — the other program — while ours sits there having logged "sharing +// CAT on port 4532" and never seeing a single client. +// +// Seen in the field with Nexus, which starts its own rigctld on 127.0.0.1:4532 +// and connects to it. Neither program reports anything wrong; the operator gets +// a CAT timeout from a daemon with no radio behind it, and OpsLog's log is +// silent because nothing ever arrived. Three exchanges went into finding that, +// so it is worth one line at startup. +// +// The counter can only be raised by our own accept loop, so a real client +// arriving during the probe makes this pass, never fail wrongly. +func (s *Server) selfTest() { + before := s.accepted.Load() + addr := fmt.Sprintf("127.0.0.1:%d", s.port) + c, err := net.DialTimeout("tcp", addr, 2*time.Second) + if err != nil { + s.log("rigctld: WARNING — could not reach our own CAT port at %s (%v); "+ + "clients on this PC will not find OpsLog", addr, err) + return + } + defer c.Close() + // Give the accept loop a moment: the dial returns as soon as the handshake + // completes, which can be marginally before Accept hands the connection over. + for i := 0; i < 20; i++ { + if s.accepted.Load() > before { + return // it reached us — nothing to say + } + time.Sleep(50 * time.Millisecond) + } + s.log("rigctld: WARNING — another program is already answering on %s. "+ + "It will receive the CAT connections meant for OpsLog, which will look "+ + "like a timeout in that program and silence here. Close it, or move "+ + "OpsLog's shared CAT to a different port.", addr) +} + // releasePTT drops the transmitter when whoever was holding it goes away. // // Nothing else will. The Kenwood/Elecraft backend deliberately suspends its diff --git a/internal/rigctld/rigctld_selftest_test.go b/internal/rigctld/rigctld_selftest_test.go new file mode 100644 index 0000000..204300d --- /dev/null +++ b/internal/rigctld/rigctld_selftest_test.go @@ -0,0 +1,94 @@ +package rigctld + +import ( + "fmt" + "net" + "strings" + "sync" + "testing" + "time" +) + +// A listener that binds successfully is not necessarily the one clients reach. +// Windows lets a second program bind the same port on the specific address +// 127.0.0.1 while ours holds 0.0.0.0, and localhost connections then go to the +// more specific one. Seen with Nexus, which starts its own rigctld on 4532. +func TestSelfTestWarnsWhenAnotherProgramHoldsLocalhost(t *testing.T) { + // Squat 127.0.0.1 first, the way the other program does. + squat, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Skipf("cannot bind localhost here: %v", err) + } + defer squat.Close() + port := squat.Addr().(*net.TCPAddr).Port + go func() { + for { + c, err := squat.Accept() + if err != nil { + return + } + c.Close() + } + }() + + var mu sync.Mutex + var lines []string + s := New(port, nil, func(f string, a ...any) { + mu.Lock() + lines = append(lines, fmt.Sprintf(f, a...)) + mu.Unlock() + }) + if err := s.Start(); err != nil { + // The wildcard bind is refused on some setups; nothing to prove then. + t.Skipf("wildcard bind refused: %v", err) + } + defer s.Stop() + + deadline := time.Now().Add(4 * time.Second) + for time.Now().Before(deadline) { + mu.Lock() + got := strings.Join(lines, "\n") + mu.Unlock() + if strings.Contains(got, "another program is already answering") { + return + } + time.Sleep(50 * time.Millisecond) + } + mu.Lock() + defer mu.Unlock() + t.Errorf("no warning was logged while another listener owned localhost:%d.\nlog was:\n%s", + port, strings.Join(lines, "\n")) +} + +// The healthy case must stay silent: a warning on every clean start would be +// noise, and noise in a log is what makes the real line easy to miss. +func TestSelfTestIsSilentWhenReachable(t *testing.T) { + // A real, free port: with 0 the OS picks one but s.port stays 0, so the probe + // would dial 127.0.0.1:0 and prove nothing. + probe, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Skipf("cannot bind: %v", err) + } + port := probe.Addr().(*net.TCPAddr).Port + probe.Close() + + var mu sync.Mutex + var lines []string + s := New(port, nil, func(f string, a ...any) { + mu.Lock() + lines = append(lines, fmt.Sprintf(f, a...)) + mu.Unlock() + }) + if err := s.Start(); err != nil { + t.Skipf("start: %v", err) + } + defer s.Stop() + time.Sleep(1500 * time.Millisecond) + mu.Lock() + defer mu.Unlock() + for _, l := range lines { + if strings.Contains(l, "WARNING") { + t.Errorf("a reachable port still warned: %q", l) + } + } +}