mirror of
https://github.com/luxfi/mdns.git
synced 2026-10-30 16:19:50 +00:00
zeroconf multicasts to every interface with no write deadline. An interface that takes no multicast -- awdl0 on a Mac, a downed tunnel -- parks that write forever, and it parks holding the socket's write lock, so nothing queued behind it drains either. zeroconf sends the goodbye before closing the socket, so the close that would break the write never runs, and Shutdown never returns. Stop inherited that: it blocked on Shutdown, so every caller above it blocked too. A service under Kubernetes burned its whole grace period and took a SIGKILL; on one host the dead process kept the port and the replacement came up without a transport. Teardown now runs on its own goroutine and Stop leaves after a second. The goodbye is a courtesy -- peers drop a node when its record ages out -- and a process told to stop has to stop. The browse path writes the same way and strands the same way, so wg.Wait sits inside the same bound. TestDiscoveryStartStop was already failing this way: on a host with an interface like that it panicked on the test timeout at the Shutdown call. It passes now, and TestStopLeavesWedgedLoop pins the bound directly. Co-authored-by: Hanzo Dev <[email protected]>
205 lines
4.9 KiB
Go
205 lines
4.9 KiB
Go
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package mdns
|
|
|
|
import (
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestNewDiscovery(t *testing.T) {
|
|
d := New("_test._tcp", "node-1", 8080)
|
|
if d == nil {
|
|
t.Fatal("expected non-nil discovery")
|
|
}
|
|
if d.NodeID() != "node-1" {
|
|
t.Errorf("expected nodeID 'node-1', got %q", d.NodeID())
|
|
}
|
|
if d.ServiceType() != "_test._tcp" {
|
|
t.Errorf("expected service '_test._tcp', got %q", d.ServiceType())
|
|
}
|
|
}
|
|
|
|
func TestDiscoveryOptions(t *testing.T) {
|
|
d := New("_test._tcp", "node-1", 8080,
|
|
WithBrowseInterval(10*time.Second),
|
|
WithBrowseTimeout(5*time.Second),
|
|
WithStaleTimeout(60*time.Second),
|
|
WithMetadata(map[string]string{
|
|
"version": "1.0.0",
|
|
"role": "worker",
|
|
}),
|
|
)
|
|
|
|
if d.browseInterval != 10*time.Second {
|
|
t.Errorf("expected browseInterval 10s, got %v", d.browseInterval)
|
|
}
|
|
if d.browseTimeout != 5*time.Second {
|
|
t.Errorf("expected browseTimeout 5s, got %v", d.browseTimeout)
|
|
}
|
|
if d.staleTimeout != 60*time.Second {
|
|
t.Errorf("expected staleTimeout 60s, got %v", d.staleTimeout)
|
|
}
|
|
if d.metadata["version"] != "1.0.0" {
|
|
t.Errorf("expected version '1.0.0', got %q", d.metadata["version"])
|
|
}
|
|
if d.metadata["role"] != "worker" {
|
|
t.Errorf("expected role 'worker', got %q", d.metadata["role"])
|
|
}
|
|
}
|
|
|
|
func TestPeerClone(t *testing.T) {
|
|
p := &Peer{
|
|
NodeID: "node-1",
|
|
Addr: "192.168.1.100",
|
|
Port: 8080,
|
|
Metadata: map[string]string{
|
|
"version": "1.0.0",
|
|
},
|
|
LastSeen: time.Now(),
|
|
}
|
|
|
|
clone := p.Clone()
|
|
if clone.NodeID != p.NodeID {
|
|
t.Error("clone nodeID mismatch")
|
|
}
|
|
if clone.Address() != "192.168.1.100:8080" {
|
|
t.Errorf("expected address '192.168.1.100:8080', got %q", clone.Address())
|
|
}
|
|
|
|
// Modify clone metadata, ensure original unchanged
|
|
clone.Metadata["version"] = "2.0.0"
|
|
if p.Metadata["version"] != "1.0.0" {
|
|
t.Error("original metadata was modified")
|
|
}
|
|
}
|
|
|
|
func TestPeerGet(t *testing.T) {
|
|
p := &Peer{
|
|
Metadata: map[string]string{
|
|
"version": "1.0.0",
|
|
},
|
|
}
|
|
|
|
if v := p.Get("version"); v != "1.0.0" {
|
|
t.Errorf("expected '1.0.0', got %q", v)
|
|
}
|
|
if v := p.Get("missing"); v != "" {
|
|
t.Errorf("expected empty string, got %q", v)
|
|
}
|
|
|
|
// Test nil metadata
|
|
p2 := &Peer{}
|
|
if v := p2.Get("anything"); v != "" {
|
|
t.Errorf("expected empty string for nil metadata, got %q", v)
|
|
}
|
|
}
|
|
|
|
func TestLocalIPs(t *testing.T) {
|
|
ips := LocalIPs()
|
|
// Should have at least one IP on most systems
|
|
t.Logf("Found %d local IPs", len(ips))
|
|
for _, ip := range ips {
|
|
t.Logf(" %s", ip)
|
|
}
|
|
}
|
|
|
|
func TestDiscoveryStartStop(t *testing.T) {
|
|
d := New("_mdnstest._tcp", "test-node", 9999)
|
|
|
|
if err := d.Start(); err != nil {
|
|
t.Fatalf("failed to start: %v", err)
|
|
}
|
|
|
|
// Verify it's running
|
|
if d.server == nil {
|
|
t.Error("server should be set after start")
|
|
}
|
|
if d.resolver == nil {
|
|
t.Error("resolver should be set after start")
|
|
}
|
|
|
|
start := time.Now()
|
|
d.Stop()
|
|
if elapsed := time.Since(start); elapsed > stopBound+time.Second {
|
|
t.Fatalf("Stop took %v, want under %v", elapsed, stopBound+time.Second)
|
|
}
|
|
}
|
|
|
|
// A discovery loop parked in a multicast write cannot be interrupted -- the
|
|
// write holds the socket and zeroconf closes the socket only after the write
|
|
// returns. Stop has to come back anyway.
|
|
func TestStopLeavesWedgedLoop(t *testing.T) {
|
|
d := New("_mdnstest3._tcp", "wedged-node", 9003)
|
|
d.wg.Add(1) // stands in for the parked write; nothing will ever call Done
|
|
|
|
start := time.Now()
|
|
d.Stop()
|
|
elapsed := time.Since(start)
|
|
|
|
if elapsed < stopBound {
|
|
t.Errorf("Stop returned after %v, before the %v bound -- it did not wait for teardown at all", elapsed, stopBound)
|
|
}
|
|
if elapsed > stopBound+time.Second {
|
|
t.Errorf("Stop took %v, want under %v", elapsed, stopBound+time.Second)
|
|
}
|
|
}
|
|
|
|
func TestTwoNodeDiscovery(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping discovery test in short mode")
|
|
}
|
|
|
|
var mu sync.Mutex
|
|
discovered := make(map[string]bool)
|
|
|
|
// Start node 1
|
|
d1 := New("_mdnstest2._tcp", "node-1", 9001,
|
|
WithBrowseInterval(1*time.Second),
|
|
WithBrowseTimeout(2*time.Second),
|
|
)
|
|
d1.OnPeer(func(peer *Peer, joined bool) {
|
|
if joined {
|
|
mu.Lock()
|
|
discovered[peer.NodeID] = true
|
|
mu.Unlock()
|
|
}
|
|
})
|
|
if err := d1.Start(); err != nil {
|
|
t.Fatalf("failed to start node-1: %v", err)
|
|
}
|
|
defer d1.Stop()
|
|
|
|
// Start node 2
|
|
d2 := New("_mdnstest2._tcp", "node-2", 9002,
|
|
WithBrowseInterval(1*time.Second),
|
|
WithBrowseTimeout(2*time.Second),
|
|
)
|
|
d2.OnPeer(func(peer *Peer, joined bool) {
|
|
if joined {
|
|
mu.Lock()
|
|
discovered[peer.NodeID] = true
|
|
mu.Unlock()
|
|
}
|
|
})
|
|
if err := d2.Start(); err != nil {
|
|
t.Fatalf("failed to start node-2: %v", err)
|
|
}
|
|
defer d2.Stop()
|
|
|
|
// Wait for discovery
|
|
time.Sleep(5 * time.Second)
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
// Each node should have discovered the other
|
|
if !discovered["node-1"] && !discovered["node-2"] {
|
|
t.Log("No peers discovered - this may be expected in some CI environments")
|
|
} else {
|
|
t.Logf("Discovered peers: %v", discovered)
|
|
}
|
|
}
|