netscan: stop the scan wedging, and stop it overstating the LAN
The results channel was sized by the number of hosts while each worker sends once per open port, so a subnet with more open ports than addresses filled the buffer and blocked a worker forever. Nothing drains the channel until wg.Wait returns and the sends have no ctx.Done case, so the calling turn hung for the life of the process. Size it by probes. Three more claims the scanner could not back. MaxHosts was spent in order, so the second of two configured subnets got two addresses out of 254 with nothing logged. A run cut short by the cap or the deadline came back indistinguishable from a complete one, and the shipped defaults never fit the budget, so every scan was silently truncated at the top of the range. Scan now reports truncation, targets are taken round-robin, and the default rate and the budget are consistent with a /24. The spoken reply read dotted quads out loud on the voice path. It now says how many devices and what shape, and writes the address list as a note, which is also the only record that Maven put packets on the LAN. The network noun is matched whole so posetil is not a scan, the rate has a stated ceiling, and a repeat question inside two minutes reuses the answer. Both query sources claimed the turn when the capability was off, which let an unconfigured scanner and an unconfigured house swallow questions that used to reach recall. Both now fall through. Found in review of #81 and #80. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
This commit is contained in:
@@ -2,10 +2,14 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
func TestWireNetScanOffUnlessEnabled(t *testing.T) {
|
||||
@@ -23,7 +27,7 @@ func TestWireNetScanOffUnlessEnabled(t *testing.T) {
|
||||
}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if w := wireNetScan(cfg); w != nil {
|
||||
if w := wireNetScan(cfg, nil); w != nil {
|
||||
t.Fatal("the scanner must not wire for this config")
|
||||
}
|
||||
})
|
||||
@@ -36,7 +40,7 @@ func TestWireNetScanOffUnlessEnabled(t *testing.T) {
|
||||
|
||||
ok := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{
|
||||
Subnets: []string{"192.168.1.0/24"}, Enabled: true,
|
||||
}})
|
||||
}}, nil)
|
||||
if ok == nil {
|
||||
t.Fatal("a valid enabled block should wire")
|
||||
}
|
||||
@@ -50,7 +54,7 @@ func TestScanSummaryOnAnEmptyRange(t *testing.T) {
|
||||
// Port 1 on loopback: nothing listens and the connection is refused
|
||||
// immediately, so the scan is fast and touches only this machine.
|
||||
Subnets: []string{"127.0.0.1/32"}, Ports: []int{1}, Rate: 1000, Enabled: true,
|
||||
}})
|
||||
}}, nil)
|
||||
if w == nil {
|
||||
t.Fatal("wireNetScan returned nil")
|
||||
}
|
||||
@@ -109,3 +113,59 @@ func TestIsNetworkQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// notingAPI counts the notes a scan writes, and remembers the last one.
|
||||
type notingAPI struct {
|
||||
ipc.CoreAPI
|
||||
n int
|
||||
last string
|
||||
}
|
||||
|
||||
func (a *notingAPI) WriteNote(_ context.Context, _ time.Time, text string, _ []float32, _ string) (int64, error) {
|
||||
a.n++
|
||||
a.last = text
|
||||
return int64(a.n), nil
|
||||
}
|
||||
|
||||
// The spoken answer must not be a list of IP addresses. It goes to piper as
|
||||
// well as to /chat, and six dotted quads read out as a digit stream is not an
|
||||
// answer anybody can use. The addresses belong in the written record.
|
||||
func TestScanSummarySpeaksACountAndWritesTheAddresses(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
|
||||
api := ¬ingAPI{}
|
||||
w := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{
|
||||
Subnets: []string{"127.0.0.1/32"}, Ports: []int{port}, Rate: 1000, Enabled: true,
|
||||
}}, api)
|
||||
if w == nil {
|
||||
t.Fatal("wireNetScan returned nil")
|
||||
}
|
||||
out, claimed := w.scanSummary(context.Background())
|
||||
if !claimed {
|
||||
t.Fatal("the summary did not claim the turn")
|
||||
}
|
||||
if strings.Contains(out, "127.0.0.1") || strings.Contains(out, portStr) {
|
||||
t.Errorf("the spoken reply reads addresses out loud: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "нашла 1 устройство") {
|
||||
t.Errorf("reply = %q, want a count", out)
|
||||
}
|
||||
if api.n != 1 {
|
||||
t.Fatalf("wrote %d notes, want 1", api.n)
|
||||
}
|
||||
if !strings.Contains(api.last, "127.0.0.1") {
|
||||
t.Errorf("the written record has no addresses: %q", api.last)
|
||||
}
|
||||
|
||||
// A follow-up question inside the TTL reuses the answer: two questions in
|
||||
// a row must not be two sweeps of the LAN.
|
||||
if _, _ = w.scanSummary(context.Background()); api.n != 1 {
|
||||
t.Errorf("a repeat question rescanned and rewrote the record (%d notes)", api.n)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user