diff --git a/browse.go b/browse.go index 356878c..eecd64c 100644 --- a/browse.go +++ b/browse.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "net" + "time" ) // BrowseEntry represents a discovered service instance. @@ -91,6 +92,31 @@ func lookupType(ctx context.Context, service string, conn MDNSConn, add AddFunc, } }() + refreshTimer := time.NewTimer(time.Hour) + refreshTimer.Stop() + + sendRefreshQuery := func() { + for _, iface := range MulticastInterfaces(ifaces...) { + q := &Query{msg: m, iface: iface} + if err := conn.SendQuery(q); err != nil { + log.Debug.Println("RefreshQuery:", err) + } + } + } + + resetRefreshTimer := func() { + exp := cache.EarliestExpiration() + if exp.IsZero() { + refreshTimer.Stop() + return + } + remaining := time.Until(exp) + refreshAt := time.Duration(float64(remaining) * 0.8) + if refreshAt > 0 { + refreshTimer.Reset(refreshAt) + } + } + es := []*BrowseEntry{} for { select { @@ -100,9 +126,13 @@ func lookupType(ctx context.Context, service string, conn MDNSConn, add AddFunc, log.Debug.Println("SendQuery:", err) } + case <-refreshTimer.C: + sendRefreshQuery() + case req := <-ch: log.Debug.Printf("Receive message at %s\n%s\n", req.IfaceName(), req.msg) cache.UpdateFrom(req) + resetRefreshTimer() for _, srv := range cache.Services() { if srv.ServiceName() != service { continue diff --git a/cache.go b/cache.go index 23093db..4a9bc6a 100644 --- a/cache.go +++ b/cache.go @@ -11,6 +11,7 @@ import ( // Cache stores services in memory. type Cache struct { services map[string]*Service + earliestExpiry time.Time } // NewCache returns a new in-memory cache. @@ -20,6 +21,24 @@ func NewCache() *Cache { } } +// EarliestExpiration returns the earliest expiration time across all cached services. +func (c *Cache) EarliestExpiration() time.Time { + return c.earliestExpiry +} + +func (c *Cache) updateEarliestExpiry(exp time.Time) { + if c.earliestExpiry.IsZero() || exp.Before(c.earliestExpiry) { + c.earliestExpiry = exp + } +} + +func (c *Cache) recalcEarliestExpiry() { + c.earliestExpiry = time.Time{} + for _, srv := range c.services { + c.updateEarliestExpiry(srv.expiration) + } +} + // Services returns a list of stored services. func (c *Cache) Services() []*Service { tmp := []*Service{} @@ -55,6 +74,7 @@ func (c *Cache) UpdateFrom(req *Request) (adds []*Service, rmvs []*Service) { entry.TTL = ttl entry.expiration = time.Now().Add(ttl) + c.updateEarliestExpiry(entry.expiration) case *dns.SRV: ttl := time.Duration(rr.Hdr.Ttl) * time.Second @@ -74,6 +94,7 @@ func (c *Cache) UpdateFrom(req *Request) (adds []*Service, rmvs []*Service) { entry.SetHostname(rr.Target) entry.TTL = ttl entry.expiration = time.Now().Add(ttl) + c.updateEarliestExpiry(entry.expiration) entry.Port = int(rr.Port) case *dns.A: @@ -112,6 +133,7 @@ func (c *Cache) UpdateFrom(req *Request) (adds []*Service, rmvs []*Service) { entry.Text = text entry.TTL = time.Duration(rr.Hdr.Ttl) * time.Second entry.expiration = time.Now().Add(entry.TTL) + c.updateEarliestExpiry(entry.expiration) } default: // ignore @@ -134,6 +156,10 @@ func (c *Cache) removeExpired() []*Service { } } + if len(outdated) > 0 { + c.recalcEarliestExpiry() + } + return outdated } diff --git a/cache_test.go b/cache_test.go new file mode 100644 index 0000000..654c772 --- /dev/null +++ b/cache_test.go @@ -0,0 +1,91 @@ +package dnssd + +import ( + "testing" + "time" +) + +func TestEarliestExpirationEmpty(t *testing.T) { + c := NewCache() + exp := c.EarliestExpiration() + if !exp.IsZero() { + t.Errorf("expected zero time for empty cache, got %v", exp) + } +} + +func TestEarliestExpirationTracking(t *testing.T) { + c := NewCache() + + later := time.Now().Add(10 * time.Second) + sooner := time.Now().Add(5 * time.Second) + + s1 := newService("Test1._test._tcp.local.") + s1.expiration = later + c.services["t1"] = s1 + c.updateEarliestExpiry(s1.expiration) + + if !c.EarliestExpiration().Equal(later) { + t.Errorf("expected earliest to be %v, got %v", later, c.EarliestExpiration()) + } + + s2 := newService("Test2._test._tcp.local.") + s2.expiration = sooner + c.services["t2"] = s2 + c.updateEarliestExpiry(s2.expiration) + + if !c.EarliestExpiration().Equal(sooner) { + t.Errorf("expected earliest to be %v (sooner), got %v", sooner, c.EarliestExpiration()) + } +} + +func TestEarliestExpirationRecalcOnRemove(t *testing.T) { + c := NewCache() + + s1 := newService("ServiceOne._test._tcp.local.") + s1.expiration = time.Now().Add(-1 * time.Second) // already expired + c.services["svc1"] = s1 + c.updateEarliestExpiry(s1.expiration) + + s2 := newService("ServiceTwo._test._tcp.local.") + s2.expiration = time.Now().Add(10 * time.Second) + c.services["svc2"] = s2 + c.updateEarliestExpiry(s2.expiration) + + if len(c.services) != 2 { + t.Fatalf("expected 2 services, got %d", len(c.services)) + } + + c.removeExpired() + + if len(c.services) != 1 { + t.Fatalf("expected 1 service after removal, got %d", len(c.services)) + } + + // earliest should be s2 (the only remaining service) + if c.EarliestExpiration().IsZero() { + t.Error("earliest should not be zero with one service remaining") + } + + remaining := time.Until(c.EarliestExpiration()) + if remaining < 9*time.Second || remaining > 11*time.Second { + t.Errorf("expected ~10s remaining, got %v", remaining) + } +} + +func TestEarliestExpirationAllRemoved(t *testing.T) { + c := NewCache() + + s1 := newService("Gone._test._tcp.local.") + s1.expiration = time.Now().Add(-1 * time.Second) + c.services["gone"] = s1 + c.updateEarliestExpiry(s1.expiration) + + c.removeExpired() + + if !c.EarliestExpiration().IsZero() { + t.Error("earliest should be zero after all services removed") + } + if len(c.services) != 0 { + t.Errorf("expected 0 services, got %d", len(c.services)) + } +}