From 424851ba2fb2670ec180b88a4072d9ef3cbc1c3d Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 27 Oct 2026 21:35:31 +0000 Subject: [PATCH] Keep working out the home page cards after startup (closes #211) Server.Start ran the loop that works out the home page's cards on the context it was given. fx ends that context once start-up is done, so the cards were worked out once and never again. The loop now runs on a context that only Stop ends, as the other background parts do. Stop waits for the loop to return, until its own context ends, before it stops the HTTP server. A new test starts a server in a synctest bubble and ends its context at once. On the bubble's fake clock, the cards must be worked out again a minute later, and not after Stop. Model: opus-5-5 --- TODO.md | 3 ++ internal/server/home_internal_test.go | 61 +++++++++++++++++++++++++++ internal/server/server.go | 31 ++++++++++++-- 3 files changed, 91 insertions(+), 4 deletions(-) diff --git a/TODO.md b/TODO.md index 3f59e85..d581f10 100644 --- a/TODO.md +++ b/TODO.md @@ -35,6 +35,9 @@ tested. # Completed Steps +- 2026-10-27: the server works out the home page's cards every minute until it + stops. It used to work them out only once, at startup: the end of the context + that `Start` is given stopped the refreshes (closes #211) - 2026-10-27: README "Rationale" says why RouteWatch exists: to answer our other services' questions about IP addresses, from its own routing table and the WHOIS details it keeps. It still says what RIS Live does not give (closes diff --git a/internal/server/home_internal_test.go b/internal/server/home_internal_test.go index 51253a7..2cfcf30 100644 --- a/internal/server/home_internal_test.go +++ b/internal/server/home_internal_test.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" "testing" + "testing/synctest" "time" "git.eeqj.de/sneak/routewatch/internal/database" @@ -341,6 +342,66 @@ func TestStartedServerWorksOutTheCards(t *testing.T) { } } +// TestStartedServerWorksOutTheCardsUntilStop starts a server with a context +// that ends as soon as Start returns. It runs in a synctest bubble, whose clock +// is fake: time.Sleep there moves the clock on and returns at once. The server +// must work the cards out again a minute later, and no more after Stop. Each +// time it works them out, it keeps a new copy. Before the fix the end of +// Start's context stopped the goroutine that works them out. +func TestStartedServerWorksOutTheCardsUntilStop(t *testing.T) { + t.Parallel() + + // The server is made outside the bubble. The streamer's metrics start a + // goroutine that never returns, and a bubble waits for each of its + // goroutines to return. + s := newServerWithTable(t, newCardsTable()) + + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + + err := s.Start(ctx) + if err != nil { + t.Fatalf("Start: %v", err) + } + + cancel() + + // The bubble's clock moves only while each of its goroutines waits + // on the bubble, and the goroutine that serves HTTP waits on the + // network. The test sends no request, so it closes the HTTP server. + _ = s.srv.Close() + + synctest.Wait() + + started := s.cards.Load() + if started == nil { + t.Fatal("the server did not work out the cards when it started") + } + + time.Sleep(homeCardsRefreshInterval) + synctest.Wait() + + if s.cards.Load() == started { + t.Error("the server did not work out the cards again a minute " + + "after its start context ended") + } + + err = s.Stop(t.Context()) + if err != nil { + t.Fatalf("Stop: %v", err) + } + + stopped := s.cards.Load() + + time.Sleep(homeCardsRefreshInterval) + synctest.Wait() + + if s.cards.Load() != stopped { + t.Error("the server worked out the cards again after Stop") + } + }) +} + // waitUntil fails the test unless done reports true within maxWait. func waitUntil(t *testing.T, maxWait time.Duration, done func() bool) { t.Helper() diff --git a/internal/server/server.go b/internal/server/server.go index ca9765f..7bf4758 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -82,6 +82,12 @@ type Server struct { // cards is the copy of the home page's cards that the pages serve, nil // until the server first works them out cards atomic.Pointer[homeCards] + + // stopCards ends the goroutine that works out the cards, and + // cardsStopped is closed once it has returned. Both are nil until Start + // starts the goroutine. + stopCards context.CancelFunc + cardsStopped chan struct{} } // New creates a new HTTP server that reads routes from table, the routing @@ -111,7 +117,8 @@ func New( // Start listens on the configured port and serves HTTP in the background. // It returns an error if it cannot listen, for example because the port is // already in use. ctx bounds opening the port, not serving. Once listening, -// the server works out the home page's cards every minute until ctx ends. +// the server works out the home page's cards every minute until Stop: the end +// of ctx does not stop it. func (s *Server) Start(ctx context.Context) error { const ( network = "tcp" @@ -148,16 +155,22 @@ func (s *Server) Start(ctx context.Context) error { } }() + ctx, s.stopCards = context.WithCancel(context.WithoutCancel(ctx)) + s.cardsStopped = make(chan struct{}) + go func() { + defer close(s.cardsStopped) + s.keepHomeCardsFresh(ctx, homeCardsRefreshInterval) }() return nil } -// Stop stops the HTTP server. It waits, until ctx ends, for the requests in -// progress to finish, and then closes the connections of those still running -// and returns ctx's error. +// Stop stops the goroutine that works out the home page's cards, and waits +// until it has returned or ctx ends. It then stops the HTTP server. It waits, +// until ctx ends, for the requests in progress to finish, and then closes the +// connections of those still running and returns ctx's error. func (s *Server) Stop(ctx context.Context) error { if s.srv == nil { return nil @@ -165,6 +178,16 @@ func (s *Server) Stop(ctx context.Context) error { s.logger.Info("Stopping HTTP server") + // Start returns before it starts the goroutine when it cannot listen. + if s.stopCards != nil { + s.stopCards() + + select { + case <-s.cardsStopped: + case <-ctx.Done(): + } + } + err := s.srv.Shutdown(ctx) if err != nil { return errors.Join(err, s.srv.Close()) -- 2.54.0