Wait for IRC listener cleanup and HTTP shutdown on stop (closes #159)
check / check (push) Waiting to run

Stopping the server returned before the IRC listener's connections had
run their cleanup, and the database closed under them, so their
sessions, clients and channel memberships stayed and their nicks stayed
taken after a restart.

The IRC listener's stop now waits up to five seconds for every
connection's cleanup, which now runs on a context that stopping does
not cancel. The HTTP server's stop hook now runs Shutdown itself and
waits for it; httpServer is set before serving starts, and the server's
own signal watcher, which raced fx's stop hooks, is gone.

Model: opus-5-5
This commit was merged in pull request #166.
This commit is contained in:
2026-10-20 23:48:30 +02:00
parent 108e381428
commit 497514d3df
6 changed files with 433 additions and 48 deletions
+3 -1
View File
@@ -222,7 +222,9 @@ func (c *Conn) serve(
) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
defer c.cleanup(ctx)
// Stopping the server cancels ctx, and the cleanup must
// still end the session then.
defer c.cleanup(context.WithoutCancel(ctx))
ping := time.AfterFunc(interval, func() {
c.send("PING :" + c.serverSfx)
+4
View File
@@ -85,6 +85,10 @@ const PingInterval = pingInterval
// for a line before it closes the connection.
const PongDeadline = pongDeadline
// StopTimeout is the longest Stop waits for the connections'
// cleanup.
const StopTimeout = stopTimeout
// NewTestConn returns a Conn for nick on tcpConn, without a
// server, database or service behind it.
func NewTestConn(
+43 -1
View File
@@ -16,6 +16,10 @@ import (
"sneak.berlin/go/neoirc/internal/service"
)
// stopTimeout is the longest stop waits for the connections'
// cleanup, which ends their sessions.
const stopTimeout = 5 * time.Second
// Params defines the dependencies for creating an IRC
// Server.
type Params struct {
@@ -40,6 +44,10 @@ type Server struct {
conns map[*Conn]struct{}
cancel context.CancelFunc
// serving counts the connections whose serve, cleanup
// included, has not returned yet.
serving sync.WaitGroup
// pingInterval and pongDeadline are the constants of the
// same names, except in tests, which shorten them.
pingInterval time.Duration
@@ -63,6 +71,7 @@ func New(
listener: nil,
cancel: nil,
mu: sync.Mutex{},
serving: sync.WaitGroup{},
pingInterval: pingInterval,
pongDeadline: pongDeadline,
@@ -112,7 +121,10 @@ func (s *Server) start(ctx context.Context, addr string) error {
return nil
}
// stop shuts down the listener and all connections.
// stop shuts down the listener and all connections, then
// waits up to stopTimeout for every connection's cleanup to
// finish, so that their sessions are ended before the
// database closes.
func (s *Server) stop() {
if s.cancel != nil {
s.cancel()
@@ -127,6 +139,22 @@ func (s *Server) stop() {
c.conn.Close() //nolint:errcheck,gosec
}
s.mu.Unlock()
done := make(chan struct{})
go func() {
s.serving.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(stopTimeout):
s.log.Error(
"irc connections not cleaned up before stop timeout",
"timeout", stopTimeout,
)
}
}
// acceptLoop accepts new connections.
@@ -152,10 +180,24 @@ func (s *Server) acceptLoop(ctx context.Context) {
)
s.mu.Lock()
// stop cancels ctx before it takes s.mu to close the
// connections, so a connection accepted while stop
// runs is either closed and waited for by stop or
// closed here.
if ctx.Err() != nil {
s.mu.Unlock()
tcpConn.Close() //nolint:errcheck,gosec
return
}
s.conns[client] = struct{}{}
s.serving.Add(1)
s.mu.Unlock()
go func() {
defer s.serving.Done()
defer func() {
s.mu.Lock()
delete(s.conns, client)
+115
View File
@@ -4,6 +4,7 @@ import (
"bufio"
"crypto/rand"
"database/sql"
"errors"
"fmt"
"log/slog"
"net"
@@ -812,6 +813,120 @@ func TestQuit(t *testing.T) {
assertContains(t, lines, "goodbye", "QUIT reason")
}
// TestStopEndsSessions checks that stopping the server ends
// a registered connection's session before Stop returns, as
// closing the connection does: the session, its client and
// its channel membership are gone from the database, and a
// channel peer has been sent the QUIT.
func TestStopEndsSessions(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
client := env.dial(t)
client.register("shutirc")
client.joinAndDrain("#shut")
ctx := t.Context()
// A peer on the HTTP API, whose session outlives the stop.
peerID, peerClientID, _, err := env.database.CreateSession(
ctx, "peer", "peer", "", "",
)
if err != nil {
t.Fatalf("create peer session: %v", err)
}
channelID, err := env.database.GetChannelByName(ctx, "#shut")
if err != nil {
t.Fatalf("get channel: %v", err)
}
err = env.database.JoinChannel(ctx, channelID, peerID)
if err != nil {
t.Fatalf("join peer: %v", err)
}
env.srv.Stop()
_, err = env.database.GetSessionByNick(ctx, "shutirc")
if !errors.Is(err, sql.ErrNoRows) {
t.Errorf("look up shutirc after stop: %v, want no rows", err)
}
clients, err := env.database.GetClientCount(ctx)
if err != nil {
t.Fatalf("count clients: %v", err)
}
if clients != 1 {
t.Errorf("%d clients after stop, want only the peer's", clients)
}
members, err := env.database.GetChannelMemberIDs(ctx, channelID)
if err != nil {
t.Fatalf("get members: %v", err)
}
if len(members) != 1 || members[0] != peerID {
t.Errorf(
"members of #shut after stop: %v, want only the peer %d",
members, peerID,
)
}
msgs, _, err := env.database.PollMessages(ctx, peerClientID, 0, 0)
if err != nil {
t.Fatalf("poll peer: %v", err)
}
for _, msg := range msgs {
if msg.Command == "QUIT" && msg.From == "shutirc" {
return
}
}
t.Errorf("peer was not sent the QUIT of shutirc: %v", msgs)
}
// TestStopReturnsWhileCleanupIsStuck checks that Stop stops
// waiting for a connection's cleanup after StopTimeout, so a
// cleanup that cannot finish does not hold up stopping.
func TestStopReturnsWhileCleanupIsStuck(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
client := env.dial(t)
client.register("stuck")
// The test database has one connection. While the test
// holds it, the cleanup's first query waits for it.
held, err := env.database.GetDB().Conn(t.Context())
if err != nil {
t.Fatalf("take the database connection: %v", err)
}
stopped := make(chan struct{})
go func() {
env.srv.Stop()
close(stopped)
}()
select {
case <-stopped:
case <-time.After(ircserver.StopTimeout + testTimeout):
t.Error("Stop did not return while a cleanup was stuck")
}
// Lets the cleanup finish before the database is closed.
err = held.Close()
if err != nil {
t.Errorf("release the database connection: %v", err)
}
}
func TestTopicSetAndGet(t *testing.T) {
t.Parallel()