From 6a514b565c41c37a1cc5116dd087f87939a21b66 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 29 Sep 2026 10:40:36 +0000 Subject: [PATCH 1/2] Build the HTTP router before the server starts (closes #104) The fx OnStart hook started a goroutine that built the router and then began serving, so OnStart returned before the router existed. Code that served requests through the started Server, as the internal/handlers tests do, raced with that goroutine or hit a nil router. OnStart now configures the server, enables Sentry and builds the router itself, and only then starts serving in the background. Sentry is set up first because the routes depend on whether it is enabled. Run, whose only caller was that hook, is folded into it. Model: opus-5-5 --- internal/server/server.go | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/internal/server/server.go b/internal/server/server.go index 50216fa..1006342 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -71,7 +71,14 @@ func New( lifecycle.Append(fx.Hook{ OnStart: func(_ context.Context) error { srv.startupTime = time.Now() - go srv.Run() //nolint:contextcheck + + // Build the router before OnStart returns, so srv can + // handle requests as soon as the app has started. + srv.configure() + srv.enableSentry() + srv.SetupRoutes() + + go srv.serve() //nolint:contextcheck return nil }, @@ -83,13 +90,6 @@ func New( return srv, nil } -// Run starts the server configuration, Sentry, and begins serving. -func (srv *Server) Run() { - srv.configure() - srv.enableSentry() - srv.serve() -} - // ServeHTTP delegates to the chi router. func (srv *Server) ServeHTTP( writer http.ResponseWriter, @@ -202,8 +202,6 @@ func (srv *Server) serveUntilShutdown() { Handler: srv, } - srv.SetupRoutes() - srv.log.Info( "http begin listen", "listenaddr", listenAddr, ) -- 2.54.0 From 6a200ea639620695a34ca7dad6165c2829abc857 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 29 Sep 2026 10:48:48 +0000 Subject: [PATCH 2/2] Read the IRC client's nick under its lock in the relay After registration each IRC connection runs a second goroutine that sends queued messages to the client, and it read c.nick without holding c.mu. The goroutine that reads client commands changes c.nick under c.mu when the client sends NICK, so the race detector failed TestIntegrationTwoClients. Every read of c.nick in relay.go now takes c.mu. No other field that goroutine reads changes after it starts. Model: opus-5-5 --- internal/ircserver/relay.go | 48 ++++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/internal/ircserver/relay.go b/internal/ircserver/relay.go index cb7d185..6d4195f 100644 --- a/internal/ircserver/relay.go +++ b/internal/ircserver/relay.go @@ -128,7 +128,11 @@ func (c *Conn) deliverIRCMessage( default: // Unknown command — deliver as server notice. if text != "" { - c.sendFromServer("NOTICE", c.nick, text) + c.mu.Lock() + nick := c.nick + c.mu.Unlock() + + c.sendFromServer("NOTICE", nick, text) } } } @@ -158,8 +162,12 @@ func (c *Conn) deliverNumeric( _ = json.Unmarshal(msg.Params, ¶ms) } + c.mu.Lock() + nick := c.nick + c.mu.Unlock() + allParams := make([]string, 0, 1+len(params)+1) - allParams = append(allParams, c.nick) + allParams = append(allParams, nick) allParams = append(allParams, params...) if text != "" { @@ -177,8 +185,12 @@ func (c *Conn) deliverTextMessage( from := msg.From target := msg.To + c.mu.Lock() + nick := c.nick + c.mu.Unlock() + // Don't echo our own messages back. - if strings.EqualFold(from, c.nick) { + if strings.EqualFold(from, nick) { return } @@ -192,9 +204,13 @@ func (c *Conn) deliverTextMessage( // deliverJoin sends a JOIN notification. func (c *Conn) deliverJoin(msg *db.IRCMessage) { + c.mu.Lock() + nick := c.nick + c.mu.Unlock() + // Don't echo our own JOINs (we already sent them // during joinChannel). - if strings.EqualFold(msg.From, c.nick) { + if strings.EqualFold(msg.From, nick) { return } @@ -206,7 +222,11 @@ func (c *Conn) deliverJoin(msg *db.IRCMessage) { // deliverPart sends a PART notification. func (c *Conn) deliverPart(msg *db.IRCMessage, text string) { - if strings.EqualFold(msg.From, c.nick) { + c.mu.Lock() + nick := c.nick + c.mu.Unlock() + + if strings.EqualFold(msg.From, nick) { return } @@ -227,7 +247,11 @@ func (c *Conn) deliverNickChange( msg *db.IRCMessage, newNick string, ) { - if strings.EqualFold(msg.From, c.nick) { + c.mu.Lock() + nick := c.nick + c.mu.Unlock() + + if strings.EqualFold(msg.From, nick) { return } @@ -241,7 +265,11 @@ func (c *Conn) deliverQuitMsg( msg *db.IRCMessage, text string, ) { - if strings.EqualFold(msg.From, c.nick) { + c.mu.Lock() + nick := c.nick + c.mu.Unlock() + + if strings.EqualFold(msg.From, nick) { return } @@ -302,7 +330,11 @@ func (c *Conn) deliverInviteMsg( _ *db.IRCMessage, text string, ) { - c.sendFromServer("NOTICE", c.nick, text) + c.mu.Lock() + nick := c.nick + c.mu.Unlock() + + c.sendFromServer("NOTICE", nick, text) } // deliverMode sends a MODE change notification. -- 2.54.0