Files
zap/node_stop_test.go
zeekayandHanzo Dev 7d94344c17 Stop the listener before tearing down discovery
Stop took discovery down first. acceptLoop wakes only when the listener closes,
so while discovery was stranded in a multicast write the listener stayed open,
acceptLoop stayed parked in Accept, and the Wait at the end of Stop waited for
it. One blocked write held the whole node. Closing the listener and the
connections first means nothing later in Stop can pin a goroutine Wait is
waiting for.

That Wait is also bounded now. Two of the goroutines under it run code this
package does not own: dispatchLoop calls a registered Handler inline, and a
connection still shaking hands is not yet in the conns map, so Stop cannot close
it and it sits on its own read deadline. Closing everything is what actually
releases them and releases them at once; the bound is there so that neither a
peer nor a handler decides when the process exits.

Start now records the Discovery only once it is running, so Stop after a failed
Start finds nothing to take down -- base calls Stop from its terminate hook
whether or not Start returned an error. The QUIC path went further and left
transClose set behind its own error return, so Stop closed the transport a
second time. Both paths now share one startDiscovery, which also settles their
disagreement about mDNS: TCP treated a discovery failure as best-effort and
QUIC treated it as fatal, so a node on a pod overlay without multicast could
serve over TCP and refused to start over QUIC.

luxfi/mdns v0.2.1 carries the matching bound underneath.

go.sum recorded the pre-rewrite zip hash for luxfi/pq v1.0.3, which fails
verification against the checksum log on any cold cache. Corrected to the hash
sum.golang.org publishes; the go.mod hash was already right.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-25 12:35:34 -07:00

93 lines
2.8 KiB
Go

// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zap
import (
"net"
"testing"
"time"
)
// stopCeiling is what a caller of Stop is promised: mDNS gives up after a
// second, zap waits at most waitBound on its own goroutines, and the rest is
// slack for a loaded machine.
const stopCeiling = 2*time.Second + waitBound
// A node that never started has nothing to take down. Base calls Stop from its
// terminate hook whether or not Start returned an error, so this is the path
// taken every time a port is already held.
func TestStopWithoutStart(t *testing.T) {
n := NewNode(NodeConfig{NodeID: "never-started", Port: pickFreePortNode(t), NoDiscovery: true})
start := time.Now()
n.Stop()
if elapsed := time.Since(start); elapsed > waitBound {
t.Fatalf("Stop took %v on a node that never started, want immediate", elapsed)
}
}
func TestStopAfterFailedStart(t *testing.T) {
held, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer held.Close()
n := NewNode(NodeConfig{
NodeID: "failed-start",
Address: held.Addr().String(),
})
if err := n.Start(); err == nil {
t.Fatal("Start succeeded on a held port")
}
start := time.Now()
n.Stop()
if elapsed := time.Since(start); elapsed > waitBound {
t.Fatalf("Stop took %v after a failed Start, want immediate", elapsed)
}
}
// dispatchLoop runs a registered Handler inline, and a connection still shaking
// hands is not in the conns map for Stop to close. Neither can be made to
// return, so Stop stops waiting on them.
func TestStopLeavesWedgedWork(t *testing.T) {
n := NewNode(NodeConfig{NodeID: "wedged", Port: pickFreePortNode(t), NoDiscovery: true})
n.wg.Add(1) // stands in for that work; nothing will ever call Done
start := time.Now()
n.Stop()
elapsed := time.Since(start)
if elapsed < waitBound {
t.Errorf("Stop returned after %v, inside the %v bound -- it did not wait at all", elapsed, waitBound)
}
if elapsed > waitBound+time.Second {
t.Errorf("Stop took %v, want under %v", elapsed, waitBound+time.Second)
}
}
// The whole teardown, discovery included, on a node that really started. On a
// host with an interface that takes no multicast this is the path that used to
// never return.
func TestStopBounded(t *testing.T) {
port := pickFreePortNode(t)
n := NewNode(NodeConfig{NodeID: "bounded", ServiceType: "_zapstop._tcp", Port: port})
if err := n.Start(); err != nil {
t.Fatalf("start: %v", err)
}
start := time.Now()
n.Stop()
if elapsed := time.Since(start); elapsed > stopCeiling {
t.Fatalf("Stop took %v, want under %v", elapsed, stopCeiling)
}
ln, err := net.Listen("tcp", n.listenAddr())
if err != nil {
t.Fatalf("port %d still held after Stop: %v", port, err)
}
ln.Close()
}